Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1644505043 | ||
|
|
e142524bfc | ||
|
|
8fac30fc02 | ||
|
|
c3b1f933b1 | ||
|
|
a839915e3e | ||
|
|
5d95f4cd39 | ||
|
|
01a3b08dac | ||
|
|
c87513b648 | ||
|
|
4f3cbc866e | ||
|
|
60548f2062 | ||
|
|
74dab43e89 | ||
|
|
01a8025140 | ||
|
|
8346748f5a | ||
|
|
38002019ea | ||
|
|
32eb27c8a6 | ||
|
|
d33b4ff64f | ||
|
|
6f2e0cbca0 | ||
|
|
95e6eb7602 | ||
|
|
f50f49fc41 | ||
|
|
5ea2abff85 | ||
|
|
9d131b9a09 | ||
|
|
5f77fceaca | ||
|
|
5d2462e5a0 | ||
|
|
0000a03ad6 | ||
|
|
d697cb2bc9 | ||
|
|
ddd071f7e5 | ||
|
|
4629ab4335 | ||
|
|
0f303c4b79 | ||
|
|
7c7c259d0a | ||
|
|
fe6bcc5288 | ||
|
|
e26ed3aa9c | ||
|
|
efb96b6e60 | ||
|
|
342cafed0e | ||
|
|
fc7c6b59d7 | ||
|
|
a77ec0447a | ||
|
|
82b5ac8160 | ||
|
|
299659eca2 | ||
|
|
6f53a3ed41 | ||
|
|
1bca7df5ab | ||
|
|
b5210c3916 | ||
|
|
755647e29c | ||
|
|
018c9d1a23 | ||
|
|
c89011819f | ||
|
|
c00d48199b | ||
|
|
de28469210 | ||
|
|
0e2f83fee0 | ||
|
|
1dd3dea231 | ||
|
|
db783c4225 | ||
|
|
8a4716bdbd | ||
|
|
9d37966c10 | ||
|
|
ac16ae56ea | ||
|
|
df3e665978 | ||
|
|
8a26764d2c | ||
|
|
ce365a706e | ||
|
|
be7daac5fb | ||
|
|
e32b7af5eb | ||
|
|
d0693206c3 | ||
|
|
b079070c30 | ||
|
|
e68e9c38a8 | ||
|
|
534150ecf9 | ||
|
|
98da7065e0 | ||
|
|
58e0194aa6 | ||
|
|
7864c46279 | ||
|
|
e3970e53e1 | ||
|
|
be46cfdffa | ||
|
|
2e00a610cb | ||
|
|
61841dd7a5 | ||
|
|
5aa0f3260d | ||
|
|
d0efa200d9 | ||
|
|
c26b5574e0 | ||
|
|
89a6372565 | ||
|
|
f40cad4713 | ||
|
|
855906d990 | ||
|
|
08da526ddd | ||
|
|
2395e14382 | ||
|
|
91c1703bb5 | ||
|
|
6a548e3dac | ||
|
|
f28ac8fa09 | ||
|
|
5e86670a5b | ||
|
|
62d43df2ad | ||
|
|
cb7510f79d | ||
|
|
08c6302bf6 | ||
|
|
6e6c3fa124 | ||
|
|
85b1867490 | ||
|
|
545a575e1a | ||
|
|
ef13fefb69 | ||
|
|
ed59849392 | ||
|
|
5eb160861d | ||
|
|
6829bcff91 | ||
|
|
b1ca8c46bf | ||
|
|
888089b2d5 | ||
|
|
fb60449dc2 | ||
|
|
25854b04f9 | ||
|
|
c34056555f | ||
|
|
f4074a37ad | ||
|
|
6146d41197 | ||
|
|
4cce5b7cfe | ||
|
|
bc89b17a4c | ||
|
|
d023b255a7 | ||
|
|
71cc961f5c | ||
|
|
9bc7bbe33f | ||
|
|
6cc03364fb | ||
|
|
d623d81a44 | ||
|
|
5cc4dcfb29 | ||
|
|
893fae27d3 | ||
|
|
29051f3503 | ||
|
|
0a3ad4e7f5 | ||
|
|
f4d391b240 | ||
|
|
8280c6b337 | ||
|
|
bebbda9465 | ||
|
|
2496d6f6e0 | ||
|
|
179083aec4 | ||
|
|
aa74984fb0 | ||
|
|
18ab4cd53a | ||
|
|
e94bb86b61 | ||
|
|
3993d679e6 | ||
|
|
83cae16319 | ||
|
|
99ebe1a400 | ||
|
|
204d803937 | ||
|
|
2a08ae0b21 | ||
|
|
8dc7ae8bfc | ||
|
|
a4dbb49006 | ||
|
|
6531a9325d | ||
|
|
ff44f0e537 | ||
|
|
dec34fc384 | ||
|
|
f5727daedb | ||
|
|
3857f2fd1a | ||
|
|
e951e97790 | ||
|
|
d0cade633a | ||
|
|
490059666f | ||
|
|
5852bb00f2 | ||
|
|
749ad4a694 | ||
|
|
0f752acae5 | ||
|
|
652bd220c2 | ||
|
|
9f2eaaf04e | ||
|
|
63d3f0e86c | ||
|
|
25e7d234ba | ||
|
|
9968775210 | ||
|
|
0d63fd4dae | ||
|
|
109958342d | ||
|
|
491546a97c | ||
|
|
7a3f734728 | ||
|
|
d59a07a8c5 | ||
|
|
5a77ae5084 | ||
|
|
dcb5bd80e3 | ||
|
|
3b8fc99f06 | ||
|
|
bda8d10f10 | ||
|
|
0fe7b433d6 | ||
|
|
8d96e176b4 | ||
|
|
af9bc861cf | ||
|
|
ab4bbea9fd | ||
|
|
fbd4b3739e | ||
|
|
395e71164f | ||
|
|
99c277e2ee | ||
|
|
9287e0d29b | ||
|
|
f2f2bafc39 | ||
|
|
5876198b5e | ||
|
|
e46bf381c7 | ||
|
|
3bf0ccf207 | ||
|
|
e41b4ffc56 | ||
|
|
a9dd646573 | ||
|
|
e2160aabcd | ||
|
|
5d085e02bf |
No files matched your search
@@ -15,6 +15,7 @@ dist
|
||||
build
|
||||
.next
|
||||
out
|
||||
www
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4321
|
||||
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
|
||||
BASE_URL=/
|
||||
|
||||
# Database Configuration
|
||||
# For self-hosted, SQLite is used by default
|
||||
@@ -18,9 +20,32 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
|
||||
# ===========================================
|
||||
# REVERSE PROXY CONFIGURATION
|
||||
# ===========================================
|
||||
# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.).
|
||||
# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank.
|
||||
#
|
||||
# Set all three to your external URL, e.g.:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
#
|
||||
# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set:
|
||||
# BASE_URL=/mirror
|
||||
# BETTER_AUTH_URL=https://git.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
#
|
||||
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
|
||||
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
|
||||
# (e.g. https://gitea-mirror.example.com,https://alt.example.com)
|
||||
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
|
||||
# ===========================================
|
||||
# DOCKER CONFIGURATION (Optional)
|
||||
# ===========================================
|
||||
@@ -46,7 +71,9 @@ DOCKER_TAG=latest
|
||||
# INCLUDE_ARCHIVED=false
|
||||
# SKIP_FORKS=false
|
||||
# MIRROR_STARRED=false
|
||||
# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos
|
||||
# STARRED_REPOS_ORG=starred # Organization name for starred repos
|
||||
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner
|
||||
|
||||
# Organization Settings
|
||||
# MIRROR_ORGANIZATIONS=false
|
||||
@@ -66,6 +93,7 @@ DOCKER_TAG=latest
|
||||
|
||||
# Basic Gitea Settings
|
||||
# GITEA_URL=http://gitea:3000
|
||||
# GITEA_EXTERNAL_URL=https://gitea.example.com # Optional: used only for UI links
|
||||
# GITEA_TOKEN=your-local-gitea-token
|
||||
# GITEA_USERNAME=your-local-gitea-username
|
||||
# GITEA_ORGANIZATION=github-mirrors # Default organization for single-org strategy
|
||||
@@ -183,4 +211,4 @@ DOCKER_TAG=latest
|
||||
# ===========================================
|
||||
|
||||
# TLS/SSL Configuration
|
||||
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
|
||||
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -30,17 +30,22 @@ This workflow runs on all branches and pull requests. It:
|
||||
|
||||
### Docker Build and Push (`docker-build.yml`)
|
||||
|
||||
This workflow builds and pushes Docker images to GitHub Container Registry (ghcr.io), but only when changes are merged to the main branch.
|
||||
This workflow builds Docker images on pushes and pull requests, and pushes to GitHub Container Registry (ghcr.io) when permissions allow (main/tags and same-repo PRs).
|
||||
|
||||
**When it runs:**
|
||||
- On push to the main branch
|
||||
- On tag creation (v*)
|
||||
- On pull requests (build + scan; push only for same-repo PRs)
|
||||
|
||||
**Key features:**
|
||||
- Builds multi-architecture images (amd64 and arm64)
|
||||
- Pushes images only on main branch, not for PRs
|
||||
- Pushes images for main/tags and same-repo PRs
|
||||
- Skips registry push for fork PRs (avoids package write permission failures)
|
||||
- Uses build caching to speed up builds
|
||||
- Creates multiple tags for each image (latest, semver, sha)
|
||||
- Auto-syncs `package.json` version from `v*` tags during release builds
|
||||
- Validates release tags use semver format before building
|
||||
- After tag builds succeed, writes the same version back to `main/package.json`
|
||||
|
||||
### Docker Security Scan (`docker-scan.yml`)
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@ on:
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'docs/**'
|
||||
- 'www/**'
|
||||
- 'helm/**'
|
||||
pull_request:
|
||||
branches: [ '*' ]
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'docs/**'
|
||||
- 'www/**'
|
||||
- 'helm/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -20,6 +24,7 @@ jobs:
|
||||
build-and-test:
|
||||
name: Build and Test Astro Project
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -28,7 +33,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: '1.2.16'
|
||||
bun-version: '1.3.10'
|
||||
|
||||
- name: Check lockfile and install dependencies
|
||||
run: |
|
||||
@@ -43,6 +48,12 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: bun test --coverage
|
||||
|
||||
- name: Check Drizzle migrations
|
||||
run: bun run db:check
|
||||
|
||||
- name: Validate migrations (SQLite lint + upgrade path)
|
||||
run: bun test:migrations
|
||||
|
||||
- name: Build Astro project
|
||||
run: bunx --bun astro build
|
||||
|
||||
@@ -36,6 +36,7 @@ env:
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -55,6 +56,7 @@ jobs:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log into registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -75,13 +77,34 @@ jobs:
|
||||
id: tag_version
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/v* ]]; then
|
||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
echo "Using version tag: ${GITHUB_REF#refs/tags/}"
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/}"
|
||||
if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release tag '${TAG_VERSION}' is invalid. Expected semver tag format like v1.2.3 or v1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
APP_VERSION="${TAG_VERSION#v}"
|
||||
echo "VERSION=${TAG_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "APP_VERSION=${APP_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Using version tag: ${TAG_VERSION}"
|
||||
else
|
||||
echo "VERSION=latest" >> $GITHUB_OUTPUT
|
||||
echo "APP_VERSION=dev" >> $GITHUB_OUTPUT
|
||||
echo "No version tag, using 'latest'"
|
||||
fi
|
||||
|
||||
# Keep version files aligned automatically for tag-based releases
|
||||
- name: Sync app version from release tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
VERSION="${{ steps.tag_version.outputs.APP_VERSION }}"
|
||||
echo "Syncing package.json version to ${VERSION}"
|
||||
|
||||
jq --arg version "${VERSION}" '.version = $version' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
echo "Version sync diff (package.json):"
|
||||
git --no-pager diff -- package.json
|
||||
|
||||
# Extract metadata for Docker
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
@@ -101,36 +124,41 @@ jobs:
|
||||
# Build and push Docker image
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false # Disable provenance to avoid unknown/unknown
|
||||
sbom: false # Disable sbom to avoid unknown/unknown
|
||||
|
||||
# Load image locally for security scanning (PRs only)
|
||||
- name: Load image for scanning
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: gitea-mirror:scan
|
||||
cache-from: type=gha
|
||||
provenance: false # Disable provenance to avoid unknown/unknown
|
||||
sbom: false # Disable sbom to avoid unknown/unknown
|
||||
|
||||
# Wait for image to be available in registry
|
||||
- name: Wait for image availability
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
echo "Waiting for image to be available in registry..."
|
||||
sleep 5
|
||||
|
||||
# Add comment to PR with image details
|
||||
- name: Comment PR with image tag
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -169,8 +197,8 @@ jobs:
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321
|
||||
\`\`\`
|
||||
|
||||
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and only built for \`linux/amd64\` to speed up CI.
|
||||
> Production images (\`latest\`, version tags) are multi-platform (\`linux/amd64\`, \`linux/arm64\`).
|
||||
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and built for both \`linux/amd64\` and \`linux/arm64\`.
|
||||
> Production images (\`latest\`, version tags) use the same multi-platform set.
|
||||
|
||||
---
|
||||
📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`;
|
||||
@@ -225,8 +253,49 @@ jobs:
|
||||
|
||||
# Upload security scan results to GitHub Security tab
|
||||
- name: Upload Docker Scout scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
sarif_file: scout-results.sarif
|
||||
|
||||
sync-version-main:
|
||||
name: Sync package.json version back to main
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout default branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Update package.json version on main
|
||||
env:
|
||||
TAG_VERSION: ${{ github.ref_name }}
|
||||
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
if [[ ! "$TAG_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release tag '${TAG_VERSION}' is invalid. Expected semver tag format like v1.2.3 or v1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_VERSION="${TAG_VERSION#v}"
|
||||
echo "Syncing ${TARGET_BRANCH}/package.json to ${APP_VERSION}"
|
||||
|
||||
jq --arg version "${APP_VERSION}" '.version = $version' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
if git diff --quiet -- package.json; then
|
||||
echo "package.json on ${TARGET_BRANCH} already at ${APP_VERSION}; nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json
|
||||
git commit -m "chore: sync version to ${APP_VERSION}"
|
||||
git push origin "HEAD:${TARGET_BRANCH}"
|
||||
@@ -0,0 +1,285 @@
|
||||
name: E2E Integration Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "LICENSE"
|
||||
- "www/**"
|
||||
- "helm/**"
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "LICENSE"
|
||||
- "www/**"
|
||||
- "helm/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
debug_enabled:
|
||||
description: "Enable debug logging"
|
||||
required: false
|
||||
default: "false"
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITEA_PORT: 3333
|
||||
FAKE_GITHUB_PORT: 4580
|
||||
GIT_SERVER_PORT: 4590
|
||||
APP_PORT: 4321
|
||||
BUN_VERSION: "1.3.10"
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
name: E2E Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install
|
||||
echo "✓ Dependencies installed"
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
npx playwright install-deps chromium
|
||||
echo "✓ Playwright ready"
|
||||
|
||||
- name: Create test git repositories
|
||||
run: |
|
||||
echo "Creating bare git repos for E2E testing..."
|
||||
bun run tests/e2e/create-test-repos.ts --output-dir tests/e2e/git-repos
|
||||
|
||||
if [ ! -f tests/e2e/git-repos/manifest.json ]; then
|
||||
echo "ERROR: Test git repos were not created (manifest.json missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Test repos created:"
|
||||
cat tests/e2e/git-repos/manifest.json | jq -r '.repos[] | " • \(.owner)/\(.name) — \(.description)"'
|
||||
|
||||
- name: Start Gitea and git-server containers
|
||||
run: |
|
||||
echo "Starting containers via docker compose..."
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml up -d
|
||||
|
||||
# Wait for git-server
|
||||
echo "Waiting for git HTTP server..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:${{ env.GIT_SERVER_PORT }}/manifest.json > /dev/null 2>&1; then
|
||||
echo "✓ Git HTTP server is ready"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "ERROR: Git HTTP server did not start"
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml logs git-server
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Wait for Gitea
|
||||
echo "Waiting for Gitea to be ready..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:${{ env.GITEA_PORT }}/api/v1/version > /dev/null 2>&1; then
|
||||
version=$(curl -sf http://localhost:${{ env.GITEA_PORT }}/api/v1/version | jq -r '.version // "unknown"')
|
||||
echo "✓ Gitea is ready (version: $version)"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "ERROR: Gitea did not become healthy within 120s"
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml logs gitea-e2e --tail=30
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Initialize database
|
||||
run: |
|
||||
bun run manage-db init
|
||||
echo "✓ Database initialized"
|
||||
|
||||
- name: Build application
|
||||
env:
|
||||
GH_API_URL: http://localhost:4580
|
||||
BETTER_AUTH_SECRET: e2e-test-secret
|
||||
run: |
|
||||
bun run build
|
||||
echo "✓ Build complete"
|
||||
|
||||
- name: Start fake GitHub API server
|
||||
run: |
|
||||
# Start with GIT_SERVER_URL pointing to the git-server container name
|
||||
# (Gitea will resolve it via Docker networking)
|
||||
PORT=${{ env.FAKE_GITHUB_PORT }} GIT_SERVER_URL="http://git-server" \
|
||||
npx tsx tests/e2e/fake-github-server.ts &
|
||||
echo $! > /tmp/fake-github.pid
|
||||
|
||||
echo "Waiting for fake GitHub API..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:${{ env.FAKE_GITHUB_PORT }}/___mgmt/health > /dev/null 2>&1; then
|
||||
echo "✓ Fake GitHub API is ready"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "ERROR: Fake GitHub API did not start"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Ensure clone URLs are set for the git-server container
|
||||
curl -sf -X POST http://localhost:${{ env.FAKE_GITHUB_PORT }}/___mgmt/set-clone-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "http://git-server"}' || true
|
||||
echo "✓ Clone URLs configured for git-server container"
|
||||
|
||||
- name: Start gitea-mirror application
|
||||
env:
|
||||
GH_API_URL: http://localhost:4580
|
||||
BETTER_AUTH_SECRET: e2e-test-secret
|
||||
BETTER_AUTH_URL: http://localhost:4321
|
||||
DATABASE_URL: file:data/gitea-mirror.db
|
||||
HOST: 0.0.0.0
|
||||
PORT: ${{ env.APP_PORT }}
|
||||
NODE_ENV: production
|
||||
PRE_SYNC_BACKUP_ENABLED: "false"
|
||||
ENCRYPTION_SECRET: "e2e-encryption-secret-32char!!"
|
||||
run: |
|
||||
# Re-init DB in case build step cleared it
|
||||
bun run manage-db init 2>/dev/null || true
|
||||
|
||||
bun run start &
|
||||
echo $! > /tmp/app.pid
|
||||
|
||||
echo "Waiting for gitea-mirror app..."
|
||||
for i in $(seq 1 90); do
|
||||
if curl -sf http://localhost:${{ env.APP_PORT }}/api/health > /dev/null 2>&1 || \
|
||||
curl -sf -o /dev/null -w "%{http_code}" http://localhost:${{ env.APP_PORT }}/ 2>/dev/null | grep -q "^[23]"; then
|
||||
echo "✓ gitea-mirror app is ready"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 $(cat /tmp/app.pid) 2>/dev/null; then
|
||||
echo "ERROR: App process died"
|
||||
exit 1
|
||||
fi
|
||||
if [ $i -eq 90 ]; then
|
||||
echo "ERROR: gitea-mirror app did not start within 180s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
APP_URL: http://localhost:${{ env.APP_PORT }}
|
||||
GITEA_URL: http://localhost:${{ env.GITEA_PORT }}
|
||||
FAKE_GITHUB_URL: http://localhost:${{ env.FAKE_GITHUB_PORT }}
|
||||
GIT_SERVER_URL: http://localhost:${{ env.GIT_SERVER_PORT }}
|
||||
CI: true
|
||||
run: |
|
||||
mkdir -p tests/e2e/test-results
|
||||
npx playwright test \
|
||||
--config tests/e2e/playwright.config.ts \
|
||||
--reporter=github,html
|
||||
|
||||
- name: Diagnostic info on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo " Diagnostic Information"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
|
||||
echo ""
|
||||
echo "── Git server status ──"
|
||||
curl -sf http://localhost:${{ env.GIT_SERVER_PORT }}/manifest.json 2>/dev/null | jq . || echo "(unreachable)"
|
||||
|
||||
echo ""
|
||||
echo "── Gitea status ──"
|
||||
curl -sf http://localhost:${{ env.GITEA_PORT }}/api/v1/version 2>/dev/null || echo "(unreachable)"
|
||||
|
||||
echo ""
|
||||
echo "── Fake GitHub status ──"
|
||||
curl -sf http://localhost:${{ env.FAKE_GITHUB_PORT }}/___mgmt/health 2>/dev/null | jq . || echo "(unreachable)"
|
||||
|
||||
echo ""
|
||||
echo "── App status ──"
|
||||
curl -sf http://localhost:${{ env.APP_PORT }}/api/health 2>/dev/null || echo "(unreachable)"
|
||||
|
||||
echo ""
|
||||
echo "── Docker containers ──"
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml ps 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "── Gitea container logs (last 50 lines) ──"
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml logs gitea-e2e --tail=50 2>/dev/null || echo "(no container)"
|
||||
|
||||
echo ""
|
||||
echo "── Git server logs (last 20 lines) ──"
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml logs git-server --tail=20 2>/dev/null || echo "(no container)"
|
||||
|
||||
echo ""
|
||||
echo "── Running processes ──"
|
||||
ps aux | grep -E "(fake-github|astro|bun|node)" | grep -v grep || true
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-playwright-report
|
||||
path: tests/e2e/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 14
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
# Stop background processes
|
||||
if [ -f /tmp/fake-github.pid ]; then
|
||||
kill $(cat /tmp/fake-github.pid) 2>/dev/null || true
|
||||
rm -f /tmp/fake-github.pid
|
||||
fi
|
||||
if [ -f /tmp/app.pid ]; then
|
||||
kill $(cat /tmp/app.pid) 2>/dev/null || true
|
||||
rm -f /tmp/app.pid
|
||||
fi
|
||||
|
||||
# Stop containers
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml down --volumes --remove-orphans 2>/dev/null || true
|
||||
|
||||
echo "✓ Cleanup complete"
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
yamllint:
|
||||
name: Lint YAML
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
@@ -35,6 +36,7 @@ jobs:
|
||||
helm-template:
|
||||
name: Helm lint & template
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Helm
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Nix Flake Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, nix]
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NIX_CONFIG: |
|
||||
accept-flake-config = true
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Setup Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
|
||||
- name: Regenerate bun.nix from bun.lock
|
||||
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
|
||||
|
||||
- name: Check flake
|
||||
run: nix flake check --accept-flake-config
|
||||
|
||||
- name: Show flake info
|
||||
run: nix flake show --accept-flake-config
|
||||
|
||||
- name: Build package
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
run: nix build --print-build-logs --accept-flake-config
|
||||
@@ -32,3 +32,23 @@ certs/*.pem
|
||||
certs/*.cer
|
||||
!certs/README.md
|
||||
|
||||
# Nix build artifacts
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
|
||||
# E2E test artifacts
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/.auth/
|
||||
tests/e2e/e2e-storage-state.json
|
||||
tests/e2e/.fake-github.pid
|
||||
tests/e2e/.app.pid
|
||||
tests/e2e/git-repos/
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
@@ -1,51 +1,91 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.2.23-alpine AS base
|
||||
FROM oven/bun:1.3.10-debian AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ----------------------------
|
||||
FROM base AS deps
|
||||
FROM base AS builder
|
||||
COPY package.json ./
|
||||
COPY bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# ----------------------------
|
||||
FROM deps AS builder
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
RUN mkdir -p dist/scripts && \
|
||||
for script in scripts/*.ts; do \
|
||||
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
|
||||
done
|
||||
for script in scripts/*.ts; do \
|
||||
if [ "$(basename "$script")" = "runtime-server.ts" ]; then continue; fi; \
|
||||
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
|
||||
done
|
||||
|
||||
# ----------------------------
|
||||
FROM deps AS pruner
|
||||
RUN bun install --production --frozen-lockfile
|
||||
FROM base AS pruner
|
||||
COPY package.json ./
|
||||
COPY bun.lock* ./
|
||||
RUN bun install --production --omit=peer --frozen-lockfile
|
||||
|
||||
# ----------------------------
|
||||
FROM base AS runner
|
||||
# Build git-lfs from source with patched Go to resolve Go stdlib CVEs
|
||||
FROM debian:trixie-slim AS git-lfs-builder
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates git make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ARG GO_VERSION=1.25.8
|
||||
ARG GIT_LFS_VERSION=3.7.1
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||
&& rm /tmp/go.tar.gz
|
||||
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
|
||||
# Force using our installed Go (not the version in go.mod toolchain directive)
|
||||
ENV GOTOOLCHAIN=local
|
||||
RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lfs/git-lfs.git /tmp/git-lfs \
|
||||
&& cd /tmp/git-lfs \
|
||||
&& go get golang.org/x/crypto@latest \
|
||||
&& go mod tidy \
|
||||
&& make \
|
||||
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
|
||||
|
||||
# ----------------------------
|
||||
FROM oven/bun:1.3.10-debian AS runner
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
git wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
|
||||
RUN git lfs install
|
||||
COPY --from=pruner /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/scripts/runtime-server.ts ./scripts/runtime-server.ts
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
|
||||
# Remove build-only packages that are not needed at runtime
|
||||
# (esbuild, vite, rollup, tailwind, svgo — all only used during `astro build`)
|
||||
RUN rm -rf node_modules/esbuild node_modules/@esbuild \
|
||||
node_modules/rollup node_modules/@rollup \
|
||||
node_modules/vite node_modules/svgo \
|
||||
node_modules/@tailwindcss/vite \
|
||||
node_modules/tailwindcss
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV DATABASE_URL=file:data/gitea-mirror.db
|
||||
ENV BASE_URL=/
|
||||
|
||||
# Create directories and setup permissions
|
||||
RUN mkdir -p /app/certs && \
|
||||
chmod +x ./docker-entrypoint.sh && \
|
||||
mkdir -p /app/data && \
|
||||
addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 gitea-mirror && \
|
||||
chown -R gitea-mirror:nodejs /app/data && \
|
||||
chown -R gitea-mirror:nodejs /app/certs
|
||||
chmod +x ./docker-entrypoint.sh && \
|
||||
mkdir -p /app/data && \
|
||||
groupadd --system --gid 1001 nodejs && \
|
||||
useradd --system --uid 1001 --gid 1001 --create-home --home-dir /home/gitea-mirror gitea-mirror && \
|
||||
chown -R gitea-mirror:nodejs /app/data && \
|
||||
chown -R gitea-mirror:nodejs /app/certs && \
|
||||
chown -R gitea-mirror:nodejs /home/gitea-mirror
|
||||
|
||||
USER gitea-mirror
|
||||
|
||||
@@ -53,6 +93,6 @@ VOLUME /app/data
|
||||
EXPOSE 4321
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
|
||||
CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,189 @@
|
||||
# Nix Deployment Quick Reference
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
# From GitHub (no clone needed!)
|
||||
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
|
||||
# Or from local clone
|
||||
nix run --extra-experimental-features 'nix-command flakes' .#gitea-mirror
|
||||
```
|
||||
|
||||
Secrets auto-generate, database auto-initializes, and the web UI starts at http://localhost:4321.
|
||||
|
||||
**Note:** If you have flakes enabled in your nix config, you can omit `--extra-experimental-features 'nix-command flakes'`
|
||||
|
||||
---
|
||||
|
||||
## Installation Options
|
||||
|
||||
### 1. Run Without Installing (from GitHub)
|
||||
```bash
|
||||
# Latest version from main branch
|
||||
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
|
||||
# Pin to specific version
|
||||
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
|
||||
```
|
||||
|
||||
### 2. Install to Profile
|
||||
```bash
|
||||
# Install from GitHub
|
||||
nix profile install --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
|
||||
# Run the installed binary
|
||||
gitea-mirror
|
||||
```
|
||||
|
||||
### 3. Use Local Clone
|
||||
```bash
|
||||
# Clone and run
|
||||
git clone https://github.com/RayLabsHQ/gitea-mirror.git
|
||||
cd gitea-mirror
|
||||
nix run --extra-experimental-features 'nix-command flakes' .#gitea-mirror
|
||||
```
|
||||
|
||||
### 4. NixOS System Service
|
||||
```nix
|
||||
# configuration.nix
|
||||
{
|
||||
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
betterAuthUrl = "https://mirror.example.com"; # For production
|
||||
openFirewall = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Development (Local Clone)
|
||||
```bash
|
||||
nix develop --extra-experimental-features 'nix-command flakes'
|
||||
# or
|
||||
direnv allow # Handles experimental features automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enable Flakes Permanently (Recommended)
|
||||
|
||||
To avoid typing `--extra-experimental-features` every time, add to `~/.config/nix/nix.conf`:
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Gets Auto-Generated?
|
||||
|
||||
On first run, the wrapper automatically:
|
||||
|
||||
1. Creates `~/.local/share/gitea-mirror/` (or `$DATA_DIR`)
|
||||
2. Generates `BETTER_AUTH_SECRET` → `.better_auth_secret`
|
||||
3. Generates `ENCRYPTION_SECRET` → `.encryption_secret`
|
||||
4. Initializes SQLite database
|
||||
5. Runs startup recovery and repair scripts
|
||||
6. Starts the application
|
||||
|
||||
---
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Database management
|
||||
gitea-mirror-db init # Initialize database
|
||||
gitea-mirror-db check # Health check
|
||||
gitea-mirror-db fix # Fix issues
|
||||
|
||||
# Development (add --extra-experimental-features 'nix-command flakes' if needed)
|
||||
nix develop # Enter dev shell
|
||||
nix build # Build package
|
||||
nix flake check # Validate flake
|
||||
nix flake update # Update dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All vars from `docker-compose.alt.yml` are supported:
|
||||
|
||||
```bash
|
||||
DATA_DIR="$HOME/.local/share/gitea-mirror"
|
||||
PORT=4321
|
||||
HOST="0.0.0.0"
|
||||
BETTER_AUTH_URL="http://localhost:4321"
|
||||
|
||||
# Secrets (auto-generated if not set)
|
||||
BETTER_AUTH_SECRET=auto-generated
|
||||
ENCRYPTION_SECRET=auto-generated
|
||||
|
||||
# Concurrency (for perfect ordering, set both to 1)
|
||||
MIRROR_ISSUE_CONCURRENCY=3
|
||||
MIRROR_PULL_REQUEST_CONCURRENCY=5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NixOS Module Options
|
||||
|
||||
```nix
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
package = ...; # Override package
|
||||
dataDir = "/var/lib/gitea-mirror"; # Data location
|
||||
user = "gitea-mirror"; # Service user
|
||||
group = "gitea-mirror"; # Service group
|
||||
host = "0.0.0.0"; # Bind address
|
||||
port = 4321; # Listen port
|
||||
betterAuthUrl = "http://..."; # External URL
|
||||
betterAuthTrustedOrigins = "..."; # CORS origins
|
||||
mirrorIssueConcurrency = 3; # Concurrency
|
||||
mirrorPullRequestConcurrency = 5; # Concurrency
|
||||
environmentFile = null; # Optional secrets file
|
||||
openFirewall = true; # Open firewall
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Docker vs Nix
|
||||
|
||||
| Feature | Docker | Nix |
|
||||
|---------|--------|-----|
|
||||
| **Config Required** | BETTER_AUTH_SECRET | None (auto-generated) |
|
||||
| **Startup** | `docker-compose up` | `nix run .#gitea-mirror` |
|
||||
| **Service** | Docker daemon | systemd (NixOS) |
|
||||
| **Updates** | `docker pull` | `nix flake update` |
|
||||
| **Reproducible** | Image-based | Hash-based |
|
||||
|
||||
---
|
||||
|
||||
## Full Documentation
|
||||
|
||||
- **[docs/NIX_DEPLOYMENT.md](docs/NIX_DEPLOYMENT.md)** - Complete deployment guide
|
||||
- NixOS module configuration
|
||||
- Home Manager integration
|
||||
- Production deployment examples
|
||||
- Migration from Docker
|
||||
- Troubleshooting guide
|
||||
|
||||
- **[docs/NIX_DISTRIBUTION.md](docs/NIX_DISTRIBUTION.md)** - Distribution guide for maintainers
|
||||
- How users consume the package
|
||||
- CI build caching
|
||||
- Releasing new versions
|
||||
- Submitting to nixpkgs
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Zero-config deployment** - Runs immediately without setup
|
||||
- **Auto-secret generation** - Secure secrets created and persisted
|
||||
- **Startup recovery** - Handles interrupted jobs automatically
|
||||
- **Graceful shutdown** - Proper signal handling
|
||||
- **Health checks** - Built-in monitoring support
|
||||
- **Security hardening** - NixOS module includes systemd protections
|
||||
- **Docker parity** - Same behavior as `docker-compose.alt.yml`
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<h1>Gitea Mirror</h1>
|
||||
<p><i>Automatically mirror repositories from GitHub to your self-hosted Gitea instance.</i></p>
|
||||
<p><i>Automatically mirror repositories from GitHub to your self-hosted Gitea/Forgejo instance.</i></p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/releases/latest"><img src="https://img.shields.io/github/v/tag/RayLabsHQ/gitea-mirror?label=release" alt="release"/></a>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/actions/workflows/astro-build-test.yml"><img src="https://img.shields.io/github/actions/workflow/status/RayLabsHQ/gitea-mirror/astro-build-test.yml?branch=main" alt="build"/></a>
|
||||
@@ -19,7 +19,7 @@ docker compose -f docker-compose.alt.yml up -d
|
||||
# Access at http://localhost:4321
|
||||
```
|
||||
|
||||
First user signup becomes admin. Configure GitHub and Gitea through the web interface!
|
||||
First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the web interface!
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/dashboard.png" alt="Dashboard" width="600" />
|
||||
@@ -28,7 +28,7 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🔁 Mirror public, private, and starred GitHub repos to Gitea
|
||||
- 🔁 Mirror public, private, and starred GitHub repos to Gitea/Forgejo
|
||||
- 🏢 Mirror entire organizations with flexible strategies
|
||||
- 🎯 Custom destination control for repos and organizations
|
||||
- 📦 **Git LFS support** - Mirror large files with Git LFS
|
||||
@@ -40,6 +40,7 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
|
||||
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+)
|
||||
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+)
|
||||
- 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+)
|
||||
- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes (Beta)
|
||||
- 🗑️ Automatic database cleanup with configurable retention
|
||||
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
|
||||
|
||||
@@ -112,7 +113,7 @@ docker compose up -d
|
||||
#### Using Pre-built Image Directly
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/raylabshq/gitea-mirror:v3.1.1
|
||||
docker pull ghcr.io/raylabshq/gitea-mirror:latest
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
@@ -150,6 +151,38 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
|
||||
|
||||
See the [Proxmox VE Community Scripts](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror) for more details.
|
||||
|
||||
### Nix/NixOS
|
||||
|
||||
Zero-configuration deployment with Nix:
|
||||
|
||||
```bash
|
||||
# Run immediately - no setup needed!
|
||||
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
|
||||
# Or build and run locally
|
||||
nix build --extra-experimental-features 'nix-command flakes'
|
||||
./result/bin/gitea-mirror
|
||||
|
||||
# Or install to profile
|
||||
nix profile install --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
gitea-mirror
|
||||
```
|
||||
|
||||
**NixOS users** - add to your configuration:
|
||||
```nix
|
||||
{
|
||||
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
betterAuthUrl = "https://mirror.example.com";
|
||||
openFirewall = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Secrets auto-generate, database auto-initializes. See [NIX.md](NIX.md) for quick reference or [docs/NIX_DEPLOYMENT.md](docs/NIX_DEPLOYMENT.md) for full documentation.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
@@ -166,31 +199,31 @@ bun run dev
|
||||
1. **First Time Setup**
|
||||
- Navigate to http://localhost:4321
|
||||
- Create admin account (first user signup)
|
||||
- Configure GitHub and Gitea connections
|
||||
- Configure GitHub and Gitea/Forgejo connections
|
||||
|
||||
2. **Mirror Strategies**
|
||||
- **Preserve Structure**: Maintains GitHub organization structure
|
||||
- **Single Organization**: All repos go to one Gitea organization
|
||||
- **Flat User**: All repos under your Gitea user account
|
||||
- **Single Organization**: All repos go to one Gitea/Forgejo organization
|
||||
- **Flat User**: All repos under your Gitea/Forgejo user account
|
||||
- **Mixed Mode**: Personal repos in one org, organization repos preserve structure
|
||||
|
||||
3. **Customization**
|
||||
- Click edit buttons on organization cards to set custom destinations
|
||||
- Override individual repository destinations in the table view
|
||||
- Starred repositories automatically go to a dedicated organization
|
||||
- Starred repositories can go to a dedicated org or preserve source owner/org paths
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Git LFS (Large File Storage)
|
||||
Mirror Git LFS objects along with your repositories:
|
||||
- Enable "Mirror LFS" option in Settings → Mirror Options
|
||||
- Requires Gitea server with LFS enabled (`LFS_START_SERVER = true`)
|
||||
- Requires Gitea/Forgejo server with LFS enabled (`LFS_START_SERVER = true`)
|
||||
- Requires Git v2.1.2+ on the server
|
||||
|
||||
### Metadata Mirroring
|
||||
Transfer complete repository metadata from GitHub to Gitea:
|
||||
Transfer complete repository metadata from GitHub to Gitea/Forgejo:
|
||||
- **Issues** - Mirror all issues with comments and labels
|
||||
- **Pull Requests** - Transfer PR discussions to Gitea
|
||||
- **Pull Requests** - Transfer PR discussions to Gitea/Forgejo
|
||||
- **Labels** - Preserve repository labels
|
||||
- **Milestones** - Keep project milestones
|
||||
- **Wiki** - Mirror wiki content
|
||||
@@ -210,7 +243,7 @@ Gitea Mirror provides powerful automatic synchronization features:
|
||||
#### Features (v3.4.0+)
|
||||
- **Auto-discovery**: Automatically discovers and imports new GitHub repositories
|
||||
- **Repository cleanup**: Removes repositories that no longer exist in GitHub
|
||||
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea's default 24h)
|
||||
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea/Forgejo's default 24h)
|
||||
- **Smart scheduling**: Only syncs repositories that need updating
|
||||
- **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set - no manual clicks required!
|
||||
|
||||
@@ -221,7 +254,7 @@ Navigate to the Configuration page and enable "Automatic Syncing" with your pref
|
||||
|
||||
**🚀 Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
|
||||
1. **Import** all your GitHub repositories on startup (no manual import needed!)
|
||||
2. **Mirror** them to Gitea immediately
|
||||
2. **Mirror** them to Gitea/Forgejo immediately
|
||||
3. **Keep them synchronized** based on your interval
|
||||
4. **Auto-discover** new repos you create/star on GitHub
|
||||
5. **Clean up** repos you delete from GitHub
|
||||
@@ -250,22 +283,73 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
|
||||
**Important Notes**:
|
||||
- **Auto-Start**: When `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set, the service automatically imports all GitHub repositories and mirrors them on startup. No manual "Import" or "Mirror" button clicks required!
|
||||
- The scheduler checks every minute for tasks to run. The `GITEA_MIRROR_INTERVAL` determines how often each repository is actually synced. For example, with `8h`, each repo syncs every 8 hours from its last successful sync.
|
||||
- **Large repo bootstrap**: For first-time mirroring of large repositories (especially with metadata/LFS), avoid very short intervals (for example `5m`). Start with a longer interval (`1h` to `8h`) or temporarily disable scheduling during the initial import/mirror run, then enable your regular interval after the first pass completes.
|
||||
- **Why this matters**: If your Gitea/Forgejo instance takes a long time to complete migrations/imports, aggressive schedules can cause repeated retries and duplicate-looking mirror attempts.
|
||||
|
||||
**🛡️ Backup Protection Features**:
|
||||
- **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors)
|
||||
- **Archive Never Deletes Data**: The `archive` action preserves all repository data:
|
||||
- Regular repositories: Made read-only using Gitea's archive feature
|
||||
- Mirror repositories: Renamed with `archived-` prefix (Gitea API limitation prevents archiving mirrors)
|
||||
- Regular repositories: Made read-only using Gitea/Forgejo's archive feature
|
||||
- Mirror repositories: Renamed with `archived-` prefix (Gitea/Forgejo API limitation prevents archiving mirrors)
|
||||
- Failed operations: Repository remains fully accessible even if marking as archived fails
|
||||
- **Manual Sync on Demand**: Archived mirrors stay in Gitea with automatic syncs disabled; trigger `Manual Sync` from the Repositories page whenever you need fresh data.
|
||||
- **The Whole Point of Backups**: Your Gitea mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||
- **Manual Sync on Demand**: Archived mirrors stay in Gitea/Forgejo with automatic syncs disabled; trigger `Manual Sync` from the Repositories page whenever you need fresh data.
|
||||
- **The Whole Point of Backups**: Your Gitea/Forgejo mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Reverse Proxy Configuration
|
||||
|
||||
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
|
||||
If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure:
|
||||
|
||||
```bash
|
||||
# BASE_URL handles the path prefix — auth URLs stay as origin only
|
||||
BASE_URL=/mirror
|
||||
BETTER_AUTH_URL=https://git.example.com
|
||||
PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `BASE_URL` sets the application path prefix.
|
||||
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` should be **origin only** (e.g. `https://git.example.com`). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
|
||||
- `BETTER_AUTH_TRUSTED_ORIGINS` should also contain origins only (no path).
|
||||
- `BASE_URL` is runtime configuration, so prebuilt registry images can be reused across different subpaths.
|
||||
|
||||
### Mirror Token Rotation (GitHub Token Changed)
|
||||
|
||||
For existing pull-mirror repositories, changing the GitHub token in Gitea Mirror does not always update stored mirror credentials in Gitea/Forgejo for already-created repositories.
|
||||
|
||||
If sync logs show authentication failures (for example `terminal prompts disabled`), do one of the following:
|
||||
|
||||
1. In Gitea/Forgejo, open repository **Settings → Mirror Settings** and update the mirror authorization password/token.
|
||||
2. Or delete and re-mirror the repository so it is recreated with current credentials.
|
||||
|
||||
### Re-sync Metadata After Changing Mirror Options
|
||||
|
||||
If you enable metadata options (issues/PRs/labels/milestones/releases) after repositories were already mirrored:
|
||||
|
||||
1. Go to **Repositories**, select the repositories, and click **Sync** to run a fresh sync pass.
|
||||
2. For a full metadata refresh, use **Re-run Metadata** on selected repositories. This clears metadata sync state for those repos and immediately starts Sync.
|
||||
3. If some repositories still miss metadata, reset metadata sync state in SQLite and sync again:
|
||||
|
||||
```bash
|
||||
sqlite3 data/gitea-mirror.db "UPDATE repositories SET metadata = NULL;"
|
||||
```
|
||||
|
||||
This clears per-repository metadata completion flags so the next sync can re-run metadata import steps.
|
||||
|
||||
### Mirror Interval vs Gitea/Forgejo `MIN_INTERVAL`
|
||||
|
||||
Gitea Mirror treats the interval configured in **Configuration** (or `GITEA_MIRROR_INTERVAL`) as the source of truth and applies it to mirrored repositories during sync.
|
||||
|
||||
If your Gitea/Forgejo server has `mirror.MIN_INTERVAL` set to a higher value (for example `24h`) and Gitea Mirror is set lower (for example `8h`), sync/mirror operations can fail when updating mirror settings.
|
||||
|
||||
To avoid this:
|
||||
|
||||
1. Set Gitea Mirror interval to a value greater than or equal to your server `MIN_INTERVAL`.
|
||||
2. Do not rely on manual per-repository mirror interval edits in Gitea/Forgejo, as they will be overwritten on sync.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -287,13 +371,13 @@ bun run build
|
||||
|
||||
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
|
||||
- **Backend**: Bun runtime, SQLite, Drizzle ORM
|
||||
- **APIs**: GitHub (Octokit), Gitea REST API
|
||||
- **APIs**: GitHub (Octokit), Gitea/Forgejo REST API
|
||||
- **Auth**: Better Auth with session-based authentication
|
||||
|
||||
## Security
|
||||
|
||||
### Token Encryption
|
||||
- All GitHub and Gitea API tokens are encrypted at rest using AES-256-GCM
|
||||
- All GitHub and Gitea/Forgejo API tokens are encrypted at rest using AES-256-GCM
|
||||
- Encryption is automatic and transparent to users
|
||||
- Set `ENCRYPTION_SECRET` environment variable for production deployments
|
||||
- Falls back to `BETTER_AUTH_SECRET` if not set
|
||||
@@ -303,6 +387,20 @@ bun run build
|
||||
- Never stored in plaintext
|
||||
- Secure cookie-based session management
|
||||
|
||||
### Admin Password Recovery (CLI)
|
||||
If email delivery is not configured, an admin with server access can reset a user password from the command line:
|
||||
|
||||
```bash
|
||||
bun run reset-password -- --email=user@example.com --new-password='new-secure-password'
|
||||
```
|
||||
|
||||
What this does:
|
||||
- Updates the credential password hash for the matching user
|
||||
- Creates a credential account if one does not already exist
|
||||
- Invalidates all active sessions for that user (forces re-login)
|
||||
|
||||
Use this only from trusted server/admin environments.
|
||||
|
||||
## Authentication
|
||||
|
||||
Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.**
|
||||
@@ -326,6 +424,8 @@ Enable users to sign in with external identity providers like Google, Azure AD,
|
||||
https://your-domain.com/api/auth/sso/callback/{provider-id}
|
||||
```
|
||||
|
||||
Need help? The [SSO & OIDC guide](docs/SSO-OIDC-SETUP.md) now includes a working Authentik walkthrough plus troubleshooting tips. If you upgraded from a version earlier than v3.8.10 and see `TypeError … url.startsWith` after the callback, delete the old provider and add it again using the Discover button (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122)).
|
||||
|
||||
### 3. Header Authentication (Reverse Proxy)
|
||||
Perfect for automatic authentication when using reverse proxies like Authentik, Authelia, or Traefik Forward Auth.
|
||||
|
||||
@@ -371,13 +471,13 @@ Gitea Mirror can also act as an OIDC provider for other applications. Register O
|
||||
## Known Limitations
|
||||
|
||||
### Pull Request Mirroring Implementation
|
||||
Pull requests **cannot be created as actual PRs** in Gitea due to API limitations. Instead, they are mirrored as **enriched issues** with comprehensive metadata.
|
||||
Pull requests **cannot be created as actual PRs** in Gitea/Forgejo due to API limitations. Instead, they are mirrored as **enriched issues** with comprehensive metadata.
|
||||
|
||||
**Why real PR mirroring isn't possible:**
|
||||
- Gitea's API doesn't support creating pull requests from external sources
|
||||
- Gitea/Forgejo's API doesn't support creating pull requests from external sources
|
||||
- Real PRs require actual Git branches with commits to exist in the repository
|
||||
- Would require complex branch synchronization and commit replication
|
||||
- The mirror relationship is one-way (GitHub → Gitea) for repository content
|
||||
- The mirror relationship is one-way (GitHub → Gitea/Forgejo) for repository content
|
||||
|
||||
**How we handle Pull Requests:**
|
||||
PRs are mirrored as issues with rich metadata including:
|
||||
@@ -391,7 +491,7 @@ PRs are mirrored as issues with rich metadata including:
|
||||
- 🔀 Base and head branch information
|
||||
- ✅ Merge status tracking
|
||||
|
||||
This approach preserves all important PR information while working within Gitea's API constraints. The PRs appear in Gitea's issue tracker with clear visual distinction and comprehensive details.
|
||||
This approach preserves all important PR information while working within Gitea/Forgejo's API constraints. The PRs appear in the issue tracker with clear visual distinction and comprehensive details.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -399,7 +499,7 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN
|
||||
|
||||
## License
|
||||
|
||||
GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
|
||||
GNU Affero General Public License v3.0 (AGPL-3.0) - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -414,7 +514,8 @@ GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
|
||||
## Support
|
||||
|
||||
- 📖 [Documentation](https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs)
|
||||
- 🔐 [Custom CA Certificates](docs/CA_CERTIFICATES.md)
|
||||
- 🔐 [Environment Variables](docs/ENVIRONMENT_VARIABLES.md)
|
||||
- 🛡️ [Force-Push Protection](docs/FORCE_PUSH_PROTECTION.md)
|
||||
- 🐛 [Report Issues](https://github.com/RayLabsHQ/gitea-mirror/issues)
|
||||
- 💬 [Discussions](https://github.com/RayLabsHQ/gitea-mirror/discussions)
|
||||
- 🔧 [Proxmox VE Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)
|
||||
@@ -3,4 +3,7 @@
|
||||
timeout = 5000
|
||||
|
||||
# Preload the setup file
|
||||
preload = ["./src/tests/setup.bun.ts"]
|
||||
preload = ["./src/tests/setup.bun.ts"]
|
||||
|
||||
# Only run tests in src/ directory (excludes tests/e2e/ which are Playwright tests)
|
||||
root = "./src/"
|
||||
@@ -0,0 +1,804 @@
|
||||
{
|
||||
"version": "2.9",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "eIiDx",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Scheduling Settings - Redesign",
|
||||
"width": 1080,
|
||||
"fill": "#09090B",
|
||||
"cornerRadius": 16,
|
||||
"gap": 24,
|
||||
"padding": 32,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7r0Wv",
|
||||
"name": "Automatic Syncing Card",
|
||||
"clip": true,
|
||||
"width": "fill_container",
|
||||
"fill": "#18181B",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#27272A"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gyCPG",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "OunzZ",
|
||||
"name": "headerIcon",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"iconFontName": "refresh-cw",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#A1A1AA"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fMdlX",
|
||||
"name": "headerTitle",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Automatic Syncing",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "4cX02",
|
||||
"name": "divider1",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Kiezh",
|
||||
"name": "Toggle Section",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "QCPzN",
|
||||
"name": "Checkbox",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"fill": "#6366F1",
|
||||
"cornerRadius": 4,
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "4FTax",
|
||||
"x": 3,
|
||||
"y": 3,
|
||||
"name": "checkIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FTzs6",
|
||||
"name": "toggleText",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "1nJKC",
|
||||
"name": "toggleLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Enable automatic repository syncing",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "r1O5t",
|
||||
"name": "toggleDesc",
|
||||
"fill": "#71717A",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Periodically sync GitHub changes to Gitea",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "nvQ6R",
|
||||
"name": "divider2",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FOoBn",
|
||||
"name": "Schedule Builder",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 20,
|
||||
"padding": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "IqHEu",
|
||||
"name": "schedHeader",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "RnVoM",
|
||||
"name": "schedTitle",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "SCHEDULE",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "aVtIZ",
|
||||
"name": "tzBadge",
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 20,
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
4,
|
||||
10
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "iXpYV",
|
||||
"name": "tzIcon",
|
||||
"width": 12,
|
||||
"height": 12,
|
||||
"iconFontName": "globe",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "WjPMl",
|
||||
"name": "tzText",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "UTC",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "P02fk",
|
||||
"name": "formRow",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kcYK5",
|
||||
"name": "Frequency",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vMvsN",
|
||||
"name": "label2",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Frequency",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "3prth",
|
||||
"name": "select2",
|
||||
"width": "fill_container",
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ANY36",
|
||||
"name": "sel2Text",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Daily",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "GUWfd",
|
||||
"name": "sel2Icon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "xphp0",
|
||||
"name": "Start Time",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "l6VkR",
|
||||
"name": "label3",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Start Time",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lWBDi",
|
||||
"name": "timeInput",
|
||||
"width": "fill_container",
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fbuMi",
|
||||
"name": "timeText",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "10:00 PM",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "5xKW7",
|
||||
"name": "timeIcon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "clock-4",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "BtYt7",
|
||||
"name": "divider3",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "520Kb",
|
||||
"name": "Status Bar",
|
||||
"width": "fill_container",
|
||||
"padding": [
|
||||
16,
|
||||
24
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "J8JzX",
|
||||
"name": "lastSync",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "MS5VM",
|
||||
"name": "lastIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "history",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "8KJHY",
|
||||
"name": "lastLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Last sync",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Fz116",
|
||||
"name": "lastValue",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Never",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZbRFN",
|
||||
"name": "nextSync",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "wIKSk",
|
||||
"name": "nextIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "calendar",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ejqSP",
|
||||
"name": "nextLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Next sync",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "M4oJ7",
|
||||
"name": "nextValue",
|
||||
"fill": "#6366F1",
|
||||
"content": "Calculating...",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7PK7H",
|
||||
"name": "Database Maintenance Card",
|
||||
"clip": true,
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "#18181B",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#27272A"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FAaon",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "64CaE",
|
||||
"name": "rHeaderIcon",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"iconFontName": "database",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#A1A1AA"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rvZlC",
|
||||
"name": "rHeaderTitle",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Database Maintenance",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "nsM0M",
|
||||
"name": "rDivider1",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "8zhPi",
|
||||
"name": "Toggle Section",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "eQbZk",
|
||||
"name": "Checkbox",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"fill": "#6366F1",
|
||||
"cornerRadius": 4,
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "t6PbY",
|
||||
"x": 3,
|
||||
"y": 3,
|
||||
"name": "rCheckIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lpBPI",
|
||||
"name": "rToggleText",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Kuy1S",
|
||||
"name": "rToggleLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Enable automatic database cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "OviVY",
|
||||
"name": "rToggleDesc",
|
||||
"fill": "#71717A",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Remove old activity logs to optimize storage",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "1og3D",
|
||||
"name": "rDivider2",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "J7576",
|
||||
"name": "Retention Section",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "JZA6R",
|
||||
"name": "retLabelRow",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Diiak",
|
||||
"name": "retLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Data retention period",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "1qqCe",
|
||||
"name": "retInfoIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "info",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfUjs",
|
||||
"name": "retRow",
|
||||
"width": "fill_container",
|
||||
"gap": 16,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "9bhls",
|
||||
"name": "retSelect",
|
||||
"width": 180,
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "3NOod",
|
||||
"name": "retSelText",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "1 month",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "8QBA8",
|
||||
"name": "retSelIcon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "GA6ye",
|
||||
"name": "retHelper",
|
||||
"fill": "#52525B",
|
||||
"content": "Cleanup runs every 2 days",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "WfXVB",
|
||||
"name": "rDivider3",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "WpXnI",
|
||||
"name": "Cleanup Status",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
16,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fbpm5",
|
||||
"name": "lastCleanup",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "DdLix",
|
||||
"name": "lastCleanupLeft",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "FN2cj",
|
||||
"name": "lastCleanIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "history",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "JjmMa",
|
||||
"name": "lastCleanLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Last cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "l1Kph",
|
||||
"name": "lastCleanValue",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Never",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "AWHY8",
|
||||
"name": "nextCleanup",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "sj0qN",
|
||||
"name": "nextCleanupLeft",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "V6RTK",
|
||||
"name": "nextCleanIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "calendar",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wf0b4",
|
||||
"name": "nextCleanLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Next cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "YWZGH",
|
||||
"name": "nextCleanValue",
|
||||
"fill": "#6366F1",
|
||||
"content": "March 20, 2026 at 12:19 AM",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,11 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
|
||||
# REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
# Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL.
|
||||
|
||||
# === CORE SETTINGS ===
|
||||
# These are technically required but have working defaults
|
||||
@@ -25,6 +30,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# Optional concurrency controls (defaults match in-app defaults)
|
||||
# If you want perfect ordering of issues and PRs, set these at 1
|
||||
@@ -32,7 +38,11 @@ services:
|
||||
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
@@ -66,6 +66,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- BETTER_AUTH_SECRET=dev-secret-key
|
||||
# GitHub/Gitea Mirror Config
|
||||
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
|
||||
@@ -89,7 +90,11 @@ services:
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -30,8 +30,21 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
|
||||
# you MUST set these three variables to your external URL. Example:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
# If deployed under a path prefix (e.g. https://git.example.com/mirror), also set:
|
||||
# BASE_URL=/mirror
|
||||
# BETTER_AUTH_URL=https://git.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
|
||||
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
|
||||
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
|
||||
# GitHub/Gitea Mirror Config
|
||||
@@ -74,7 +87,11 @@ services:
|
||||
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
|
||||
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
@@ -139,16 +139,29 @@ fi
|
||||
|
||||
# Initialize configuration from environment variables if provided
|
||||
echo "Checking for environment configuration..."
|
||||
if [ -f "dist/scripts/startup-env-config.js" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun dist/scripts/startup-env-config.js
|
||||
ENV_CONFIG_EXIT_CODE=$?
|
||||
elif [ -f "scripts/startup-env-config.ts" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun scripts/startup-env-config.ts
|
||||
ENV_CONFIG_EXIT_CODE=$?
|
||||
|
||||
# Only run the env config script if relevant env vars are set
|
||||
# This avoids spawning a heavy Bun process on memory-constrained systems
|
||||
HAS_ENV_CONFIG=false
|
||||
if [ -n "$GITHUB_USERNAME" ] || [ -n "$GITHUB_TOKEN" ] || [ -n "$GITEA_URL" ] || [ -n "$GITEA_USERNAME" ] || [ -n "$GITEA_TOKEN" ]; then
|
||||
HAS_ENV_CONFIG=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_ENV_CONFIG" = "true" ]; then
|
||||
if [ -f "dist/scripts/startup-env-config.js" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun dist/scripts/startup-env-config.js || ENV_CONFIG_EXIT_CODE=$?
|
||||
ENV_CONFIG_EXIT_CODE=${ENV_CONFIG_EXIT_CODE:-0}
|
||||
elif [ -f "scripts/startup-env-config.ts" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
bun scripts/startup-env-config.ts || ENV_CONFIG_EXIT_CODE=$?
|
||||
ENV_CONFIG_EXIT_CODE=${ENV_CONFIG_EXIT_CODE:-0}
|
||||
else
|
||||
echo "Environment configuration script not found. Skipping."
|
||||
ENV_CONFIG_EXIT_CODE=0
|
||||
fi
|
||||
else
|
||||
echo "Environment configuration script not found. Skipping."
|
||||
echo "No GitHub/Gitea environment variables found, skipping env config initialization."
|
||||
ENV_CONFIG_EXIT_CODE=0
|
||||
fi
|
||||
|
||||
@@ -161,17 +174,15 @@ fi
|
||||
|
||||
# Run startup recovery to handle any interrupted jobs
|
||||
echo "Running startup recovery..."
|
||||
RECOVERY_EXIT_CODE=0
|
||||
if [ -f "dist/scripts/startup-recovery.js" ]; then
|
||||
echo "Running startup recovery using compiled script..."
|
||||
bun dist/scripts/startup-recovery.js --timeout=30000
|
||||
RECOVERY_EXIT_CODE=$?
|
||||
bun dist/scripts/startup-recovery.js --timeout=30000 || RECOVERY_EXIT_CODE=$?
|
||||
elif [ -f "scripts/startup-recovery.ts" ]; then
|
||||
echo "Running startup recovery using TypeScript script..."
|
||||
bun scripts/startup-recovery.ts --timeout=30000
|
||||
RECOVERY_EXIT_CODE=$?
|
||||
bun scripts/startup-recovery.ts --timeout=30000 || RECOVERY_EXIT_CODE=$?
|
||||
else
|
||||
echo "Warning: Startup recovery script not found. Skipping recovery."
|
||||
RECOVERY_EXIT_CODE=0
|
||||
fi
|
||||
|
||||
# Log recovery result
|
||||
@@ -185,17 +196,15 @@ fi
|
||||
|
||||
# Run repository status repair to fix any inconsistent mirroring states
|
||||
echo "Running repository status repair..."
|
||||
REPAIR_EXIT_CODE=0
|
||||
if [ -f "dist/scripts/repair-mirrored-repos.js" ]; then
|
||||
echo "Running repository repair using compiled script..."
|
||||
bun dist/scripts/repair-mirrored-repos.js --startup
|
||||
REPAIR_EXIT_CODE=$?
|
||||
bun dist/scripts/repair-mirrored-repos.js --startup || REPAIR_EXIT_CODE=$?
|
||||
elif [ -f "scripts/repair-mirrored-repos.ts" ]; then
|
||||
echo "Running repository repair using TypeScript script..."
|
||||
bun scripts/repair-mirrored-repos.ts --startup
|
||||
REPAIR_EXIT_CODE=$?
|
||||
bun scripts/repair-mirrored-repos.ts --startup || REPAIR_EXIT_CODE=$?
|
||||
else
|
||||
echo "Warning: Repository repair script not found. Skipping repair."
|
||||
REPAIR_EXIT_CODE=0
|
||||
fi
|
||||
|
||||
# Log repair result
|
||||
@@ -220,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP
|
||||
|
||||
# Start the application
|
||||
echo "Starting Gitea Mirror..."
|
||||
bun ./dist/server/entry.mjs &
|
||||
if [ -f "./scripts/runtime-server.ts" ]; then
|
||||
bun ./scripts/runtime-server.ts &
|
||||
elif [ -f "./dist/scripts/runtime-server.js" ]; then
|
||||
bun ./dist/scripts/runtime-server.js &
|
||||
else
|
||||
bun ./dist/server/entry.mjs &
|
||||
fi
|
||||
APP_PID=$!
|
||||
|
||||
# Wait for the application to finish
|
||||
|
||||
@@ -310,26 +310,25 @@ bunx tsc --noEmit
|
||||
|
||||
## Release Process
|
||||
|
||||
1. **Update version**:
|
||||
```bash
|
||||
npm version patch # or minor/major
|
||||
```
|
||||
1. **Choose release version** (`X.Y.Z`) and update `CHANGELOG.md`
|
||||
|
||||
2. **Update CHANGELOG.md**
|
||||
|
||||
3. **Build and test**:
|
||||
2. **Build and test**:
|
||||
```bash
|
||||
bun run build
|
||||
bun test
|
||||
```
|
||||
|
||||
4. **Create release**:
|
||||
3. **Create release tag** (semver format required):
|
||||
```bash
|
||||
git tag v2.23.0
|
||||
git push origin v2.23.0
|
||||
git tag vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
5. **Create GitHub release**
|
||||
4. **Create GitHub release**
|
||||
|
||||
5. **CI version sync (automatic)**:
|
||||
- On `v*` tags, release CI updates `package.json` version in the build context from the tag (`vX.Y.Z` -> `X.Y.Z`), so Docker release images always report the correct app version.
|
||||
- After the release build succeeds, CI commits the same `package.json` version back to `main` automatically.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -349,6 +348,6 @@ git push origin v2.23.0
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check existing [issues](https://github.com/yourusername/gitea-mirror/issues)
|
||||
- Join [discussions](https://github.com/yourusername/gitea-mirror/discussions)
|
||||
- Read the [FAQ](./FAQ.md)
|
||||
- Check existing [issues](https://github.com/RayLabsHQ/gitea-mirror/issues)
|
||||
- Join [discussions](https://github.com/RayLabsHQ/gitea-mirror/discussions)
|
||||
- Review project docs in [docs/README.md](./README.md)
|
||||
@@ -33,10 +33,11 @@ Essential application settings required for running Gitea Mirror.
|
||||
| `NODE_ENV` | Application environment | `production` | No |
|
||||
| `HOST` | Server host binding | `0.0.0.0` | No |
|
||||
| `PORT` | Server port | `4321` | No |
|
||||
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
|
||||
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
|
||||
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
@@ -61,7 +62,9 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
|
||||
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
|
||||
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
|
||||
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
|
||||
|
||||
### Organization Settings
|
||||
|
||||
@@ -77,6 +80,7 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SKIP_STARRED_ISSUES` | Enable lightweight mode for starred repos (skip issues) | `false` | `true`, `false` |
|
||||
| `AUTO_MIRROR_STARRED` | Automatically mirror starred repos during scheduled syncs and "Mirror All". When `false`, starred repos are imported for browsing but must be mirrored individually. | `false` | `true`, `false` |
|
||||
|
||||
## Gitea Configuration
|
||||
|
||||
@@ -87,6 +91,7 @@ Settings for the destination Gitea instance.
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `GITEA_URL` | Gitea instance URL | - | Valid URL |
|
||||
| `GITEA_EXTERNAL_URL` | Optional external/browser URL used for dashboard links. API and mirroring still use `GITEA_URL`. | - | Valid URL |
|
||||
| `GITEA_TOKEN` | Gitea access token | - | - |
|
||||
| `GITEA_USERNAME` | Gitea username | - | - |
|
||||
| `GITEA_ORGANIZATION` | Default organization for single-org strategy | `github-mirrors` | Any string |
|
||||
@@ -298,6 +303,7 @@ services:
|
||||
environment:
|
||||
# Core Configuration
|
||||
- NODE_ENV=production
|
||||
- BASE_URL=/
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- BETTER_AUTH_SECRET=your-secure-secret-here
|
||||
# Primary access URL:
|
||||
@@ -366,6 +372,24 @@ This setup allows you to:
|
||||
|
||||
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
|
||||
|
||||
### Path Prefix Deployments
|
||||
|
||||
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
|
||||
|
||||
```bash
|
||||
# BASE_URL handles the path prefix — auth URLs stay as origin only
|
||||
BASE_URL=/mirror
|
||||
BETTER_AUTH_URL=https://git.example.com
|
||||
PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` must be **origin only** (scheme + host). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
|
||||
- `BETTER_AUTH_TRUSTED_ORIGINS` must also contain origins only (no path).
|
||||
- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
|
||||
|
||||
### Trusted Origins
|
||||
|
||||
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Force-Push Protection
|
||||
|
||||
This document describes the smart force-push protection system introduced in gitea-mirror v3.11.0+.
|
||||
|
||||
## The Problem
|
||||
|
||||
GitHub repositories can be force-pushed at any time — rewriting history, deleting branches, or replacing commits entirely. When gitea-mirror syncs a force-pushed repo, the old history in Gitea is silently overwritten. Files, commits, and branches disappear with no way to recover them.
|
||||
|
||||
The original workaround (`backupBeforeSync: true`) created a full git bundle backup before **every** sync. This doesn't scale — a user with 100+ GiB of mirrors would need up to 2 TB of backup storage with default retention settings, even though force-pushes are rare.
|
||||
|
||||
## Solution: Smart Detection
|
||||
|
||||
Instead of backing up everything every time, the system detects force-pushes **before** they happen and only acts when needed.
|
||||
|
||||
### How Detection Works
|
||||
|
||||
Before each sync, the app compares branch SHAs between Gitea (the mirror) and GitHub (the source):
|
||||
|
||||
1. **Fetch branches from both sides** — lightweight API calls to get branch names and their latest commit SHAs
|
||||
2. **Compare each branch**:
|
||||
- SHAs match → nothing changed, no action needed
|
||||
- SHAs differ → check if the change is a normal push or a force-push
|
||||
3. **Ancestry check** — for branches with different SHAs, call GitHub's compare API to determine if the new SHA is a descendant of the old one:
|
||||
- **Fast-forward** (new SHA descends from old) → normal push, safe to sync
|
||||
- **Diverged** (histories split) → force-push detected
|
||||
- **404** (old SHA doesn't exist on GitHub anymore) → history was rewritten, force-push detected
|
||||
- **Branch deleted on GitHub** → flagged as destructive change
|
||||
|
||||
### What Happens on Detection
|
||||
|
||||
Depends on the configured strategy (see below):
|
||||
- **Backup strategies** (`always`, `on-force-push`): create a git bundle snapshot, then sync
|
||||
- **Block strategy** (`block-on-force-push`): halt the sync, mark the repo as `pending-approval`, wait for user action
|
||||
|
||||
### Fail-Open Design
|
||||
|
||||
If detection itself fails (GitHub rate limits, network errors, API outages), sync proceeds normally. Detection never blocks a sync due to its own failure. Individual branch check failures are skipped — one flaky branch doesn't affect the others.
|
||||
|
||||
## Backup Strategies
|
||||
|
||||
Configure via **Settings → GitHub Configuration → Destructive Update Protection**.
|
||||
|
||||
| Strategy | What It Does | Storage Cost | Best For |
|
||||
|---|---|---|---|
|
||||
| **Disabled** | No detection, no backups | Zero | Repos you don't care about losing |
|
||||
| **Always Backup** | Snapshot before every sync (original behavior) | High | Small mirror sets, maximum safety |
|
||||
| **Smart** (default) | Detect force-pushes, backup only when found | Near-zero normally | Most users — efficient protection |
|
||||
| **Block & Approve** | Detect force-pushes, block sync until approved | Zero | Critical repos needing manual review |
|
||||
|
||||
### Strategy Details
|
||||
|
||||
#### Disabled
|
||||
|
||||
Syncs proceed without any detection or backup. If a force-push happens on GitHub, the mirror silently overwrites.
|
||||
|
||||
#### Always Backup
|
||||
|
||||
Creates a git bundle snapshot before every sync regardless of whether a force-push occurred. This is the legacy behavior (equivalent to the old `backupBeforeSync: true`). Safe but expensive for large mirror sets.
|
||||
|
||||
#### Smart (`on-force-push`) — Recommended
|
||||
|
||||
Runs the force-push detection before each sync. On normal days (no force-pushes), syncs proceed without any backup overhead. When a force-push is detected, a snapshot is created before the sync runs.
|
||||
|
||||
This gives you protection when it matters with near-zero cost when it doesn't.
|
||||
|
||||
#### Block & Approve (`block-on-force-push`)
|
||||
|
||||
Runs detection and, when a force-push is found, **blocks the sync entirely**. The repository is marked as `pending-approval` and excluded from future scheduled syncs until you take action:
|
||||
|
||||
- **Approve**: creates a backup first, then syncs (safe)
|
||||
- **Dismiss**: clears the flag and resumes normal syncing (no backup)
|
||||
|
||||
Use this for repos where you want manual control over destructive changes.
|
||||
|
||||
## Additional Settings
|
||||
|
||||
These appear when any non-disabled strategy is selected:
|
||||
|
||||
### Snapshot Retention Count
|
||||
|
||||
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **5**.
|
||||
|
||||
### Snapshot Retention Days
|
||||
|
||||
Maximum age (in days) for backup snapshots. Bundles older than this are deleted during retention enforcement, though at least one bundle is always kept. Set to `0` to disable time-based retention. Default: **30**.
|
||||
|
||||
### Snapshot Directory
|
||||
|
||||
Where git bundle backups are stored. Default: **`data/repo-backups`**. Bundles are organized as `<directory>/<owner>/<repo>/<timestamp>.bundle`.
|
||||
|
||||
### Block Sync on Snapshot Failure
|
||||
|
||||
Available for **Always Backup** and **Smart** strategies. When enabled, if the snapshot creation fails (disk full, permissions error, etc.), the sync is also blocked. When disabled, sync continues even if the snapshot couldn't be created.
|
||||
|
||||
Recommended: **enabled** if you rely on backups for recovery.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The old `backupBeforeSync` boolean is still recognized:
|
||||
|
||||
| Old Setting | New Equivalent |
|
||||
|---|---|
|
||||
| `backupBeforeSync: true` | `backupStrategy: "on-force-push"` |
|
||||
| `backupBeforeSync: false` | `backupStrategy: "disabled"` |
|
||||
| Neither set | `backupStrategy: "on-force-push"` (new default) |
|
||||
|
||||
Existing configurations are automatically mapped. The old field is deprecated but will continue to work.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
No new environment variables are required. The backup strategy is configured through the web UI and stored in the database alongside other config.
|
||||
|
||||
## API
|
||||
|
||||
### Approve/Dismiss Blocked Repos
|
||||
|
||||
When using the `block-on-force-push` strategy, repos that are blocked can be managed via the API:
|
||||
|
||||
```bash
|
||||
# Approve sync (creates backup first, then syncs)
|
||||
curl -X POST http://localhost:4321/api/job/approve-sync \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: <session>" \
|
||||
-d '{"repositoryIds": ["<id>"], "action": "approve"}'
|
||||
|
||||
# Dismiss (clear the block, resume normal syncing)
|
||||
curl -X POST http://localhost:4321/api/job/approve-sync \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: <session>" \
|
||||
-d '{"repositoryIds": ["<id>"], "action": "dismiss"}'
|
||||
```
|
||||
|
||||
Blocked repos also show an **Approve** / **Dismiss** button in the repository table UI.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/lib/utils/force-push-detection.ts` | Core detection: fetch branches, compare SHAs, check ancestry |
|
||||
| `src/lib/repo-backup.ts` | Strategy resolver, backup decision logic, bundle creation |
|
||||
| `src/lib/gitea-enhanced.ts` | Sync flow integration (calls detection + backup before mirror-sync) |
|
||||
| `src/pages/api/job/approve-sync.ts` | Approve/dismiss API endpoint |
|
||||
| `src/components/config/GitHubConfigForm.tsx` | Strategy selector UI |
|
||||
| `src/components/repositories/RepositoryTable.tsx` | Pending-approval badge + action buttons |
|
||||
|
||||
### Detection Flow
|
||||
|
||||
```
|
||||
syncGiteaRepoEnhanced()
|
||||
│
|
||||
├─ Resolve backup strategy (config → backupStrategy → backupBeforeSync → default)
|
||||
│
|
||||
├─ If strategy needs detection ("on-force-push" or "block-on-force-push"):
|
||||
│ │
|
||||
│ ├─ fetchGiteaBranches() — GET /api/v1/repos/{owner}/{repo}/branches
|
||||
│ ├─ fetchGitHubBranches() — octokit.paginate(repos.listBranches)
|
||||
│ │
|
||||
│ └─ For each Gitea branch where SHA differs:
|
||||
│ └─ checkAncestry() — octokit.repos.compareCommits()
|
||||
│ ├─ "ahead" or "identical" → fast-forward (safe)
|
||||
│ ├─ "diverged" or "behind" → force-push detected
|
||||
│ └─ 404/422 → old SHA gone → force-push detected
|
||||
│
|
||||
├─ If "block-on-force-push" + detected:
|
||||
│ └─ Set repo status to "pending-approval", return early
|
||||
│
|
||||
├─ If backup needed (always, or on-force-push + detected):
|
||||
│ └─ Create git bundle snapshot
|
||||
│
|
||||
└─ Proceed to mirror-sync
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Repos stuck in "pending-approval"**: Use the Approve or Dismiss buttons in the repository table, or call the approve-sync API endpoint.
|
||||
|
||||
**Detection always skipped**: Check the activity log for skip reasons. Common causes: Gitea repo not yet mirrored (first sync), GitHub API rate limits, network errors. All are fail-open by design.
|
||||
|
||||
**Backups consuming too much space**: Lower the retention count, or switch from "Always Backup" to "Smart" which only creates backups on actual force-pushes.
|
||||
|
||||
**False positives**: The detection compares branch-by-branch. A rebase (which is a force-push) will correctly trigger detection. If you routinely rebase branches, consider using "Smart" instead of "Block & Approve" to avoid constant approval prompts.
|
||||
@@ -0,0 +1,486 @@
|
||||
# Nix Deployment Guide
|
||||
|
||||
This guide covers deploying Gitea Mirror using Nix flakes. The Nix deployment follows the same minimal configuration philosophy as `docker-compose.alt.yml` - secrets are auto-generated, and everything else can be configured via the web UI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Nix 2.4+ installed
|
||||
- For NixOS module: NixOS 23.05+
|
||||
|
||||
### Enable Flakes (Recommended)
|
||||
|
||||
To enable flakes permanently and avoid typing flags, add to `/etc/nix/nix.conf` or `~/.config/nix/nix.conf`:
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
**Note:** If you don't enable flakes globally, add `--extra-experimental-features 'nix-command flakes'` to all nix commands shown below.
|
||||
|
||||
## Quick Start (Zero Configuration!)
|
||||
|
||||
### Run Immediately - No Setup Required
|
||||
|
||||
```bash
|
||||
# Run directly from the flake (local)
|
||||
nix run --extra-experimental-features 'nix-command flakes' .#gitea-mirror
|
||||
|
||||
# Or from GitHub (once published)
|
||||
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
|
||||
# If you have flakes enabled globally, simply:
|
||||
nix run .#gitea-mirror
|
||||
```
|
||||
|
||||
That's it! On first run:
|
||||
- Secrets (`BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET`) are auto-generated
|
||||
- Database is automatically created and initialized
|
||||
- Startup recovery and repair scripts run automatically
|
||||
- Access the web UI at http://localhost:4321
|
||||
|
||||
Everything else (GitHub credentials, Gitea settings, mirror options) is configured through the web interface after signup.
|
||||
|
||||
### Development Environment
|
||||
|
||||
```bash
|
||||
# Enter development shell with all dependencies
|
||||
nix develop --extra-experimental-features 'nix-command flakes'
|
||||
|
||||
# Or use direnv for automatic environment loading (handles flags automatically)
|
||||
echo "use flake" > .envrc
|
||||
direnv allow
|
||||
```
|
||||
|
||||
### Build and Install
|
||||
|
||||
```bash
|
||||
# Build the package
|
||||
nix build --extra-experimental-features 'nix-command flakes'
|
||||
|
||||
# Run the built package
|
||||
./result/bin/gitea-mirror
|
||||
|
||||
# Install to your profile
|
||||
nix profile install --extra-experimental-features 'nix-command flakes' .#gitea-mirror
|
||||
```
|
||||
|
||||
## What Happens on First Run?
|
||||
|
||||
Following the same pattern as the Docker deployment, the Nix package automatically:
|
||||
|
||||
1. **Creates data directory**: `~/.local/share/gitea-mirror` (or `$DATA_DIR`)
|
||||
2. **Generates secrets** (stored securely in data directory):
|
||||
- `BETTER_AUTH_SECRET` - Session authentication (32-char hex)
|
||||
- `ENCRYPTION_SECRET` - Token encryption (48-char base64)
|
||||
3. **Initializes database**: SQLite database with Drizzle migrations
|
||||
4. **Runs startup scripts**:
|
||||
- Environment configuration loader
|
||||
- Crash recovery for interrupted jobs
|
||||
- Repository status repair
|
||||
5. **Starts the application** with graceful shutdown handling
|
||||
|
||||
## NixOS Module - Minimal Deployment
|
||||
|
||||
### Simplest Possible Configuration
|
||||
|
||||
Add to your NixOS configuration (`/etc/nixos/configuration.nix`):
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, gitea-mirror, ... }: {
|
||||
nixosConfigurations.your-hostname = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
gitea-mirror.nixosModules.default
|
||||
{
|
||||
# That's it! Just enable the service
|
||||
services.gitea-mirror.enable = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Apply with:
|
||||
```bash
|
||||
sudo nixos-rebuild switch
|
||||
```
|
||||
|
||||
Access at http://localhost:4321, sign up (first user is admin), and configure everything via the web UI.
|
||||
|
||||
### Production Configuration
|
||||
|
||||
For production with custom domain and firewall:
|
||||
|
||||
```nix
|
||||
{
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
host = "0.0.0.0";
|
||||
port = 4321;
|
||||
betterAuthUrl = "https://mirror.example.com";
|
||||
betterAuthTrustedOrigins = "https://mirror.example.com";
|
||||
openFirewall = true;
|
||||
};
|
||||
|
||||
# Optional: Use with nginx reverse proxy
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."mirror.example.com" = {
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:4321";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
enableACME = true;
|
||||
forceSSL = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced: Manual Secret Management
|
||||
|
||||
If you prefer to manage secrets manually (e.g., with sops-nix or agenix):
|
||||
|
||||
1. Create a secrets file:
|
||||
```bash
|
||||
# /var/lib/gitea-mirror/secrets.env
|
||||
BETTER_AUTH_SECRET=your-32-character-minimum-secret-key-here
|
||||
ENCRYPTION_SECRET=your-encryption-secret-here
|
||||
```
|
||||
|
||||
2. Reference it in your configuration:
|
||||
```nix
|
||||
{
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
environmentFile = "/var/lib/gitea-mirror/secrets.env";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Full Configuration Options
|
||||
|
||||
```nix
|
||||
{
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
package = gitea-mirror.packages.x86_64-linux.default; # Override package
|
||||
dataDir = "/var/lib/gitea-mirror";
|
||||
user = "gitea-mirror";
|
||||
group = "gitea-mirror";
|
||||
host = "0.0.0.0";
|
||||
port = 4321;
|
||||
betterAuthUrl = "https://mirror.example.com";
|
||||
betterAuthTrustedOrigins = "https://mirror.example.com";
|
||||
|
||||
# Concurrency controls (match docker-compose.alt.yml)
|
||||
mirrorIssueConcurrency = 3; # Set to 1 for perfect chronological order
|
||||
mirrorPullRequestConcurrency = 5; # Set to 1 for perfect chronological order
|
||||
|
||||
environmentFile = null; # Optional secrets file
|
||||
openFirewall = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Service Management (NixOS)
|
||||
|
||||
```bash
|
||||
# Start the service
|
||||
sudo systemctl start gitea-mirror
|
||||
|
||||
# Stop the service
|
||||
sudo systemctl stop gitea-mirror
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart gitea-mirror
|
||||
|
||||
# Check status
|
||||
sudo systemctl status gitea-mirror
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u gitea-mirror -f
|
||||
|
||||
# Health check
|
||||
curl http://localhost:4321/api/health
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All variables from `docker-compose.alt.yml` are supported:
|
||||
|
||||
```bash
|
||||
# === AUTO-GENERATED (Don't set unless you want specific values) ===
|
||||
BETTER_AUTH_SECRET # Auto-generated, stored in data dir
|
||||
ENCRYPTION_SECRET # Auto-generated, stored in data dir
|
||||
|
||||
# === CORE SETTINGS (Have good defaults) ===
|
||||
DATA_DIR="$HOME/.local/share/gitea-mirror"
|
||||
DATABASE_URL="file:$DATA_DIR/gitea-mirror.db"
|
||||
HOST="0.0.0.0"
|
||||
PORT="4321"
|
||||
NODE_ENV="production"
|
||||
|
||||
# === BETTER AUTH (Override for custom domains) ===
|
||||
BETTER_AUTH_URL="http://localhost:4321"
|
||||
BETTER_AUTH_TRUSTED_ORIGINS="http://localhost:4321"
|
||||
PUBLIC_BETTER_AUTH_URL="http://localhost:4321"
|
||||
|
||||
# === CONCURRENCY CONTROLS ===
|
||||
MIRROR_ISSUE_CONCURRENCY=3 # Default: 3 (set to 1 for perfect order)
|
||||
MIRROR_PULL_REQUEST_CONCURRENCY=5 # Default: 5 (set to 1 for perfect order)
|
||||
|
||||
# === CONFIGURE VIA WEB UI (Not needed at startup) ===
|
||||
# GitHub credentials, Gitea settings, mirror options, scheduling, etc.
|
||||
# All configured after signup through the web interface
|
||||
```
|
||||
|
||||
## Database Management
|
||||
|
||||
The Nix package includes a database management helper:
|
||||
|
||||
```bash
|
||||
# Initialize database (done automatically on first run)
|
||||
gitea-mirror-db init
|
||||
|
||||
# Check database health
|
||||
gitea-mirror-db check
|
||||
|
||||
# Fix database issues
|
||||
gitea-mirror-db fix
|
||||
|
||||
# Reset users
|
||||
gitea-mirror-db reset-users
|
||||
```
|
||||
|
||||
## Home Manager Integration
|
||||
|
||||
For single-user deployments:
|
||||
|
||||
```nix
|
||||
{ config, pkgs, ... }:
|
||||
let
|
||||
gitea-mirror = (import (fetchTarball "https://github.com/RayLabsHQ/gitea-mirror/archive/main.tar.gz")).packages.${pkgs.system}.default;
|
||||
in {
|
||||
home.packages = [ gitea-mirror ];
|
||||
|
||||
# Optional: Run as user service
|
||||
systemd.user.services.gitea-mirror = {
|
||||
Unit = {
|
||||
Description = "Gitea Mirror Service";
|
||||
After = [ "network.target" ];
|
||||
};
|
||||
|
||||
Service = {
|
||||
Type = "simple";
|
||||
ExecStart = "${gitea-mirror}/bin/gitea-mirror";
|
||||
Restart = "always";
|
||||
Environment = [
|
||||
"DATA_DIR=%h/.local/share/gitea-mirror"
|
||||
"HOST=127.0.0.1"
|
||||
"PORT=4321"
|
||||
];
|
||||
};
|
||||
|
||||
Install = {
|
||||
WantedBy = [ "default.target" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Docker Image from Nix (Optional)
|
||||
|
||||
You can also use Nix to create a Docker image:
|
||||
|
||||
```nix
|
||||
# Add to flake.nix packages section
|
||||
dockerImage = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "gitea-mirror";
|
||||
tag = "latest";
|
||||
contents = [ self.packages.${system}.default pkgs.cacert pkgs.openssl ];
|
||||
config = {
|
||||
Cmd = [ "${self.packages.${system}.default}/bin/gitea-mirror" ];
|
||||
ExposedPorts = { "4321/tcp" = {}; };
|
||||
Env = [
|
||||
"DATA_DIR=/data"
|
||||
"DATABASE_URL=file:/data/gitea-mirror.db"
|
||||
];
|
||||
Volumes = { "/data" = {}; };
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Build and load:
|
||||
```bash
|
||||
nix build --extra-experimental-features 'nix-command flakes' .#dockerImage
|
||||
docker load < result
|
||||
docker run -p 4321:4321 -v gitea-mirror-data:/data gitea-mirror:latest
|
||||
```
|
||||
|
||||
## Comparison: Docker vs Nix
|
||||
|
||||
Both deployment methods follow the same philosophy:
|
||||
|
||||
| Feature | Docker Compose | Nix |
|
||||
|---------|---------------|-----|
|
||||
| **Configuration** | Minimal (only BETTER_AUTH_SECRET) | Zero config (auto-generated) |
|
||||
| **Secret Generation** | Auto-generated & persisted | Auto-generated & persisted |
|
||||
| **Database Init** | Automatic on first run | Automatic on first run |
|
||||
| **Startup Scripts** | Runs recovery/repair/env-config | Runs recovery/repair/env-config |
|
||||
| **Graceful Shutdown** | Signal handling in entrypoint | Signal handling in wrapper |
|
||||
| **Health Check** | Docker healthcheck | systemd timer (optional) |
|
||||
| **Updates** | `docker pull` | `nix flake update && nixos-rebuild` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check Auto-Generated Secrets
|
||||
```bash
|
||||
# For standalone
|
||||
cat ~/.local/share/gitea-mirror/.better_auth_secret
|
||||
cat ~/.local/share/gitea-mirror/.encryption_secret
|
||||
|
||||
# For NixOS service
|
||||
sudo cat /var/lib/gitea-mirror/.better_auth_secret
|
||||
sudo cat /var/lib/gitea-mirror/.encryption_secret
|
||||
```
|
||||
|
||||
### Database Issues
|
||||
```bash
|
||||
# Check if database exists
|
||||
ls -la ~/.local/share/gitea-mirror/gitea-mirror.db
|
||||
|
||||
# Reinitialize (deletes all data!)
|
||||
rm ~/.local/share/gitea-mirror/gitea-mirror.db
|
||||
gitea-mirror-db init
|
||||
```
|
||||
|
||||
### Permission Issues (NixOS)
|
||||
```bash
|
||||
sudo chown -R gitea-mirror:gitea-mirror /var/lib/gitea-mirror
|
||||
sudo chmod 700 /var/lib/gitea-mirror
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
```bash
|
||||
# Change port
|
||||
export PORT=8080
|
||||
gitea-mirror
|
||||
|
||||
# Or in NixOS config
|
||||
services.gitea-mirror.port = 8080;
|
||||
```
|
||||
|
||||
### View Startup Logs
|
||||
```bash
|
||||
# Standalone (verbose output on console)
|
||||
gitea-mirror
|
||||
|
||||
# NixOS service
|
||||
sudo journalctl -u gitea-mirror -f --since "5 minutes ago"
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
### Standalone Installation
|
||||
```bash
|
||||
# Update flake lock
|
||||
nix flake update --extra-experimental-features 'nix-command flakes'
|
||||
|
||||
# Rebuild
|
||||
nix build --extra-experimental-features 'nix-command flakes'
|
||||
|
||||
# Or update profile
|
||||
nix profile upgrade --extra-experimental-features 'nix-command flakes' gitea-mirror
|
||||
```
|
||||
|
||||
### NixOS
|
||||
```bash
|
||||
# Update input
|
||||
sudo nix flake lock --update-input gitea-mirror --extra-experimental-features 'nix-command flakes'
|
||||
|
||||
# Rebuild system
|
||||
sudo nixos-rebuild switch --flake .#your-hostname
|
||||
```
|
||||
|
||||
## Migration from Docker
|
||||
|
||||
To migrate from Docker to Nix while keeping your data:
|
||||
|
||||
1. **Stop Docker container:**
|
||||
```bash
|
||||
docker-compose -f docker-compose.alt.yml down
|
||||
```
|
||||
|
||||
2. **Copy data directory:**
|
||||
```bash
|
||||
# For standalone
|
||||
cp -r ./data ~/.local/share/gitea-mirror
|
||||
|
||||
# For NixOS
|
||||
sudo cp -r ./data /var/lib/gitea-mirror
|
||||
sudo chown -R gitea-mirror:gitea-mirror /var/lib/gitea-mirror
|
||||
```
|
||||
|
||||
3. **Copy secrets (if you want to keep them):**
|
||||
```bash
|
||||
# Extract from Docker volume
|
||||
docker run --rm -v gitea-mirror_data:/data alpine \
|
||||
cat /data/.better_auth_secret > better_auth_secret
|
||||
docker run --rm -v gitea-mirror_data:/data alpine \
|
||||
cat /data/.encryption_secret > encryption_secret
|
||||
|
||||
# Copy to new location
|
||||
cp better_auth_secret ~/.local/share/gitea-mirror/.better_auth_secret
|
||||
cp encryption_secret ~/.local/share/gitea-mirror/.encryption_secret
|
||||
chmod 600 ~/.local/share/gitea-mirror/.*_secret
|
||||
```
|
||||
|
||||
4. **Start Nix version:**
|
||||
```bash
|
||||
gitea-mirror
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Example GitHub Actions workflow (see `.github/workflows/nix-build.yml`):
|
||||
|
||||
```yaml
|
||||
name: Nix Build
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
- run: nix flake check
|
||||
- run: nix build --print-build-logs
|
||||
```
|
||||
|
||||
This uses:
|
||||
- **Determinate Nix Installer** - Fast, reliable Nix installation with flakes enabled by default
|
||||
- **Magic Nix Cache** - Free caching using GitHub Actions cache (no account needed)
|
||||
|
||||
## Resources
|
||||
|
||||
- [Nix Manual](https://nixos.org/manual/nix/stable/)
|
||||
- [NixOS Options Search](https://search.nixos.org/options)
|
||||
- [Nix Pills Tutorial](https://nixos.org/guides/nix-pills/)
|
||||
- [Project Documentation](../README.md)
|
||||
- [Docker Deployment](../docker-compose.alt.yml) - Equivalent minimal config
|
||||
@@ -0,0 +1,322 @@
|
||||
# Nix Package Distribution Guide
|
||||
|
||||
This guide explains how Gitea Mirror is distributed via Nix and how users can consume it.
|
||||
|
||||
## Distribution Methods
|
||||
|
||||
### Method 1: Direct GitHub Usage (Zero Infrastructure)
|
||||
|
||||
**No CI, releases, or setup needed!** Users can consume directly from GitHub:
|
||||
|
||||
```bash
|
||||
# Latest from main branch
|
||||
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
|
||||
# Pin to specific commit
|
||||
nix run github:RayLabsHQ/gitea-mirror/abc123def
|
||||
|
||||
# Pin to git tag
|
||||
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. Nix fetches the repository from GitHub
|
||||
2. Nix reads `flake.nix` and `flake.lock`
|
||||
3. Nix builds the package locally on the user's machine
|
||||
4. Package is cached in `/nix/store` for reuse
|
||||
|
||||
**Pros:**
|
||||
- Zero infrastructure needed
|
||||
- Works immediately after pushing code
|
||||
- Users always get reproducible builds
|
||||
|
||||
**Cons:**
|
||||
- Users must build from source (slower first time)
|
||||
- Requires build dependencies (Bun, etc.)
|
||||
|
||||
---
|
||||
|
||||
### Method 2: CI Build Caching
|
||||
|
||||
The GitHub Actions workflow uses **Magic Nix Cache** (by Determinate Systems) to cache builds:
|
||||
|
||||
- **Zero configuration required** - no accounts or tokens needed
|
||||
- **Automatic** - CI workflow handles everything
|
||||
- **Uses GitHub Actions cache** - fast, reliable, free
|
||||
|
||||
#### How It Works:
|
||||
|
||||
1. GitHub Actions builds the package on each push/PR
|
||||
2. Build artifacts are cached in GitHub Actions cache
|
||||
3. Subsequent builds reuse cached dependencies (faster CI)
|
||||
|
||||
Note: This caches CI builds. Users still build locally, but the flake.lock ensures reproducibility.
|
||||
|
||||
---
|
||||
|
||||
### Method 3: nixpkgs Submission (Official Distribution)
|
||||
|
||||
Submit to the official Nix package repository for maximum visibility.
|
||||
|
||||
#### Process:
|
||||
|
||||
1. **Prepare package** (already done with `flake.nix`)
|
||||
2. **Test thoroughly**
|
||||
3. **Submit PR to nixpkgs:** https://github.com/NixOS/nixpkgs
|
||||
|
||||
#### User Experience:
|
||||
|
||||
```bash
|
||||
# After acceptance into nixpkgs
|
||||
nix run nixpkgs#gitea-mirror
|
||||
|
||||
# NixOS configuration
|
||||
environment.systemPackages = [ pkgs.gitea-mirror ];
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Maximum discoverability (official repo)
|
||||
- Trusted by Nix community
|
||||
- Included in NixOS search
|
||||
- Binary caching by cache.nixos.org
|
||||
|
||||
**Cons:**
|
||||
- Submission/review process
|
||||
- Must follow nixpkgs guidelines
|
||||
- Updates require PRs
|
||||
|
||||
---
|
||||
|
||||
## Current Distribution Strategy
|
||||
|
||||
### Phase 1: Direct GitHub (Immediate) ✅
|
||||
|
||||
Already working! Users can:
|
||||
|
||||
```bash
|
||||
nix run github:RayLabsHQ/gitea-mirror
|
||||
```
|
||||
|
||||
### Phase 2: CI Build Validation ✅
|
||||
|
||||
GitHub Actions workflow validates builds on every push/PR:
|
||||
|
||||
- Uses Magic Nix Cache for fast CI builds
|
||||
- Tests on both Linux and macOS
|
||||
- No setup required - works automatically
|
||||
|
||||
### Phase 3: Version Releases (Optional)
|
||||
|
||||
Tag releases for version pinning:
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
|
||||
# Users can then pin:
|
||||
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
|
||||
```
|
||||
|
||||
### Phase 4: nixpkgs Submission (Long Term)
|
||||
|
||||
Once package is stable and well-tested, submit to nixpkgs.
|
||||
|
||||
---
|
||||
|
||||
## User Documentation
|
||||
|
||||
### For Users: How to Install
|
||||
|
||||
Add this to your `docs/NIX_DEPLOYMENT.md`:
|
||||
|
||||
#### Option 1: Direct Install (No Configuration)
|
||||
|
||||
```bash
|
||||
# Run immediately
|
||||
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
|
||||
# Install to profile
|
||||
nix profile install --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
|
||||
```
|
||||
|
||||
#### Option 2: Pin to Specific Version
|
||||
|
||||
```bash
|
||||
# Pin to git tag
|
||||
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
|
||||
|
||||
# Pin to commit
|
||||
nix run github:RayLabsHQ/gitea-mirror/abc123def
|
||||
|
||||
# Lock in flake.nix
|
||||
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror/vX.Y.Z";
|
||||
```
|
||||
|
||||
#### Option 3: NixOS Configuration
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
|
||||
# Or pin to version:
|
||||
# gitea-mirror.url = "github:RayLabsHQ/gitea-mirror/vX.Y.Z";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, gitea-mirror, ... }: {
|
||||
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
gitea-mirror.nixosModules.default
|
||||
{
|
||||
services.gitea-mirror = {
|
||||
enable = true;
|
||||
betterAuthUrl = "https://mirror.example.com";
|
||||
openFirewall = true;
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maintaining the Distribution
|
||||
|
||||
### Releasing New Versions
|
||||
|
||||
```bash
|
||||
# 1. Update version in package.json
|
||||
vim package.json # Update version field
|
||||
|
||||
# 2. Update flake.nix version (line 17)
|
||||
vim flake.nix # Update version = "X.Y.Z";
|
||||
|
||||
# 3. Commit changes
|
||||
git add package.json flake.nix
|
||||
git commit -m "chore: bump version to vX.Y.Z"
|
||||
|
||||
# 4. Create git tag
|
||||
git tag vX.Y.Z
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
|
||||
# 5. GitHub Actions builds and caches automatically
|
||||
```
|
||||
|
||||
Users can then pin to the new version:
|
||||
```bash
|
||||
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
|
||||
```
|
||||
|
||||
### Updating Flake Lock
|
||||
|
||||
The `flake.lock` file pins all dependencies. Update it periodically:
|
||||
|
||||
```bash
|
||||
# Update all inputs
|
||||
nix flake update
|
||||
|
||||
# Update specific input
|
||||
nix flake lock --update-input nixpkgs
|
||||
|
||||
# Test after update
|
||||
nix build
|
||||
nix flake check
|
||||
|
||||
# Commit the updated lock file
|
||||
git add flake.lock
|
||||
git commit -m "chore: update flake dependencies"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Distribution Issues
|
||||
|
||||
### Users Report Build Failures
|
||||
|
||||
1. **Check GitHub Actions:** Ensure CI is passing
|
||||
2. **Test locally:** `nix flake check`
|
||||
3. **Check flake.lock:** May need update if dependencies changed
|
||||
|
||||
### CI Cache Not Working
|
||||
|
||||
1. **Check workflow logs:** Review GitHub Actions for errors
|
||||
2. **Clear cache:** GitHub Actions → Caches → Delete relevant cache
|
||||
3. **Verify flake.lock:** May need `nix flake update` if dependencies changed
|
||||
|
||||
### Version Pinning Not Working
|
||||
|
||||
```bash
|
||||
# Verify tag exists
|
||||
git tag -l
|
||||
|
||||
# Ensure tag is pushed
|
||||
git ls-remote --tags origin
|
||||
|
||||
# Test specific tag
|
||||
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced: Custom Binary Cache
|
||||
|
||||
If you prefer self-hosting instead of Cachix:
|
||||
|
||||
### Option 1: S3-Compatible Storage
|
||||
|
||||
```nix
|
||||
# Generate signing key
|
||||
nix-store --generate-binary-cache-key cache.example.com cache-priv-key.pem cache-pub-key.pem
|
||||
|
||||
# Push to S3
|
||||
nix copy --to s3://my-nix-cache?region=us-east-1 $(nix-build)
|
||||
```
|
||||
|
||||
Users configure:
|
||||
```nix
|
||||
substituters = https://my-bucket.s3.amazonaws.com/nix-cache
|
||||
trusted-public-keys = cache.example.com:BASE64_PUBLIC_KEY
|
||||
```
|
||||
|
||||
### Option 2: Self-Hosted Nix Store
|
||||
|
||||
Run `nix-serve` on your server:
|
||||
|
||||
```bash
|
||||
# On server
|
||||
nix-serve -p 8080
|
||||
|
||||
# Behind nginx/caddy
|
||||
proxy_pass http://localhost:8080;
|
||||
```
|
||||
|
||||
Users configure:
|
||||
```nix
|
||||
substituters = https://cache.example.com
|
||||
trusted-public-keys = YOUR_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Distribution Methods
|
||||
|
||||
| Method | Setup Time | User Speed | Cost | Discoverability |
|
||||
|--------|-----------|------------|------|-----------------|
|
||||
| Direct GitHub | 0 min | Slow (build) | Free | Low |
|
||||
| nixpkgs | Hours/days | Fast (binary) | Free | High |
|
||||
| Self-hosted cache | 30+ min | Fast (binary) | Server cost | Low |
|
||||
|
||||
**Current approach:** Direct GitHub consumption with CI validation using Magic Nix Cache. Users build locally (reproducible via flake.lock). Consider **nixpkgs** submission for maximum reach once the package is mature.
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Nix Flakes Documentation](https://nixos.wiki/wiki/Flakes)
|
||||
- [Magic Nix Cache](https://github.com/DeterminateSystems/magic-nix-cache-action)
|
||||
- [nixpkgs Contributing Guide](https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md)
|
||||
- [Nix Binary Cache Setup](https://nixos.org/manual/nix/stable/package-management/binary-cache-substituter.html)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Notifications
|
||||
|
||||
Gitea Mirror supports push notifications for mirror events. You can be alerted when jobs succeed, fail, or when new repositories are discovered.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### 1. Ntfy.sh (Direct)
|
||||
|
||||
[Ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can use the public server at `https://ntfy.sh` or self-host your own instance.
|
||||
|
||||
**Setup (public server):**
|
||||
1. Go to **Configuration > Notifications**
|
||||
2. Enable notifications and select **Ntfy.sh** as the provider
|
||||
3. Set the **Topic** to a unique name (e.g., `my-gitea-mirror-abc123`)
|
||||
4. Leave the Server URL as `https://ntfy.sh`
|
||||
5. Subscribe to the same topic on your phone or desktop using the [ntfy app](https://ntfy.sh/docs/subscribe/phone/)
|
||||
|
||||
**Setup (self-hosted):**
|
||||
1. Deploy ntfy using Docker: `docker run -p 8080:80 binwiederhier/ntfy serve`
|
||||
2. Set the **Server URL** to your instance (e.g., `http://ntfy:8080`)
|
||||
3. If authentication is enabled, provide an **Access token**
|
||||
4. Set your **Topic** name
|
||||
|
||||
**Priority levels:**
|
||||
- `min` / `low` / `default` / `high` / `urgent`
|
||||
- Error notifications automatically use `high` priority regardless of the default setting
|
||||
|
||||
### 2. Apprise API (Aggregator)
|
||||
|
||||
[Apprise](https://github.com/caronc/apprise-api) is a notification aggregator that supports 100+ services (Slack, Discord, Telegram, Email, Pushover, and many more) through a single API.
|
||||
|
||||
**Setup:**
|
||||
1. Deploy the Apprise API server:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
apprise:
|
||||
image: caronc/apprise:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- apprise-config:/config
|
||||
volumes:
|
||||
apprise-config:
|
||||
```
|
||||
2. Configure your notification services in Apprise (via its web UI at `http://localhost:8000` or API)
|
||||
3. Create a configuration token/key in Apprise
|
||||
4. In Gitea Mirror, go to **Configuration > Notifications**
|
||||
5. Enable notifications and select **Apprise API**
|
||||
6. Set the **Server URL** to your Apprise instance (e.g., `http://apprise:8000`)
|
||||
7. Enter the **Token/path** you created in step 3
|
||||
|
||||
**Tag filtering:**
|
||||
- Optionally set a **Tag** to only notify specific Apprise services
|
||||
- Leave empty to notify all configured services
|
||||
|
||||
## Event Types
|
||||
|
||||
| Event | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| Sync errors | On | A mirror job failed |
|
||||
| Sync success | Off | A mirror job completed successfully |
|
||||
| New repo discovered | Off | A new GitHub repo was auto-imported during scheduled sync |
|
||||
|
||||
## Testing
|
||||
|
||||
Use the **Send Test Notification** button on the Notifications settings page to verify your configuration. The test sends a sample success notification to your configured provider.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Notifications not arriving:**
|
||||
- Check that notifications are enabled in the settings
|
||||
- Verify the provider configuration (URL, topic/token)
|
||||
- Use the Test button to check connectivity
|
||||
- Check the server logs for `[NotificationService]` messages
|
||||
|
||||
**Ntfy authentication errors:**
|
||||
- Ensure your access token is correct
|
||||
- If self-hosting, verify the ntfy server allows the topic
|
||||
|
||||
**Apprise connection refused:**
|
||||
- Verify the Apprise API server is running and accessible from the Gitea Mirror container
|
||||
- If using Docker, ensure both containers are on the same network
|
||||
- Check the Apprise server logs for errors
|
||||
|
||||
**Tokens and security:**
|
||||
- Notification tokens (ntfy access tokens, Apprise tokens) are encrypted at rest using the same AES-256-GCM encryption as GitHub/Gitea tokens
|
||||
- Tokens are decrypted only when sending notifications or displaying in the settings UI
|
||||
@@ -7,6 +7,8 @@ This folder contains engineering and operations references for the open-source G
|
||||
### Core workflow
|
||||
- **[DEVELOPMENT_WORKFLOW.md](./DEVELOPMENT_WORKFLOW.md)** – Set up a local environment, run scripts, and understand the repo layout (app + marketing site).
|
||||
- **[ENVIRONMENT_VARIABLES.md](./ENVIRONMENT_VARIABLES.md)** – Complete reference for every configuration flag supported by the app and Docker images.
|
||||
- **[NIX_DEPLOYMENT.md](./NIX_DEPLOYMENT.md)** – User-facing deployment guide for Nix and NixOS.
|
||||
- **[NIX_DISTRIBUTION.md](./NIX_DISTRIBUTION.md)** – Maintainer notes for packaging, releases, and distribution strategy.
|
||||
|
||||
### Reliability & recovery
|
||||
- **[GRACEFUL_SHUTDOWN.md](./GRACEFUL_SHUTDOWN.md)** – How signal handling, shutdown coordination, and job persistence work in v3.
|
||||
@@ -32,8 +34,6 @@ The first user you create locally becomes the administrator. All other configura
|
||||
## Contributing & support
|
||||
|
||||
- 🎯 Contribution guide: [../CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
- 📘 Code of conduct: [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)
|
||||
- 🐞 Issues & feature requests: <https://github.com/RayLabsHQ/gitea-mirror/issues>
|
||||
- 💬 Discussions: <https://github.com/RayLabsHQ/gitea-mirror/discussions>
|
||||
|
||||
Security disclosures should follow the process in [../SECURITY.md](../SECURITY.md).
|
||||
- 🔐 Security policy & advisories: <https://github.com/RayLabsHQ/gitea-mirror/security>
|
||||
@@ -81,6 +81,26 @@ Replace `{provider-id}` with your chosen Provider ID.
|
||||
- Client Secret: [Your Okta Client Secret]
|
||||
- Click "Discover" to auto-fill endpoints
|
||||
|
||||
### Example: Authentik SSO Setup
|
||||
|
||||
Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mirror/issues/134)) follow these steps:
|
||||
|
||||
1. In Authentik, create a new **Application** and OIDC **Provider** (implicit flow works well for testing).
|
||||
2. Start creating an SSO provider inside Gitea Mirror so you can copy the redirect URL shown (`https://your-domain.com/api/auth/sso/callback/authentik` if you pick `authentik` as your Provider ID).
|
||||
3. Paste that redirect URL into the Authentik Provider configuration and finish creating the provider.
|
||||
4. Copy the Authentik issuer URL, client ID, and client secret.
|
||||
5. Back in Gitea Mirror:
|
||||
- Issuer URL: the exact value from Authentik (keep any trailing slash Authentik shows).
|
||||
- Provider ID: match the one you used in step 2.
|
||||
- Click **Discover** so Gitea Mirror stores the authorization, token, and JWKS endpoints (Authentik publishes them via discovery).
|
||||
- Domain: enter the email domain you expect to match (e.g. `example.com`).
|
||||
6. Save the provider and test the login flow.
|
||||
|
||||
Notes:
|
||||
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
|
||||
- Authentik must report the user’s email as verified (default behavior) so Gitea Mirror can auto-link accounts.
|
||||
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
|
||||
|
||||
## Setting up OIDC Provider
|
||||
|
||||
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
|
||||
@@ -165,6 +185,7 @@ When an application requests authentication:
|
||||
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
|
||||
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
|
||||
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
|
||||
4. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
|
||||
### OIDC Provider Issues
|
||||
|
||||
@@ -202,4 +223,4 @@ This immediately prevents the application from authenticating new users.
|
||||
If migrating from the previous JWT-based authentication:
|
||||
- Existing users remain unaffected
|
||||
- Users can continue using email/password authentication
|
||||
- SSO can be added as an additional authentication method
|
||||
- SSO can be added as an additional authentication method
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 144 KiB |
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `accounts` ADD `id_token` text;--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD `access_token_expires_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD `refresh_token_expires_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD `scope` text;
|
||||
@@ -0,0 +1,18 @@
|
||||
ALTER TABLE `organizations` ADD `normalized_name` text NOT NULL DEFAULT '';--> statement-breakpoint
|
||||
UPDATE `organizations` SET `normalized_name` = lower(trim(`name`));--> statement-breakpoint
|
||||
DELETE FROM `organizations`
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MIN(rowid)
|
||||
FROM `organizations`
|
||||
GROUP BY `user_id`, `normalized_name`
|
||||
);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint
|
||||
ALTER TABLE `repositories` ADD `normalized_full_name` text NOT NULL DEFAULT '';--> statement-breakpoint
|
||||
UPDATE `repositories` SET `normalized_full_name` = lower(trim(`full_name`));--> statement-breakpoint
|
||||
DELETE FROM `repositories`
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MIN(rowid)
|
||||
FROM `repositories`
|
||||
GROUP BY `user_id`, `normalized_full_name`
|
||||
);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_repositories_user_normalized_full_name` ON `repositories` (`user_id`,`normalized_full_name`);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `repositories` ADD `metadata` text;
|
||||
@@ -0,0 +1,149 @@
|
||||
CREATE TABLE `__new_repositories` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`config_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`full_name` text NOT NULL,
|
||||
`normalized_full_name` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`clone_url` text NOT NULL,
|
||||
`owner` text NOT NULL,
|
||||
`organization` text,
|
||||
`mirrored_location` text DEFAULT '',
|
||||
`is_private` integer DEFAULT false NOT NULL,
|
||||
`is_fork` integer DEFAULT false NOT NULL,
|
||||
`forked_from` text,
|
||||
`has_issues` integer DEFAULT false NOT NULL,
|
||||
`is_starred` integer DEFAULT false NOT NULL,
|
||||
`is_archived` integer DEFAULT false NOT NULL,
|
||||
`size` integer DEFAULT 0 NOT NULL,
|
||||
`has_lfs` integer DEFAULT false NOT NULL,
|
||||
`has_submodules` integer DEFAULT false NOT NULL,
|
||||
`language` text,
|
||||
`description` text,
|
||||
`default_branch` text NOT NULL,
|
||||
`visibility` text DEFAULT 'public' NOT NULL,
|
||||
`status` text DEFAULT 'imported' NOT NULL,
|
||||
`last_mirrored` integer,
|
||||
`error_message` text,
|
||||
`destination_org` text,
|
||||
`metadata` text,
|
||||
`imported_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_repositories` (
|
||||
`id`,
|
||||
`user_id`,
|
||||
`config_id`,
|
||||
`name`,
|
||||
`full_name`,
|
||||
`normalized_full_name`,
|
||||
`url`,
|
||||
`clone_url`,
|
||||
`owner`,
|
||||
`organization`,
|
||||
`mirrored_location`,
|
||||
`is_private`,
|
||||
`is_fork`,
|
||||
`forked_from`,
|
||||
`has_issues`,
|
||||
`is_starred`,
|
||||
`is_archived`,
|
||||
`size`,
|
||||
`has_lfs`,
|
||||
`has_submodules`,
|
||||
`language`,
|
||||
`description`,
|
||||
`default_branch`,
|
||||
`visibility`,
|
||||
`status`,
|
||||
`last_mirrored`,
|
||||
`error_message`,
|
||||
`destination_org`,
|
||||
`metadata`,
|
||||
`imported_at`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`repositories`.`id`,
|
||||
`repositories`.`user_id`,
|
||||
`repositories`.`config_id`,
|
||||
`repositories`.`name`,
|
||||
`repositories`.`full_name`,
|
||||
`repositories`.`normalized_full_name`,
|
||||
`repositories`.`url`,
|
||||
`repositories`.`clone_url`,
|
||||
`repositories`.`owner`,
|
||||
`repositories`.`organization`,
|
||||
`repositories`.`mirrored_location`,
|
||||
`repositories`.`is_private`,
|
||||
`repositories`.`is_fork`,
|
||||
`repositories`.`forked_from`,
|
||||
`repositories`.`has_issues`,
|
||||
`repositories`.`is_starred`,
|
||||
`repositories`.`is_archived`,
|
||||
`repositories`.`size`,
|
||||
`repositories`.`has_lfs`,
|
||||
`repositories`.`has_submodules`,
|
||||
`repositories`.`language`,
|
||||
`repositories`.`description`,
|
||||
`repositories`.`default_branch`,
|
||||
`repositories`.`visibility`,
|
||||
`repositories`.`status`,
|
||||
`repositories`.`last_mirrored`,
|
||||
`repositories`.`error_message`,
|
||||
`repositories`.`destination_org`,
|
||||
`repositories`.`metadata`,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT MIN(`mj`.`timestamp`)
|
||||
FROM `mirror_jobs` `mj`
|
||||
WHERE `mj`.`user_id` = `repositories`.`user_id`
|
||||
AND `mj`.`status` = 'imported'
|
||||
AND (
|
||||
(`mj`.`repository_id` IS NOT NULL AND `mj`.`repository_id` = `repositories`.`id`)
|
||||
OR (
|
||||
`mj`.`repository_id` IS NULL
|
||||
AND `mj`.`repository_name` IS NOT NULL
|
||||
AND (
|
||||
lower(trim(`mj`.`repository_name`)) = `repositories`.`normalized_full_name`
|
||||
OR lower(trim(`mj`.`repository_name`)) = lower(trim(`repositories`.`name`))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
`repositories`.`created_at`,
|
||||
unixepoch()
|
||||
) AS `imported_at`,
|
||||
`repositories`.`created_at`,
|
||||
`repositories`.`updated_at`
|
||||
FROM `repositories`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `repositories`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__new_repositories` RENAME TO `repositories`;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_user_id` ON `repositories` (`user_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_config_id` ON `repositories` (`config_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_status` ON `repositories` (`status`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_owner` ON `repositories` (`owner`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_organization` ON `repositories` (`organization`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_is_fork` ON `repositories` (`is_fork`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_is_starred` ON `repositories` (`is_starred`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_user_imported_at` ON `repositories` (`user_id`,`imported_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_repositories_user_full_name` ON `repositories` (`user_id`,`full_name`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_repositories_user_normalized_full_name` ON `repositories` (`user_id`,`normalized_full_name`);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add index for mirroredLocation lookups (used by name collision detection)
|
||||
CREATE INDEX IF NOT EXISTS `idx_repositories_mirrored_location` ON `repositories` (`user_id`, `mirrored_location`);
|
||||
|
||||
-- Add unique partial index to enforce that no two repos for the same user
|
||||
-- can claim the same non-empty mirroredLocation. This prevents race conditions
|
||||
-- during concurrent batch mirroring of starred repos with duplicate names.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `uniq_repositories_user_mirrored_location`
|
||||
ON `repositories` (`user_id`, `mirrored_location`)
|
||||
WHERE `mirrored_location` != '';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;
|
||||
@@ -43,6 +43,48 @@
|
||||
"when": 1757786449446,
|
||||
"tag": "0005_polite_preak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1761483928546,
|
||||
"tag": "0006_military_la_nuit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761534391115,
|
||||
"tag": "0007_whole_hellion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761802056073,
|
||||
"tag": "0008_serious_thena",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1773542995732,
|
||||
"tag": "0009_nervous_tyger_tiger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1774054800000,
|
||||
"tag": "0010_mirrored_location_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1774058400000,
|
||||
"tag": "0011_notification_config",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"nodes": {
|
||||
"bun2nix": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"import-tree": "import-tree",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770895533,
|
||||
"narHash": "sha256-v3QaK9ugy9bN9RXDnjw0i2OifKmz2NnKM82agtqm/UY=",
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"rev": "c843f477b15f51151f8c6bcc886954699440a6e1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"import-tree": {
|
||||
"locked": {
|
||||
"lastModified": 1763762820,
|
||||
"narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=",
|
||||
"owner": "vic",
|
||||
"repo": "import-tree",
|
||||
"rev": "3c23749d8013ec6daa1d7255057590e9ca726646",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "vic",
|
||||
"repo": "import-tree",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1761672384,
|
||||
"narHash": "sha256-o9KF3DJL7g7iYMZq9SWgfS1BFlNbsm6xplRjVlOCkXI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "08dacfca559e1d7da38f3cf05f1f45ee9bfd213c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769909678,
|
||||
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "72716169fe93074c333e8d0173151350670b824c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"bun2nix": "bun2nix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"bun2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770228511,
|
||||
"narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "337a4fe074be1042a35086f15481d763b8ddc0e7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
{
|
||||
description = "Gitea Mirror - Self-hosted GitHub to Gitea mirroring service";
|
||||
|
||||
nixConfig = {
|
||||
extra-substituters = [
|
||||
"https://nix-community.cachix.org"
|
||||
];
|
||||
extra-trusted-public-keys = [
|
||||
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
|
||||
];
|
||||
};
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
bun2nix = {
|
||||
url = "github:nix-community/bun2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, bun2nix }:
|
||||
let
|
||||
forEachSystem = flake-utils.lib.eachDefaultSystem;
|
||||
in
|
||||
(forEachSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
b2n = bun2nix.packages.${system}.default;
|
||||
|
||||
# Build the application
|
||||
gitea-mirror = pkgs.stdenv.mkDerivation {
|
||||
pname = "gitea-mirror";
|
||||
version = "3.14.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.bun
|
||||
b2n.hook
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
sqlite
|
||||
openssl
|
||||
];
|
||||
|
||||
bunDeps = b2n.fetchBunDeps {
|
||||
bunNix = ./bun.nix;
|
||||
};
|
||||
|
||||
# bun2nix defaults to isolated installs on Linux, which can be
|
||||
# very slow in CI for larger dependency trees and may appear stuck.
|
||||
# Use hoisted linker and fail fast on lockfile drift.
|
||||
bunInstallFlags = if pkgs.stdenv.hostPlatform.isDarwin then [
|
||||
"--linker=hoisted"
|
||||
"--backend=copyfile"
|
||||
"--frozen-lockfile"
|
||||
"--no-progress"
|
||||
] else [
|
||||
"--linker=hoisted"
|
||||
"--frozen-lockfile"
|
||||
"--no-progress"
|
||||
];
|
||||
|
||||
# Let the bun2nix hook handle dependency installation via the
|
||||
# pre-fetched cache, but skip its default build/check/install
|
||||
# phases since we have custom ones.
|
||||
dontUseBunBuild = true;
|
||||
dontUseBunCheck = true;
|
||||
dontUseBunInstall = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$TMPDIR
|
||||
|
||||
# The bun2nix cache is in the read-only Nix store, but bunx/astro
|
||||
# may try to write to it at build time. Copy the cache to a
|
||||
# writable location.
|
||||
if [ -n "$BUN_INSTALL_CACHE_DIR" ] && [ -d "$BUN_INSTALL_CACHE_DIR" ]; then
|
||||
WRITABLE_CACHE="$TMPDIR/bun-cache"
|
||||
cp -rL "$BUN_INSTALL_CACHE_DIR" "$WRITABLE_CACHE" 2>/dev/null || true
|
||||
chmod -R u+w "$WRITABLE_CACHE" 2>/dev/null || true
|
||||
export BUN_INSTALL_CACHE_DIR="$WRITABLE_CACHE"
|
||||
fi
|
||||
|
||||
# Build the Astro application
|
||||
bun run build
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/gitea-mirror
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Copy the built application
|
||||
cp -r dist $out/lib/gitea-mirror/
|
||||
cp -r node_modules $out/lib/gitea-mirror/
|
||||
cp -r scripts $out/lib/gitea-mirror/
|
||||
cp -r src $out/lib/gitea-mirror/
|
||||
cp -r drizzle $out/lib/gitea-mirror/
|
||||
cp package.json $out/lib/gitea-mirror/
|
||||
cp tsconfig.json $out/lib/gitea-mirror/
|
||||
|
||||
# Create entrypoint script that matches Docker behavior
|
||||
cat > $out/bin/gitea-mirror <<'EOF'
|
||||
#!${pkgs.bash}/bin/bash
|
||||
set -e
|
||||
|
||||
# === DEFAULT CONFIGURATION ===
|
||||
# These match docker-compose.alt.yml defaults
|
||||
export DATA_DIR=''${DATA_DIR:-"$HOME/.local/share/gitea-mirror"}
|
||||
export DATABASE_URL=''${DATABASE_URL:-"file:$DATA_DIR/gitea-mirror.db"}
|
||||
export HOST=''${HOST:-"0.0.0.0"}
|
||||
export PORT=''${PORT:-"4321"}
|
||||
export NODE_ENV=''${NODE_ENV:-"production"}
|
||||
|
||||
# Better Auth configuration
|
||||
export BETTER_AUTH_URL=''${BETTER_AUTH_URL:-"http://localhost:4321"}
|
||||
export BETTER_AUTH_TRUSTED_ORIGINS=''${BETTER_AUTH_TRUSTED_ORIGINS:-"http://localhost:4321"}
|
||||
export PUBLIC_BETTER_AUTH_URL=''${PUBLIC_BETTER_AUTH_URL:-"http://localhost:4321"}
|
||||
|
||||
# Concurrency settings (match docker-compose.alt.yml)
|
||||
export MIRROR_ISSUE_CONCURRENCY=''${MIRROR_ISSUE_CONCURRENCY:-3}
|
||||
export MIRROR_PULL_REQUEST_CONCURRENCY=''${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
|
||||
|
||||
# Create data directory
|
||||
mkdir -p "$DATA_DIR"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$SCRIPT_DIR/../lib/gitea-mirror"
|
||||
|
||||
# The app uses process.cwd()/data for the database, but the Nix store
|
||||
# is read-only. Create a writable working directory with symlinks to
|
||||
# the app files and a real data directory.
|
||||
WORK_DIR="$DATA_DIR/.workdir"
|
||||
mkdir -p "$WORK_DIR"
|
||||
for item in dist node_modules scripts src drizzle package.json tsconfig.json; do
|
||||
ln -sfn "$APP_DIR/$item" "$WORK_DIR/$item"
|
||||
done
|
||||
ln -sfn "$DATA_DIR" "$WORK_DIR/data"
|
||||
cd "$WORK_DIR"
|
||||
|
||||
# === AUTO-GENERATE SECRETS ===
|
||||
BETTER_AUTH_SECRET_FILE="$DATA_DIR/.better_auth_secret"
|
||||
ENCRYPTION_SECRET_FILE="$DATA_DIR/.encryption_secret"
|
||||
|
||||
# Generate BETTER_AUTH_SECRET if not provided
|
||||
if [ -z "$BETTER_AUTH_SECRET" ]; then
|
||||
if [ -f "$BETTER_AUTH_SECRET_FILE" ]; then
|
||||
echo "Using previously generated BETTER_AUTH_SECRET"
|
||||
export BETTER_AUTH_SECRET=$(cat "$BETTER_AUTH_SECRET_FILE")
|
||||
else
|
||||
echo "Generating a secure random BETTER_AUTH_SECRET"
|
||||
GENERATED_SECRET=$(${pkgs.openssl}/bin/openssl rand -hex 32)
|
||||
export BETTER_AUTH_SECRET="$GENERATED_SECRET"
|
||||
echo "$GENERATED_SECRET" > "$BETTER_AUTH_SECRET_FILE"
|
||||
chmod 600 "$BETTER_AUTH_SECRET_FILE"
|
||||
echo "✅ BETTER_AUTH_SECRET generated and saved to $BETTER_AUTH_SECRET_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate ENCRYPTION_SECRET if not provided
|
||||
if [ -z "$ENCRYPTION_SECRET" ]; then
|
||||
if [ -f "$ENCRYPTION_SECRET_FILE" ]; then
|
||||
echo "Using previously generated ENCRYPTION_SECRET"
|
||||
export ENCRYPTION_SECRET=$(cat "$ENCRYPTION_SECRET_FILE")
|
||||
else
|
||||
echo "Generating a secure random ENCRYPTION_SECRET"
|
||||
GENERATED_ENCRYPTION_SECRET=$(${pkgs.openssl}/bin/openssl rand -base64 36)
|
||||
export ENCRYPTION_SECRET="$GENERATED_ENCRYPTION_SECRET"
|
||||
echo "$GENERATED_ENCRYPTION_SECRET" > "$ENCRYPTION_SECRET_FILE"
|
||||
chmod 600 "$ENCRYPTION_SECRET_FILE"
|
||||
echo "✅ ENCRYPTION_SECRET generated and saved to $ENCRYPTION_SECRET_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# === DATABASE INITIALIZATION ===
|
||||
DB_PATH=$(echo "$DATABASE_URL" | ${pkgs.gnused}/bin/sed 's|^file:||')
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Database not found. It will be created and initialized via Drizzle migrations on first app startup..."
|
||||
touch "$DB_PATH"
|
||||
else
|
||||
echo "Database already exists, Drizzle will check for pending migrations on startup..."
|
||||
fi
|
||||
|
||||
# === STARTUP SCRIPTS ===
|
||||
# Initialize configuration from environment variables
|
||||
echo "Checking for environment configuration..."
|
||||
if [ -f "scripts/startup-env-config.ts" ]; then
|
||||
echo "Loading configuration from environment variables..."
|
||||
${pkgs.bun}/bin/bun scripts/startup-env-config.ts && \
|
||||
echo "✅ Environment configuration loaded successfully" || \
|
||||
echo "⚠️ Environment configuration loading completed with warnings"
|
||||
fi
|
||||
|
||||
# Run startup recovery
|
||||
echo "Running startup recovery..."
|
||||
if [ -f "scripts/startup-recovery.ts" ]; then
|
||||
${pkgs.bun}/bin/bun scripts/startup-recovery.ts --timeout=30000 && \
|
||||
echo "✅ Startup recovery completed successfully" || \
|
||||
echo "⚠️ Startup recovery completed with warnings"
|
||||
fi
|
||||
|
||||
# Run repository status repair
|
||||
echo "Running repository status repair..."
|
||||
if [ -f "scripts/repair-mirrored-repos.ts" ]; then
|
||||
${pkgs.bun}/bin/bun scripts/repair-mirrored-repos.ts --startup && \
|
||||
echo "✅ Repository status repair completed successfully" || \
|
||||
echo "⚠️ Repository status repair completed with warnings"
|
||||
fi
|
||||
|
||||
# === SIGNAL HANDLING ===
|
||||
shutdown_handler() {
|
||||
echo "🛑 Received shutdown signal, forwarding to application..."
|
||||
if [ ! -z "$APP_PID" ]; then
|
||||
kill -TERM "$APP_PID" 2>/dev/null || true
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap 'shutdown_handler' TERM INT HUP
|
||||
|
||||
# === START APPLICATION ===
|
||||
echo "Starting Gitea Mirror..."
|
||||
echo "Access the web interface at $BETTER_AUTH_URL"
|
||||
${pkgs.bun}/bin/bun dist/server/entry.mjs &
|
||||
APP_PID=$!
|
||||
|
||||
wait "$APP_PID"
|
||||
EOF
|
||||
chmod +x $out/bin/gitea-mirror
|
||||
|
||||
# Create database management helper
|
||||
cat > $out/bin/gitea-mirror-db <<'EOF'
|
||||
#!${pkgs.bash}/bin/bash
|
||||
export DATA_DIR=''${DATA_DIR:-"$HOME/.local/share/gitea-mirror"}
|
||||
mkdir -p "$DATA_DIR"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR/../lib/gitea-mirror"
|
||||
exec ${pkgs.bun}/bin/bun scripts/manage-db.ts "$@"
|
||||
EOF
|
||||
chmod +x $out/bin/gitea-mirror-db
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Self-hosted GitHub to Gitea mirroring service";
|
||||
homepage = "https://github.com/RayLabsHQ/gitea-mirror";
|
||||
license = licenses.mit;
|
||||
maintainers = [ ];
|
||||
platforms = platforms.linux ++ platforms.darwin;
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
default = gitea-mirror;
|
||||
gitea-mirror = gitea-mirror;
|
||||
};
|
||||
|
||||
# Development shell
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
bun
|
||||
sqlite
|
||||
openssl
|
||||
b2n
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "🚀 Gitea Mirror development environment"
|
||||
echo ""
|
||||
echo "Quick start:"
|
||||
echo " bun install # Install dependencies"
|
||||
echo " bun run dev # Start development server"
|
||||
echo " bun run build # Build for production"
|
||||
echo ""
|
||||
echo "Nix packaging:"
|
||||
echo " bun2nix -o bun.nix # Regenerate bun.nix after dependency changes"
|
||||
echo " nix build # Build the package"
|
||||
echo ""
|
||||
echo "Database:"
|
||||
echo " bun run manage-db init # Initialize database"
|
||||
echo " bun run db:studio # Open Drizzle Studio"
|
||||
'';
|
||||
};
|
||||
|
||||
}
|
||||
)) // {
|
||||
nixosModules.default = { config, lib, pkgs, ... }:
|
||||
with lib;
|
||||
let
|
||||
cfg = config.services.gitea-mirror;
|
||||
in {
|
||||
options.services.gitea-mirror = {
|
||||
enable = mkEnableOption "Gitea Mirror service";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = self.packages.${pkgs.system}.default;
|
||||
description = "The Gitea Mirror package to use";
|
||||
};
|
||||
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/gitea-mirror";
|
||||
description = "Directory to store data and database";
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "gitea-mirror";
|
||||
description = "User account under which Gitea Mirror runs";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "gitea-mirror";
|
||||
description = "Group under which Gitea Mirror runs";
|
||||
};
|
||||
|
||||
host = mkOption {
|
||||
type = types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Host to bind to";
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 4321;
|
||||
description = "Port to listen on";
|
||||
};
|
||||
|
||||
betterAuthUrl = mkOption {
|
||||
type = types.str;
|
||||
default = "http://localhost:4321";
|
||||
description = "Better Auth URL (external URL of the service)";
|
||||
};
|
||||
|
||||
betterAuthTrustedOrigins = mkOption {
|
||||
type = types.str;
|
||||
default = "http://localhost:4321";
|
||||
description = "Comma-separated list of trusted origins for Better Auth";
|
||||
};
|
||||
|
||||
mirrorIssueConcurrency = mkOption {
|
||||
type = types.int;
|
||||
default = 3;
|
||||
description = "Number of concurrent issue mirror operations (set to 1 for perfect ordering)";
|
||||
};
|
||||
|
||||
mirrorPullRequestConcurrency = mkOption {
|
||||
type = types.int;
|
||||
default = 5;
|
||||
description = "Number of concurrent PR mirror operations (set to 1 for perfect ordering)";
|
||||
};
|
||||
|
||||
environmentFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to file containing environment variables.
|
||||
Only needed if you want to set BETTER_AUTH_SECRET or ENCRYPTION_SECRET manually.
|
||||
Otherwise, secrets will be auto-generated and stored in the data directory.
|
||||
|
||||
Example:
|
||||
BETTER_AUTH_SECRET=your-32-character-secret-here
|
||||
ENCRYPTION_SECRET=your-encryption-secret-here
|
||||
'';
|
||||
};
|
||||
|
||||
openFirewall = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Open the firewall for the specified port";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = {};
|
||||
|
||||
systemd.services.gitea-mirror = {
|
||||
description = "Gitea Mirror - GitHub to Gitea mirroring service";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
DATA_DIR = cfg.dataDir;
|
||||
DATABASE_URL = "file:${cfg.dataDir}/gitea-mirror.db";
|
||||
HOST = cfg.host;
|
||||
PORT = toString cfg.port;
|
||||
NODE_ENV = "production";
|
||||
BETTER_AUTH_URL = cfg.betterAuthUrl;
|
||||
BETTER_AUTH_TRUSTED_ORIGINS = cfg.betterAuthTrustedOrigins;
|
||||
PUBLIC_BETTER_AUTH_URL = cfg.betterAuthUrl;
|
||||
MIRROR_ISSUE_CONCURRENCY = toString cfg.mirrorIssueConcurrency;
|
||||
MIRROR_PULL_REQUEST_CONCURRENCY = toString cfg.mirrorPullRequestConcurrency;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
ExecStart = "${cfg.package}/bin/gitea-mirror";
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
|
||||
# Graceful shutdown
|
||||
TimeoutStopSec = "30s";
|
||||
KillMode = "mixed";
|
||||
KillSignal = "SIGTERM";
|
||||
} // optionalAttrs (cfg.environmentFile != null) {
|
||||
EnvironmentFile = cfg.environmentFile;
|
||||
};
|
||||
};
|
||||
|
||||
# Health check timer (optional monitoring)
|
||||
systemd.timers.gitea-mirror-healthcheck = {
|
||||
description = "Gitea Mirror health check timer";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "5min";
|
||||
OnUnitActiveSec = "5min";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.gitea-mirror-healthcheck = {
|
||||
description = "Gitea Mirror health check";
|
||||
after = [ "gitea-mirror.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${pkgs.bash}/bin/bash -c '${pkgs.curl}/bin/curl -f http://127.0.0.1:${toString cfg.port}/api/health || true'";
|
||||
User = "nobody";
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall = mkIf cfg.openFirewall {
|
||||
allowedTCPPorts = [ cfg.port ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Overlay for adding to nixpkgs
|
||||
overlays.default = final: prev: {
|
||||
gitea-mirror = self.packages.${final.system}.default;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.8.7",
|
||||
"version": "3.15.1",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -16,6 +16,7 @@
|
||||
"check-db": "bun scripts/manage-db.ts check",
|
||||
"fix-db": "bun scripts/manage-db.ts fix",
|
||||
"reset-users": "bun scripts/manage-db.ts reset-users",
|
||||
"reset-password": "bun scripts/manage-db.ts reset-password",
|
||||
"db:generate": "bun drizzle-kit generate",
|
||||
"db:migrate": "bun drizzle-kit migrate",
|
||||
"db:push": "bun drizzle-kit push",
|
||||
@@ -30,86 +31,99 @@
|
||||
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
|
||||
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
|
||||
"preview": "bunx --bun astro preview",
|
||||
"start": "bun dist/server/entry.mjs",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
|
||||
"start": "bun scripts/runtime-server.ts",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts",
|
||||
"test": "bun test",
|
||||
"test:migrations": "bun scripts/validate-migrations.ts",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"test:e2e": "bash tests/e2e/run-e2e.sh",
|
||||
"test:e2e:ci": "bash tests/e2e/run-e2e.sh --ci",
|
||||
"test:e2e:keep": "bash tests/e2e/run-e2e.sh --keep",
|
||||
"test:e2e:cleanup": "bash tests/e2e/cleanup.sh",
|
||||
"astro": "bunx --bun astro"
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.20.5",
|
||||
"devalue": "^5.3.2"
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"node-forge": "^1.3.3",
|
||||
"svgo": "^4.0.1",
|
||||
"rollup": ">=4.59.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.5",
|
||||
"@astrojs/mdx": "4.3.7",
|
||||
"@astrojs/node": "9.5.0",
|
||||
"@astrojs/react": "^4.4.0",
|
||||
"@better-auth/sso": "1.4.0-beta.12",
|
||||
"@octokit/plugin-throttling": "^11.0.2",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.19",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"astro": "^5.14.8",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"buffer": "^6.0.3",
|
||||
"better-auth": "1.4.0-beta.12",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.15",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.20.6",
|
||||
"vitest": "^3.2.4"
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^28.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"packageManager": "bun@1.2.23"
|
||||
"packageManager": "bun@1.3.10"
|
||||
}
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" viewBox="0 0 512 512"><image width="512" height="512" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAoeVSURBVHgB7P0HlKXXdR6InnP+eFPFruqcgUZoBAJgADMpUaRIWTZHY3LsGVvWrJlHjjyPsqWxPQ7rLUHLeUbLM09+szyi5zk8pzHpJAclyhSULIomSAokGJFz5wo3/PGct7+9z3+rutFIBCSiybMbhaq69d97/3TP3vvb3/62UsGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQsWLFiwYMGCBQt2bZtWwYIF+/00/sw55/iXn/qpn7r8M/iTSj3wyQcue+z0h067e3/qXrP977a1ukup9FIaH1rLonPn6Oc8dYvFYv3w8sOuuFTofDl3P/izP9he7TXUT+16m5/8STffIT3f1O3aR6eCBQv2HW0hAAgW7PfZ7rnnHkPfTLmnXNy3vG+l39+z4HSznOh4eXmwsD7q93tt21qnnbOWNkyUshOry3FpFD0zTk0U6Yg+u5YCCe3qmrZFREHbxnnsksXE2lreyxh28Ka2trk03tygBy48/uxXLzx54bFzqR6dX9pcKigYqGgbq4IFC/ZdZSEACBbsVbJ33fOu+C2Lb1lbHC5GNrZxPVOrxbnZSrG5uffUjdctH7/hxGDQG/VXR6uDyWyauLgd7l3ZtzjoLS1Y1Y6SJF1YGA5XKCHP6OVa9fyfz93O+sqsXV/xvO4xV5b1uK7rjacvPH7x0vjchUT3tpuyrgbZwrSopuXG5CL9uZ2lafLs/b/7wLlnts9fWNq3crFqmvH2pbPV9je2Nz/+8Y/XKliwYN8RFgKAYMFeplGyrT/8Ux8erPWPDReXBgv27Pa+ns0OvOlNd6+cuu70db3ecEib9SgbX+sPesuz7emxtqqXRqOFuDfomV2Q+64XVZd9Gi3943R/5z0v+zt+1/inX/wjbB29ljbP+15dOQLWlI2lbZ+qInt+PJlsUoBwjh67cGH8zNZn7/+tZ373vge+plV8du3UgaaaTM58035z6xM//omC9mMeaKhgwYJdExYCgGDBnsc+8rMfSS597VKsFlWkHthKT568aeGNb7t72B8O15rYnti3cuTYsfXDN+dZdksxK9ajOFJZnvXyPI/p6eJxyUETkM9ukXB6+kagvpv7YAL5Xds2bds5cnLWTWvbBkEGP51egMoBTWMbS7A/b8NoPxUHkijJ+v3+ICa7WiBAz0OUQFvSO1vXaqNj+uLAgV/HOtWQ0dPJ52uD33mv6VmRibrgwtH+0HZtNdmeVBS9PJFl2fmmbtpnzp77zW88+5VvzIrtZwZ6cWsymbhP/utPPlvG5VZ2y8lGbS22pymmoJJHKC8EC/YatBAABPtut+eQ8u69915z6k2nBt/z7u85ubZw8GRRz1byOFtKo/j2A2vrt67t3btET0hjwuzzNM/IUQKyZ4dPjhnOlrw6ivjk7MkB1+QtAdrTezTk7Osul6fnad6ubWs43W5v4JQJi2/Y+/vdu2KfHTLu4XC4RLaqXwQG8McGR+78+yo49bbmQKMC2kDvY+i7ta1tsE1s4tRQXECPRxQM0H8cIxjPFcAuOtrHCT2jKmv6R1HAxsbmhXPjzc9OivGjcZxcmE7HTz7x2AMP/ct/9AsX3/Wud1VXIR4GtCBYsG+jhQAgWDCyn/n5n8k2n9k8tnfx6LEV1T++d//6nUcOH75hcWF1OU6iYUJpPXnrFfKAGfnDOQTvtHWUHbfI5OGvqV5etXVdkZXk22tDRt8VHDr5/oae4F290/RAg0zaajt3hJHi19YcFTR1BRfZ+Xd5P2ccvdtoNOrt3btv/8LCaPmlHqP1SMQuoN4iSLEMETCq4Gh/aqAQHBB4i8j/w/kTwoEgB3CBoeCA/ovBROT/GNto6fUjs0EHtVVXdTGbTi8++cwTzzz28KNbhXZfuVhf+I0Hn/raV5/afGr6iZ/8RO3LBsGCBfs2WQgAgn1X2o//nR8/uJ4duCGf6ZXT199y8MjxI6eyQX5iZWHv2sKgv582OfA8T7VFWVZI0imlhgMlR02emvJgctrwoZacX1WWZc35NH0RNqDJsVL+3RKMb+AtOZcuK0qdKwoKImUMO2ZQ9pUhtN+iDADo3+2CBjhrJx89pCLEcdrh/QcO7KOXidWrYFb54KBV2M9WceUC9YiGOwy4I4HqEHRojGbEKdU7EJLQDtLBpFmW0iMxYoXoassKYSFbFzbO3X/m3JOPbG+Oz9dt9fUHnnz4aw8/9o2v/vSf/emzKliwYL/vFgKAYN8Npj/04x/KF/ccWGgf2zp46qaTN/zAH/wDbz26fv2bh/1enxL2o1QH75H3uuxJSNCRpduWs/EadXT63pIzr+Aby6KsAfyTY2wL+qWpS4sKfwX4HsV+Cg+ABDjyqlpHPoVHTd6hxU/jlS09WXUEPSv8AGT/VVM15EvZv+JPIASiXDArq+rokUOHbrn59K0LC4sj9ftgHSER+0T/VQh0cHwNfqMghQAKAB0GpwrtieAkJEmSxUmcJgSf0DEgtDAdERFoA52Bs5NZ+dBjTz70n7/w5c/95teeeuzrVT1+djgZbtxzzz2VCnY103/8z/zx/nB1mJambI7MjkwCvyLYK7EQALzGDbnXj/3Yj6W9Q710PBy79XPrBX3oGxXsRe1Dn/hQVP98PTp6YP+Rd37f99x8bP2Gm9aX9vzBhdHwekL0E0+eg5NVyGwJWgeUr+gbofUtJefs0dGOp8j/oy5fzWazCqw9OOaKnDHAc9oUrrwGkN9UVU3O3ZLDBPRvqTpecdOeESdOSTQCBNpmp/5N/lMDnqfnVrQzjdNMDgTyvxsipwoEJ+TNdSdPnLjt1tteR2WABfX7bUxg5OgGuACHSOA40I5RdGBrHD/9reEqQRylJjIxVhkqF8RZlsV0aCmhBzH9zmsPHXJbFNUlY90DFza3fuPBx7/2+f/46V/4xlcffejpZJJMP/nJT4ZggK79+z/2/tGKWzn6gR983+laTfZH+XD6tfse+8KnP/kfvvTbv/3bhQp8imDfgoUA4LVpXKWFE/gTf/GHVt5wy/e8/s2ve9edl7YuFA8/8aVf+Wc//y+/ee/fv7fcdfXCh98bztlHP/rR+D1/9D1Z2qTH9y4fePfa0tL7RksLtywtriykcTIiZxv5DB1Qe4tMnxxrCQdPXt7C89LPdVFQVt/S7/S65KQqgeApOafNDGX05OvqoppVjh5GVkzF/wb+0VmG+PlB1AbokXkXHrB0VOORD1MBoME2EOqRXXecLINjYEGyI9+6+yPqJPNWN9x06tQdt7/udcPhtyEAuMI4YOJKh0QEQDzIprPpbAImBAIBXBNy/imhAiARxnkvHxAykETegB7QS9HJaGYbG5ubWxubD569dPbX7n/q/l8sx+VXLz54cUxBL5+e7ybegASBWv3wj3/owFu+93t/yKZr78nX41O679Zt/2S0OB78evzI/f/n49/84m9+7GM/uR04FcFeroUA4LVn7PyhFncu/ep1Nxw98ieO3vK2P7B87MYD5BNmT12wX/vGfb/79bW6+OVFN/lPvd76xoc//OFWBWP7y3/3b950eHTgXXfecscb962tn9yzuucoodKH6E+mY8OjRF+U1bStmGkPSjs7e87kqQJO/r1FmstBAG1pmdFPmH/N1QAk4RQ0gC1vHYEAFRf3Ge4vS6zZ5M7YpyMSIEzBwpHj72be6kdOk14bmABq61VRNU5We27ZQyWAfqRKvLO7HseuU6ih9R2vv/X2N73p7je9FgKAK412F0GVpfNb1GVRALWYTCbTFlUNqpzQ74oKA2k/73MTRZ9KMBQQ9HHCQDyUWIBf59knnnnsm5ubmxceP/f05x46++Svfu3BL3z17/yFv3NJfZfYj/zZD+1bXr3xe47eePQH1m46cLfqDY8QXgIoRal8Sa0kqVqfTn9n/OjWP/j4//fj/+KfffyfnVfBgr0MCwHAa9Q+9MN/4I173nbrXzh684H3pevrvWywpPb0l5Q1+1SuIrW3bb42PXPpdx58+PH7Zxcf//zZS9944K//2N8+d5WX+o4XZ/mL//tf3BtNo+tuOHb9G978PW9/59H1I28iH7x/3ltPzpPcakVZKLine truncated
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 216 KiB |
@@ -4,9 +4,9 @@ import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { users, configs, repositories, organizations, mirrorJobs, events } from "../src/lib/db/schema";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { users, configs, repositories, organizations, mirrorJobs, events, accounts, sessions } from "../src/lib/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
|
||||
// Command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
@@ -194,6 +194,92 @@ async function fixDatabase() {
|
||||
console.log("✅ Database location fixed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a single user's password (admin recovery flow)
|
||||
*/
|
||||
async function resetPassword() {
|
||||
const emailArg = args.find((arg) => arg.startsWith("--email="));
|
||||
const passwordArg = args.find((arg) => arg.startsWith("--new-password="));
|
||||
const email = emailArg?.split("=")[1]?.trim().toLowerCase();
|
||||
const newPassword = passwordArg?.split("=")[1];
|
||||
|
||||
if (!email || !newPassword) {
|
||||
console.log("❌ Missing required arguments");
|
||||
console.log("Usage:");
|
||||
console.log(" bun run manage-db reset-password --email=user@example.com --new-password='new-secure-password'");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
console.log("❌ Password must be at least 8 characters");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.log("❌ Database does not exist");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
const db = drizzle({ client: sqlite });
|
||||
|
||||
try {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log(`❌ No user found for email: ${email}`);
|
||||
sqlite.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
const now = new Date();
|
||||
|
||||
const credentialAccount = await db.query.accounts.findFirst({
|
||||
where: and(
|
||||
eq(accounts.userId, user.id),
|
||||
eq(accounts.providerId, "credential"),
|
||||
),
|
||||
});
|
||||
|
||||
if (credentialAccount) {
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accounts.id, credentialAccount.id));
|
||||
} else {
|
||||
await db.insert(accounts).values({
|
||||
id: uuidv4(),
|
||||
accountId: user.id,
|
||||
userId: user.id,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const deletedSessions = await db
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.userId, user.id))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
console.log(`✅ Password reset for ${email}`);
|
||||
console.log(`🔒 Cleared ${deletedSessions.length} active session(s)`);
|
||||
|
||||
sqlite.close();
|
||||
} catch (error) {
|
||||
console.error("❌ Error resetting password:", error);
|
||||
sqlite.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto mode - check and initialize if needed
|
||||
*/
|
||||
@@ -224,6 +310,9 @@ switch (command) {
|
||||
case "cleanup":
|
||||
await cleanupDatabase();
|
||||
break;
|
||||
case "reset-password":
|
||||
await resetPassword();
|
||||
break;
|
||||
case "auto":
|
||||
await autoMode();
|
||||
break;
|
||||
@@ -233,7 +322,8 @@ switch (command) {
|
||||
console.log(" check - Check database status");
|
||||
console.log(" fix - Fix database location issues");
|
||||
console.log(" reset-users - Remove all users and related data");
|
||||
console.log(" reset-password - Reset one user's password and clear sessions");
|
||||
console.log(" cleanup - Remove all database files");
|
||||
console.log(" auto - Auto initialize if needed");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -15,33 +15,40 @@ import { repoStatusEnum } from "@/types/Repository";
|
||||
const isDryRun = process.argv.includes("--dry-run");
|
||||
const specificRepo = process.argv.find(arg => arg.startsWith("--repo-name="))?.split("=")[1];
|
||||
const isStartupMode = process.argv.includes("--startup");
|
||||
const requestTimeoutMs = parsePositiveInteger(process.env.GITEA_REPAIR_REQUEST_TIMEOUT_MS, 15000);
|
||||
const progressInterval = parsePositiveInteger(process.env.GITEA_REPAIR_PROGRESS_INTERVAL, 100);
|
||||
|
||||
async function checkRepoInGitea(config: any, owner: string, repoName: string): Promise<boolean> {
|
||||
try {
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
return false;
|
||||
}
|
||||
type GiteaLookupResult = {
|
||||
exists: boolean;
|
||||
details: any | null;
|
||||
timedOut: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${config.giteaConfig.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error(`Error checking repo ${owner}/${repoName} in Gitea:`, error);
|
||||
return false;
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function getRepoDetailsFromGitea(config: any, owner: string, repoName: string): Promise<any> {
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return error.name === "TimeoutError" || error.name === "AbortError";
|
||||
}
|
||||
|
||||
async function getRepoDetailsFromGitea(config: any, owner: string, repoName: string): Promise<GiteaLookupResult> {
|
||||
try {
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||
return null;
|
||||
return {
|
||||
exists: false,
|
||||
details: null,
|
||||
timedOut: false,
|
||||
error: "Missing Gitea URL or token in config",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
@@ -50,16 +57,41 @@ async function getRepoDetailsFromGitea(config: any, owner: string, repoName: str
|
||||
headers: {
|
||||
Authorization: `token ${config.giteaConfig.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(requestTimeoutMs),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
return {
|
||||
exists: true,
|
||||
details: await response.json(),
|
||||
timedOut: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
exists: false,
|
||||
details: null,
|
||||
timedOut: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exists: false,
|
||||
details: null,
|
||||
timedOut: false,
|
||||
error: `Gitea API returned HTTP ${response.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error getting repo details for ${owner}/${repoName}:`, error);
|
||||
return null;
|
||||
return {
|
||||
exists: false,
|
||||
details: null,
|
||||
timedOut: isTimeoutError(error),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +131,8 @@ async function repairMirroredRepositories() {
|
||||
.from(repositories)
|
||||
.where(whereConditions);
|
||||
|
||||
const totalRepos = repos.length;
|
||||
|
||||
if (repos.length === 0) {
|
||||
if (!isStartupMode) {
|
||||
console.log("✅ No repositories found that need repair");
|
||||
@@ -109,13 +143,25 @@ async function repairMirroredRepositories() {
|
||||
if (!isStartupMode) {
|
||||
console.log(`📋 Found ${repos.length} repositories to check:`);
|
||||
console.log("");
|
||||
} else {
|
||||
console.log(`Checking ${totalRepos} repositories for status inconsistencies...`);
|
||||
console.log(`Request timeout: ${requestTimeoutMs}ms | Progress interval: every ${progressInterval} repositories`);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const configCache = new Map<string, any>();
|
||||
let checkedCount = 0;
|
||||
let repairedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
let timeoutCount = 0;
|
||||
let giteaErrorCount = 0;
|
||||
let giteaErrorSamples = 0;
|
||||
let startupSkipWarningCount = 0;
|
||||
|
||||
for (const repo of repos) {
|
||||
checkedCount++;
|
||||
|
||||
if (!isStartupMode) {
|
||||
console.log(`🔍 Checking repository: ${repo.name}`);
|
||||
console.log(` Current status: ${repo.status}`);
|
||||
@@ -124,13 +170,29 @@ async function repairMirroredRepositories() {
|
||||
|
||||
try {
|
||||
// Get user configuration
|
||||
const config = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.id, repo.configId))
|
||||
.limit(1);
|
||||
const configKey = String(repo.configId);
|
||||
let userConfig = configCache.get(configKey);
|
||||
|
||||
if (config.length === 0) {
|
||||
if (!userConfig) {
|
||||
const config = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.id, repo.configId))
|
||||
.limit(1);
|
||||
|
||||
if (config.length === 0) {
|
||||
if (!isStartupMode) {
|
||||
console.log(` ❌ No configuration found for repository`);
|
||||
}
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
userConfig = config[0];
|
||||
configCache.set(configKey, userConfig);
|
||||
}
|
||||
|
||||
if (!userConfig) {
|
||||
if (!isStartupMode) {
|
||||
console.log(` ❌ No configuration found for repository`);
|
||||
}
|
||||
@@ -138,7 +200,6 @@ async function repairMirroredRepositories() {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userConfig = config[0];
|
||||
const giteaUsername = userConfig.giteaConfig?.defaultOwner;
|
||||
|
||||
if (!giteaUsername) {
|
||||
@@ -153,25 +214,59 @@ async function repairMirroredRepositories() {
|
||||
let existsInGitea = false;
|
||||
let actualOwner = giteaUsername;
|
||||
let giteaRepoDetails = null;
|
||||
let repoRequestTimedOut = false;
|
||||
let repoRequestErrored = false;
|
||||
|
||||
// First check user location
|
||||
existsInGitea = await checkRepoInGitea(userConfig, giteaUsername, repo.name);
|
||||
if (existsInGitea) {
|
||||
giteaRepoDetails = await getRepoDetailsFromGitea(userConfig, giteaUsername, repo.name);
|
||||
const userLookup = await getRepoDetailsFromGitea(userConfig, giteaUsername, repo.name);
|
||||
existsInGitea = userLookup.exists;
|
||||
giteaRepoDetails = userLookup.details;
|
||||
|
||||
if (userLookup.timedOut) {
|
||||
timeoutCount++;
|
||||
repoRequestTimedOut = true;
|
||||
} else if (userLookup.error) {
|
||||
giteaErrorCount++;
|
||||
repoRequestErrored = true;
|
||||
if (!isStartupMode || giteaErrorSamples < 3) {
|
||||
console.log(` ⚠️ Gitea lookup issue for ${giteaUsername}/${repo.name}: ${userLookup.error}`);
|
||||
giteaErrorSamples++;
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in user location and repo has organization, check organization
|
||||
if (!existsInGitea && repo.organization) {
|
||||
existsInGitea = await checkRepoInGitea(userConfig, repo.organization, repo.name);
|
||||
const orgLookup = await getRepoDetailsFromGitea(userConfig, repo.organization, repo.name);
|
||||
existsInGitea = orgLookup.exists;
|
||||
if (existsInGitea) {
|
||||
actualOwner = repo.organization;
|
||||
giteaRepoDetails = await getRepoDetailsFromGitea(userConfig, repo.organization, repo.name);
|
||||
giteaRepoDetails = orgLookup.details;
|
||||
}
|
||||
|
||||
if (orgLookup.timedOut) {
|
||||
timeoutCount++;
|
||||
repoRequestTimedOut = true;
|
||||
} else if (orgLookup.error) {
|
||||
giteaErrorCount++;
|
||||
repoRequestErrored = true;
|
||||
if (!isStartupMode || giteaErrorSamples < 3) {
|
||||
console.log(` ⚠️ Gitea lookup issue for ${repo.organization}/${repo.name}: ${orgLookup.error}`);
|
||||
giteaErrorSamples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsInGitea) {
|
||||
if (!isStartupMode) {
|
||||
console.log(` ⏭️ Repository not found in Gitea - skipping`);
|
||||
} else if (repoRequestTimedOut || repoRequestErrored) {
|
||||
if (startupSkipWarningCount < 3) {
|
||||
console.log(` ⚠️ Skipping ${repo.name}; Gitea was slow/unreachable during lookup`);
|
||||
startupSkipWarningCount++;
|
||||
if (startupSkipWarningCount === 3) {
|
||||
console.log(` ℹ️ Additional slow/unreachable lookup warnings suppressed; progress logs will continue`);
|
||||
}
|
||||
}
|
||||
}
|
||||
skippedCount++;
|
||||
continue;
|
||||
@@ -241,22 +336,43 @@ async function repairMirroredRepositories() {
|
||||
|
||||
if (!isStartupMode) {
|
||||
console.log("");
|
||||
} else if (checkedCount % progressInterval === 0 || checkedCount === totalRepos) {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000);
|
||||
console.log(
|
||||
`Repair progress: ${checkedCount}/${totalRepos} checked | repaired=${repairedCount}, skipped=${skippedCount}, errors=${errorCount}, timeouts=${timeoutCount} | elapsed=${elapsedSeconds}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isStartupMode) {
|
||||
// In startup mode, only log if there were repairs or errors
|
||||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000);
|
||||
console.log(
|
||||
`Repository repair summary: checked=${checkedCount}, repaired=${repairedCount}, skipped=${skippedCount}, errors=${errorCount}, timeouts=${timeoutCount}, elapsed=${elapsedSeconds}s`
|
||||
);
|
||||
if (repairedCount > 0) {
|
||||
console.log(`Repaired ${repairedCount} repository status inconsistencies`);
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
console.log(`Warning: ${errorCount} repositories had errors during repair`);
|
||||
}
|
||||
if (timeoutCount > 0) {
|
||||
console.log(
|
||||
`Warning: ${timeoutCount} Gitea API requests timed out. Increase GITEA_REPAIR_REQUEST_TIMEOUT_MS if your Gitea instance is under heavy load.`
|
||||
);
|
||||
}
|
||||
if (giteaErrorCount > 0) {
|
||||
console.log(`Warning: ${giteaErrorCount} Gitea API requests failed with non-timeout errors.`);
|
||||
}
|
||||
} else {
|
||||
console.log("📊 Repair Summary:");
|
||||
console.log(` Checked: ${checkedCount}`);
|
||||
console.log(` Repaired: ${repairedCount}`);
|
||||
console.log(` Skipped: ${skippedCount}`);
|
||||
console.log(` Errors: ${errorCount}`);
|
||||
console.log(` Timeouts: ${timeoutCount}`);
|
||||
if (giteaErrorCount > 0) {
|
||||
console.log(` Gitea API Errors: ${giteaErrorCount}`);
|
||||
}
|
||||
|
||||
if (isDryRun && repairedCount > 0) {
|
||||
console.log("");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
function normalizeBasePath(basePath: string | undefined): string {
|
||||
if (!basePath || !basePath.trim()) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
let normalized = basePath.trim();
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
return normalized || "/";
|
||||
}
|
||||
|
||||
function rewriteRequestUrl(rawUrl: string, basePath: string): string | null {
|
||||
if (basePath === "/") {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const url = new URL(rawUrl, "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === basePath || pathname === `${basePath}/`) {
|
||||
url.pathname = "/";
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
if (pathname.startsWith(`${basePath}/`)) {
|
||||
url.pathname = pathname.slice(basePath.length) || "/";
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePath = normalizeBasePath(process.env.BASE_URL);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
const port = Number.parseInt(process.env.PORT || "4321", 10);
|
||||
|
||||
process.env.ASTRO_NODE_AUTOSTART = "disabled";
|
||||
const { handler } = await import("../dist/server/entry.mjs");
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
if (!req.url) {
|
||||
res.statusCode = 400;
|
||||
res.end("Bad Request");
|
||||
return;
|
||||
}
|
||||
|
||||
const rewrittenUrl = rewriteRequestUrl(req.url, basePath);
|
||||
if (rewrittenUrl === null) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
req.url = rewrittenUrl;
|
||||
req.headers["x-gitea-mirror-base-rewritten"] = "1";
|
||||
|
||||
Promise.resolve((handler as unknown as (request: IncomingMessage, response: ServerResponse) => unknown)(req, res)).catch((error) => {
|
||||
console.error("Unhandled runtime server error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Runtime server listening on http://${host}:${port} (BASE_URL=${basePath})`);
|
||||
});
|
||||
@@ -114,10 +114,10 @@ EOF
|
||||
echo "======================================"
|
||||
echo "1. Access Authentik at http://localhost:9000"
|
||||
echo "2. Login with akadmin / admin-password"
|
||||
echo "3. Create OAuth2 Provider for Gitea Mirror:"
|
||||
echo "3. Create an Authentik OIDC Provider for Gitea Mirror:"
|
||||
echo " - Name: gitea-mirror"
|
||||
echo " - Redirect URIs:"
|
||||
echo " http://localhost:4321/api/auth/callback/sso-provider"
|
||||
echo " - Redirect URI:"
|
||||
echo " http://localhost:4321/api/auth/sso/callback/authentik"
|
||||
echo " - Scopes: openid, profile, email"
|
||||
echo ""
|
||||
echo "4. Create Application:"
|
||||
@@ -131,10 +131,14 @@ EOF
|
||||
echo "6. Configure SSO in Gitea Mirror:"
|
||||
echo " - Go to Settings → Authentication & SSO"
|
||||
echo " - Add provider with:"
|
||||
echo " - Provider ID: authentik"
|
||||
echo " - Issuer URL: http://localhost:9000/application/o/gitea-mirror/"
|
||||
echo " - Click Discover to pull Authentik endpoints"
|
||||
echo " - Client ID: (from Authentik provider)"
|
||||
echo " - Client Secret: (from Authentik provider)"
|
||||
echo ""
|
||||
echo "If you previously registered this provider on a version earlier than v3.8.10, delete it and re-add it after upgrading to avoid missing endpoint data."
|
||||
echo ""
|
||||
;;
|
||||
|
||||
stop)
|
||||
@@ -177,4 +181,4 @@ EOF
|
||||
echo " status - Show service status"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
type JournalEntry = {
|
||||
idx: number;
|
||||
tag: string;
|
||||
when: number;
|
||||
breakpoints: boolean;
|
||||
};
|
||||
|
||||
type Migration = {
|
||||
entry: JournalEntry;
|
||||
statements: string[];
|
||||
};
|
||||
|
||||
type UpgradeFixture = {
|
||||
seed: (db: Database) => void;
|
||||
verify: (db: Database) => void;
|
||||
};
|
||||
|
||||
type TableInfoRow = {
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
const migrationsFolder = path.join(process.cwd(), "drizzle");
|
||||
const migrations = loadMigrations();
|
||||
const latestMigration = migrations.at(-1);
|
||||
|
||||
/**
|
||||
* Known SQLite limitations that Drizzle-kit's auto-generated migrations
|
||||
* can violate. Each rule is checked against every SQL statement.
|
||||
*/
|
||||
const SQLITE_LINT_RULES: { pattern: RegExp; message: string }[] = [
|
||||
{
|
||||
pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s*\(/i,
|
||||
message:
|
||||
"ALTER TABLE ADD COLUMN with an expression default (e.g. DEFAULT (unixepoch())) " +
|
||||
"is not allowed in SQLite. Use the table-recreation pattern instead " +
|
||||
"(CREATE new table, INSERT SELECT, DROP old, RENAME).",
|
||||
},
|
||||
{
|
||||
pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s+CURRENT_(TIME|DATE|TIMESTAMP)\b/i,
|
||||
message:
|
||||
"ALTER TABLE ADD COLUMN with DEFAULT CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP " +
|
||||
"is not allowed in SQLite. Use the table-recreation pattern instead.",
|
||||
},
|
||||
];
|
||||
|
||||
function loadMigrations(): Migration[] {
|
||||
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
|
||||
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
||||
entries: JournalEntry[];
|
||||
};
|
||||
|
||||
return journal.entries.map((entry) => {
|
||||
const migrationPath = path.join(migrationsFolder, `${entry.tag}.sql`);
|
||||
const statements = readFileSync(migrationPath, "utf8")
|
||||
.split("--> statement-breakpoint")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return { entry, statements };
|
||||
});
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function runMigration(db: Database, migration: Migration) {
|
||||
db.run("BEGIN");
|
||||
|
||||
try {
|
||||
for (const statement of migration.statements) {
|
||||
db.run(statement);
|
||||
}
|
||||
|
||||
db.run("COMMIT");
|
||||
} catch (error) {
|
||||
try {
|
||||
db.run("ROLLBACK");
|
||||
} catch {
|
||||
// Ignore rollback errors so the original failure is preserved.
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrations(db: Database, selectedMigrations: Migration[]) {
|
||||
for (const migration of selectedMigrations) {
|
||||
runMigration(db, migration);
|
||||
}
|
||||
}
|
||||
|
||||
function seedPre0009Database(db: Database) {
|
||||
// Seed every existing table so ALTER TABLE paths run against non-empty data.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('c1', 'u1', 'Default', '{}', '{}', '{}', '{}')");
|
||||
db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, access_token, refresh_token, id_token, access_token_expires_at, refresh_token_expires_at, scope) VALUES ('acct1', 'acct-1', 'u1', 'github', 'access-token', 'refresh-token', 'id-token', 2000, 3000, 'repo')");
|
||||
db.run("INSERT INTO events (id, user_id, channel, payload) VALUES ('evt1', 'u1', 'sync', '{\"status\":\"queued\"}')");
|
||||
db.run("INSERT INTO mirror_jobs (id, user_id, repository_id, repository_name, status, message, timestamp) VALUES ('job1', 'u1', 'r1', 'owner/repo', 'imported', 'Imported repository', 900)");
|
||||
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, public_repository_count, private_repository_count, fork_repository_count) VALUES ('org1', 'u1', 'c1', 'Example Org', 'https://example.com/org.png', 1, 0, 0)");
|
||||
db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, organization, default_branch, created_at, updated_at, metadata) VALUES ('r1', 'u1', 'c1', 'repo', 'owner/repo', 'owner/repo', 'https://example.com/repo', 'https://example.com/repo.git', 'owner', 'Example Org', 'main', 1000, 1100, '{\"issues\":true}')");
|
||||
db.run("INSERT INTO sessions (id, token, user_id, expires_at) VALUES ('sess1', 'session-token', 'u1', 4000)");
|
||||
db.run("INSERT INTO verification_tokens (id, token, identifier, type, expires_at) VALUES ('vt1', 'verify-token', 'u1@example.com', 'email', 5000)");
|
||||
db.run("INSERT INTO verifications (id, identifier, value, expires_at) VALUES ('ver1', 'u1@example.com', '123456', 6000)");
|
||||
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', '[\"https://example.com/callback\"]', 'confidential', 'u1')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'oauth-access-token', 'oauth-refresh-token', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
|
||||
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('consent1', 'u1', 'client-1', '[\"repo\"]', true)");
|
||||
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso1', 'https://issuer.example.com', 'example.com', '{}', 'u1', 'provider-1')");
|
||||
db.run("INSERT INTO rate_limits (id, user_id, provider, `limit`, remaining, used, reset, retry_after, status, last_checked) VALUES ('rl1', 'u1', 'github', 5000, 4999, 1, 9000, NULL, 'ok', 8500)");
|
||||
}
|
||||
|
||||
function verify0009Migration(db: Database) {
|
||||
const repositoryColumns = db.query("PRAGMA table_info(repositories)").all() as TableInfoRow[];
|
||||
const importedAtColumn = repositoryColumns.find((column) => column.name === "imported_at");
|
||||
|
||||
assert(importedAtColumn, "Expected repositories.imported_at column to exist after migration");
|
||||
assert(importedAtColumn.notnull === 1, "Expected repositories.imported_at to be NOT NULL");
|
||||
assert(importedAtColumn.dflt_value === "unixepoch()", `Expected repositories.imported_at default to be unixepoch(), got ${importedAtColumn.dflt_value ?? "null"}`);
|
||||
|
||||
const existingRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r1'").get() as { imported_at: number } | null;
|
||||
assert(existingRepo?.imported_at === 900, `Expected existing repository imported_at to backfill from mirror_jobs timestamp 900, got ${existingRepo?.imported_at ?? "null"}`);
|
||||
|
||||
db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) VALUES ('r2', 'u1', 'c1', 'repo-two', 'owner/repo-two', 'owner/repo-two', 'https://example.com/repo-two', 'https://example.com/repo-two.git', 'owner', 'main')");
|
||||
const newRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r2'").get() as { imported_at: number } | null;
|
||||
assert(typeof newRepo?.imported_at === "number" && newRepo.imported_at > 0, "Expected new repository insert to receive imported_at from the column default");
|
||||
|
||||
const importedAtIndex = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'repositories' AND name = 'idx_repositories_user_imported_at'")
|
||||
.get() as { name: string } | null;
|
||||
assert(importedAtIndex?.name === "idx_repositories_user_imported_at", "Expected repositories imported_at index to exist after migration");
|
||||
}
|
||||
|
||||
function seedPre0010Database(db: any) {
|
||||
// Seed a repo row to verify index creation doesn't break existing data
|
||||
seedPre0009Database(db);
|
||||
}
|
||||
|
||||
function verify0010Migration(db: any) {
|
||||
const indexes = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'"
|
||||
).all();
|
||||
if (indexes.length === 0) {
|
||||
throw new Error("Missing unique partial index uniq_repositories_user_mirrored_location");
|
||||
}
|
||||
|
||||
const lookupIdx = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_repositories_mirrored_location'"
|
||||
).all();
|
||||
if (lookupIdx.length === 0) {
|
||||
throw new Error("Missing lookup index idx_repositories_mirrored_location");
|
||||
}
|
||||
}
|
||||
|
||||
function seedPre0011Database(db: any) {
|
||||
seedPre0009Database(db);
|
||||
runMigration(db, migrations.find((m) => m.entry.tag === "0009_nervous_tyger_tiger")!);
|
||||
runMigration(db, migrations.find((m) => m.entry.tag === "0010_mirrored_location_index")!);
|
||||
}
|
||||
|
||||
function verify0011Migration(db: any) {
|
||||
const configColumns = db.query("PRAGMA table_info(configs)").all() as TableInfoRow[];
|
||||
const notificationConfigColumn = configColumns.find((column: any) => column.name === "notification_config");
|
||||
|
||||
assert(notificationConfigColumn, "Expected configs.notification_config column to exist after migration");
|
||||
assert(notificationConfigColumn.notnull === 1, "Expected configs.notification_config to be NOT NULL");
|
||||
assert(
|
||||
notificationConfigColumn.dflt_value !== null,
|
||||
"Expected configs.notification_config to have a default value",
|
||||
);
|
||||
|
||||
const existingConfig = db.query("SELECT notification_config FROM configs WHERE id = 'c1'").get() as { notification_config: string } | null;
|
||||
assert(existingConfig, "Expected existing config row to still exist");
|
||||
const parsed = JSON.parse(existingConfig.notification_config);
|
||||
assert(parsed.enabled === false, "Expected default notification_config.enabled to be false");
|
||||
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
|
||||
}
|
||||
|
||||
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
"0009_nervous_tyger_tiger": {
|
||||
seed: seedPre0009Database,
|
||||
verify: verify0009Migration,
|
||||
},
|
||||
"0010_mirrored_location_index": {
|
||||
seed: seedPre0010Database,
|
||||
verify: verify0010Migration,
|
||||
},
|
||||
"0011_notification_config": {
|
||||
seed: seedPre0011Database,
|
||||
verify: verify0011Migration,
|
||||
},
|
||||
};
|
||||
|
||||
function lintMigrations(selectedMigrations: Migration[]) {
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const migration of selectedMigrations) {
|
||||
for (const statement of migration.statements) {
|
||||
for (const rule of SQLITE_LINT_RULES) {
|
||||
if (rule.pattern.test(statement)) {
|
||||
violations.push(`[${migration.entry.tag}] ${rule.message}\n Statement: ${statement.slice(0, 120)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert(
|
||||
violations.length === 0,
|
||||
`SQLite lint found ${violations.length} violation(s):\n\n${violations.join("\n\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateMigrations() {
|
||||
assert(latestMigration, "No migrations found in drizzle/meta/_journal.json");
|
||||
|
||||
// Lint all migrations for known SQLite pitfalls before running anything.
|
||||
lintMigrations(migrations);
|
||||
|
||||
const emptyDb = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(emptyDb, migrations);
|
||||
} finally {
|
||||
emptyDb.close();
|
||||
}
|
||||
|
||||
const upgradeFixture = latestUpgradeFixtures[latestMigration.entry.tag];
|
||||
assert(
|
||||
upgradeFixture,
|
||||
`Missing upgrade fixture for latest migration ${latestMigration.entry.tag}. Add one in scripts/validate-migrations.ts.`,
|
||||
);
|
||||
|
||||
const upgradeDb = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(upgradeDb, migrations.slice(0, -1));
|
||||
upgradeFixture.seed(upgradeDb);
|
||||
runMigration(upgradeDb, latestMigration);
|
||||
upgradeFixture.verify(upgradeDb);
|
||||
} finally {
|
||||
upgradeDb.close();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
validateMigrations();
|
||||
} catch (error) {
|
||||
console.error("Migration validation failed:");
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
@@ -21,7 +22,7 @@ export function NotFound() {
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild className="w-full">
|
||||
<a href="/">
|
||||
<a href={withBase("/")}>
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go to Dashboard
|
||||
</a>
|
||||
@@ -45,21 +46,21 @@ export function NotFound() {
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<a
|
||||
href="/repositories"
|
||||
href={withBase("/repositories")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Repositories</span>
|
||||
</a>
|
||||
<a
|
||||
href="/config"
|
||||
href={withBase("/config")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Config</span>
|
||||
</a>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
@@ -77,4 +78,4 @@ export function NotFound() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { toast } from 'sonner';
|
||||
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
||||
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
||||
import { useNavigation } from '@/components/layout/MainLayout';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -321,7 +322,7 @@ export function ActivityLog() {
|
||||
setIsInitialLoading(true);
|
||||
setShowCleanupDialog(false);
|
||||
|
||||
const response = await fetch('/api/activities/cleanup', {
|
||||
const response = await fetch(withBase('/api/activities/cleanup'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
|
||||
export function LoginForm() {
|
||||
@@ -47,7 +48,7 @@ export function LoginForm() {
|
||||
toast.success('Login successful!');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
window.location.href = withBase('/');
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
@@ -64,12 +65,15 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
|
||||
const callbackURL =
|
||||
typeof window !== 'undefined'
|
||||
? new URL(withBase('/'), window.location.origin).toString()
|
||||
: `http://localhost:4321${withBase('/')}`;
|
||||
await authClient.signIn.sso({
|
||||
email: ssoEmail || undefined,
|
||||
domain: domain,
|
||||
providerId: providerId,
|
||||
callbackURL: `${baseURL}/`,
|
||||
callbackURL,
|
||||
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -85,7 +89,7 @@ export function LoginForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
export function SignupForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -42,7 +43,7 @@ export function SignupForm() {
|
||||
toast.success('Account created successfully! Redirecting to dashboard...');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
window.location.href = withBase('/');
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
@@ -57,7 +58,7 @@ export function SignupForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Zap,
|
||||
Info,
|
||||
Archive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -28,6 +30,10 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
} from "@/lib/utils/schedule-utils";
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
@@ -38,15 +44,13 @@ interface AutomationSettingsProps {
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
const scheduleIntervals = [
|
||||
{ label: "Every hour", value: 3600 },
|
||||
{ label: "Every 2 hours", value: 7200 },
|
||||
{ label: "Every 4 hours", value: 14400 },
|
||||
{ label: "Every 8 hours", value: 28800 },
|
||||
{ label: "Every 12 hours", value: 43200 },
|
||||
{ label: "Daily", value: 86400 },
|
||||
{ label: "Every 2 days", value: 172800 },
|
||||
{ label: "Weekly", value: 604800 },
|
||||
const clockFrequencies = [
|
||||
{ label: "Every hour", value: 1 },
|
||||
{ label: "Every 2 hours", value: 2 },
|
||||
{ label: "Every 4 hours", value: 4 },
|
||||
{ label: "Every 8 hours", value: 8 },
|
||||
{ label: "Every 12 hours", value: 12 },
|
||||
{ label: "Daily", value: 24 },
|
||||
];
|
||||
|
||||
const retentionPeriods = [
|
||||
@@ -85,6 +89,27 @@ export function AutomationSettings({
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: AutomationSettingsProps) {
|
||||
const browserTimezone =
|
||||
typeof Intl !== "undefined"
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
: "UTC";
|
||||
|
||||
// Use saved timezone, but treat "UTC" as unset for users who never chose it
|
||||
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
|
||||
|
||||
const nextScheduledRun = useMemo(() => {
|
||||
if (!scheduleConfig.enabled) return null;
|
||||
const startTime = scheduleConfig.startTime || "22:00";
|
||||
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
|
||||
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
|
||||
if (!cronExpression) return null;
|
||||
try {
|
||||
return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
|
||||
|
||||
// Update nextRun for cleanup when settings change
|
||||
useEffect(() => {
|
||||
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
|
||||
@@ -125,7 +150,7 @@ export function AutomationSettings({
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
@@ -136,14 +161,21 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror"
|
||||
checked={scheduleConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
enabled: !!checked,
|
||||
timezone: checked ? browserTimezone : scheduleConfig.timezone,
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
|
||||
scheduleMode: "clock",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
@@ -154,79 +186,123 @@ export function AutomationSettings({
|
||||
Enable automatic repository syncing
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Periodically check GitHub for changes and mirror them to Gitea
|
||||
Periodically sync GitHub changes to Gitea
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="mirror-interval" className="text-sm">
|
||||
Sync frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={scheduleConfig.interval.toString()}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
interval: parseInt(value, 10),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="mirror-interval" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scheduleIntervals.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Schedule
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
{effectiveTimezone}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-frequency"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={String(scheduleConfig.clockFrequencyHours || 24)}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: parseInt(value, 10),
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="clock-frequency" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clockFrequencies.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-start-time"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Start time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
|
||||
<Clock className="size-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="clock-start-time"
|
||||
type="time"
|
||||
value={scheduleConfig.startTime || "22:00"}
|
||||
onChange={(event) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
startTime: event.target.value,
|
||||
clockFrequencyHours:
|
||||
scheduleConfig.clockFrequencyHours || 24,
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync
|
||||
</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync{" "}
|
||||
<span className="font-medium">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
{scheduleConfig.enabled ? (
|
||||
scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync{" "}
|
||||
<span className="font-medium text-primary">
|
||||
{scheduleConfig.nextRun
|
||||
? formatDate(scheduleConfig.nextRun)
|
||||
: nextScheduledRun
|
||||
? formatDate(nextScheduledRun)
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic syncing to schedule periodic repository updates
|
||||
</div>
|
||||
<span>Enable syncing to schedule updates</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
@@ -237,7 +313,7 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
@@ -255,13 +331,13 @@ export function AutomationSettings({
|
||||
Enable automatic database cleanup
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove old activity logs and events to optimize storage
|
||||
Remove old activity logs to optimize storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="ml-6 space-y-5">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||
Data retention period
|
||||
@@ -312,7 +388,7 @@ export function AutomationSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="mt-auto space-y-2 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GitHubConfigForm } from './GitHubConfigForm';
|
||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { AutomationSettings } from './AutomationSettings';
|
||||
import { SSOSettings } from './SSOSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
import type {
|
||||
ConfigApiResponse,
|
||||
GiteaConfig,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
DatabaseCleanupConfig,
|
||||
MirrorOptions,
|
||||
AdvancedOptions,
|
||||
NotificationConfig,
|
||||
} from '@/types/config';
|
||||
import { Button } from '../ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -22,6 +24,7 @@ import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
type ConfigState = {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -30,8 +33,11 @@ type ConfigState = {
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
mirrorOptions: MirrorOptions;
|
||||
advancedOptions: AdvancedOptions;
|
||||
notificationConfig: NotificationConfig;
|
||||
};
|
||||
|
||||
const CONFIG_API_PATH = withBase('/api/config');
|
||||
|
||||
export function ConfigTabs() {
|
||||
const [config, setConfig] = useState<ConfigState>({
|
||||
githubConfig: {
|
||||
@@ -39,15 +45,23 @@ export function ConfigTabs() {
|
||||
token: '',
|
||||
privateRepositories: false,
|
||||
mirrorStarred: false,
|
||||
starredLists: [],
|
||||
},
|
||||
giteaConfig: {
|
||||
url: '',
|
||||
externalUrl: '',
|
||||
username: '',
|
||||
token: '',
|
||||
organization: 'github-mirrors',
|
||||
visibility: 'public',
|
||||
starredReposOrg: 'starred',
|
||||
starredReposMode: 'dedicated-org',
|
||||
preserveOrgStructure: false,
|
||||
backupStrategy: "on-force-push",
|
||||
backupRetentionCount: 5,
|
||||
backupRetentionDays: 30,
|
||||
backupDirectory: 'data/repo-backups',
|
||||
blockSyncOnBackupFailure: true,
|
||||
},
|
||||
scheduleConfig: {
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
@@ -77,6 +91,14 @@ export function ConfigTabs() {
|
||||
advancedOptions: {
|
||||
skipForks: false,
|
||||
starredCodeOnly: false,
|
||||
autoMirrorStarred: false,
|
||||
},
|
||||
notificationConfig: {
|
||||
enabled: false,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: false,
|
||||
},
|
||||
});
|
||||
const { user } = useAuth();
|
||||
@@ -87,10 +109,12 @@ export function ConfigTabs() {
|
||||
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
|
||||
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
|
||||
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
|
||||
const [isAutoSavingNotification, setIsAutoSavingNotification] = useState<boolean>(false);
|
||||
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveNotificationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isConfigFormValid = (): boolean => {
|
||||
const { githubConfig, giteaConfig } = config;
|
||||
@@ -117,19 +141,31 @@ export function ConfigTabs() {
|
||||
if (!user?.id) return;
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const result = await apiRequest<{ success: boolean; message?: string }>(
|
||||
const result = await apiRequest<{ success: boolean; message?: string; failedOrgs?: string[]; recoveredOrgs?: number }>(
|
||||
`/sync?userId=${user.id}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
result.success
|
||||
? toast.success(
|
||||
'GitHub data imported successfully! Head to the Repositories page to start mirroring.',
|
||||
)
|
||||
: toast.error(
|
||||
`Failed to import GitHub data: ${
|
||||
result.message || 'Unknown error'
|
||||
}`,
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
'GitHub data imported successfully! Head to the Repositories page to start mirroring.',
|
||||
);
|
||||
if (result.failedOrgs && result.failedOrgs.length > 0) {
|
||||
toast.warning(
|
||||
`${result.failedOrgs.length} org${result.failedOrgs.length > 1 ? 's' : ''} failed to import (${result.failedOrgs.join(', ')}). Check the Organizations tab for details.`,
|
||||
);
|
||||
}
|
||||
if (result.recoveredOrgs && result.recoveredOrgs > 0) {
|
||||
toast.success(
|
||||
`${result.recoveredOrgs} previously failed org${result.recoveredOrgs > 1 ? 's' : ''} recovered successfully.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
`Failed to import GitHub data: ${
|
||||
result.message || 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error importing GitHub data: ${
|
||||
@@ -165,7 +201,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -231,7 +267,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -296,7 +332,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -345,7 +381,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -385,7 +421,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -420,7 +456,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -440,6 +476,55 @@ export function ConfigTabs() {
|
||||
}
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
|
||||
|
||||
// Auto-save function for notification config changes
|
||||
const autoSaveNotificationConfig = useCallback(async (notifConfig: NotificationConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveNotificationTimeoutRef.current) {
|
||||
clearTimeout(autoSaveNotificationTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveNotificationTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingNotification(true);
|
||||
|
||||
const reqPayload = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
notificationConfig: notifConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Silent success - no toast for auto-save
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingNotification(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions, config.advancedOptions]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -455,6 +540,9 @@ export function ConfigTabs() {
|
||||
if (autoSaveGiteaTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveNotificationTimeoutRef.current) {
|
||||
clearTimeout(autoSaveNotificationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -486,6 +574,8 @@ export function ConfigTabs() {
|
||||
},
|
||||
advancedOptions:
|
||||
response.advancedOptions || config.advancedOptions,
|
||||
notificationConfig:
|
||||
(response as any).notificationConfig || config.notificationConfig,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -615,9 +705,10 @@ export function ConfigTabs() {
|
||||
|
||||
{/* Content section - Tabs layout */}
|
||||
<Tabs defaultValue="connections" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="connections">Connections</TabsTrigger>
|
||||
<TabsTrigger value="automation">Automation</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
||||
<TabsTrigger value="sso">Authentication</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -654,9 +745,20 @@ export function ConfigTabs() {
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
giteaConfig={config.giteaConfig}
|
||||
setGiteaConfig={update =>
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
giteaConfig:
|
||||
typeof update === 'function'
|
||||
? update(prev.giteaConfig)
|
||||
: update,
|
||||
}))
|
||||
}
|
||||
onAutoSave={autoSaveGitHubConfig}
|
||||
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
|
||||
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
|
||||
onGiteaAutoSave={autoSaveGiteaConfig}
|
||||
isAutoSaving={isAutoSavingGitHub}
|
||||
/>
|
||||
<GiteaConfigForm
|
||||
@@ -694,6 +796,17 @@ export function ConfigTabs() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" className="space-y-4">
|
||||
<NotificationSettings
|
||||
notificationConfig={config.notificationConfig}
|
||||
onNotificationChange={(newConfig) => {
|
||||
setConfig(prev => ({ ...prev, notificationConfig: newConfig }));
|
||||
autoSaveNotificationConfig(newConfig);
|
||||
}}
|
||||
isAutoSaving={isAutoSavingNotification}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sso" className="space-y-4">
|
||||
<SSOSettings />
|
||||
</TabsContent>
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { githubApi } from "@/lib/api";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, GiteaConfig, BackupStrategy } from "@/types/config";
|
||||
import { Input } from "../ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { Info } from "lucide-react";
|
||||
import { Info, ShieldAlert } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
@@ -26,23 +27,29 @@ interface GitHubConfigFormProps {
|
||||
setMirrorOptions: React.Dispatch<React.SetStateAction<MirrorOptions>>;
|
||||
advancedOptions: AdvancedOptions;
|
||||
setAdvancedOptions: React.Dispatch<React.SetStateAction<AdvancedOptions>>;
|
||||
giteaConfig?: GiteaConfig;
|
||||
setGiteaConfig?: React.Dispatch<React.SetStateAction<GiteaConfig>>;
|
||||
onAutoSave?: (githubConfig: GitHubConfig) => Promise<void>;
|
||||
onMirrorOptionsAutoSave?: (mirrorOptions: MirrorOptions) => Promise<void>;
|
||||
onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise<void>;
|
||||
onGiteaAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function GitHubConfigForm({
|
||||
config,
|
||||
setConfig,
|
||||
config,
|
||||
setConfig,
|
||||
mirrorOptions,
|
||||
setMirrorOptions,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
onAutoSave,
|
||||
giteaConfig,
|
||||
setGiteaConfig,
|
||||
onAutoSave,
|
||||
onMirrorOptionsAutoSave,
|
||||
onAdvancedOptionsAutoSave,
|
||||
isAutoSaving
|
||||
onGiteaAutoSave,
|
||||
isAutoSaving
|
||||
}: GitHubConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -202,7 +209,161 @@ export function GitHubConfigForm({
|
||||
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{giteaConfig && setGiteaConfig && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-primary" />
|
||||
Destructive Update Protection
|
||||
<Badge variant="secondary" className="ml-2 text-[10px] px-1.5 py-0">BETA</Badge>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how to handle force-pushes or rewritten upstream history on GitHub.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "disabled",
|
||||
label: "Disabled",
|
||||
desc: "No detection or backups",
|
||||
},
|
||||
{
|
||||
value: "always",
|
||||
label: "Always Backup",
|
||||
desc: "Snapshot before every sync (high disk usage)",
|
||||
},
|
||||
{
|
||||
value: "on-force-push",
|
||||
label: "Smart",
|
||||
desc: "Backup only on force-push",
|
||||
},
|
||||
{
|
||||
value: "block-on-force-push",
|
||||
label: "Block & Approve",
|
||||
desc: "Require approval on force-push",
|
||||
},
|
||||
] as const).map((opt) => {
|
||||
const isSelected = (giteaConfig.backupStrategy ?? "on-force-push") === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newConfig = { ...giteaConfig, backupStrategy: opt.value as BackupStrategy };
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className={`flex flex-col items-start gap-1 rounded-lg border p-3 text-left text-sm transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-input hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="backup-retention" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot retention count
|
||||
</label>
|
||||
<input
|
||||
id="backup-retention"
|
||||
name="backupRetentionCount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={giteaConfig.backupRetentionCount ?? 5}
|
||||
onChange={(e) => {
|
||||
const newConfig = {
|
||||
...giteaConfig,
|
||||
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
|
||||
};
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="backup-retention-days" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot retention days
|
||||
</label>
|
||||
<input
|
||||
id="backup-retention-days"
|
||||
name="backupRetentionDays"
|
||||
type="number"
|
||||
min={0}
|
||||
value={giteaConfig.backupRetentionDays ?? 30}
|
||||
onChange={(e) => {
|
||||
const newConfig = {
|
||||
...giteaConfig,
|
||||
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
|
||||
};
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">0 = no time-based limit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="backup-directory" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot directory
|
||||
</label>
|
||||
<input
|
||||
id="backup-directory"
|
||||
name="backupDirectory"
|
||||
type="text"
|
||||
value={giteaConfig.backupDirectory || "data/repo-backups"}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...giteaConfig, backupDirectory: e.target.value };
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="data/repo-backups"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{((giteaConfig.backupStrategy ?? "on-force-push") === "always" ||
|
||||
(giteaConfig.backupStrategy ?? "on-force-push") === "on-force-push") && (
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
name="blockSyncOnBackupFailure"
|
||||
type="checkbox"
|
||||
checked={Boolean(giteaConfig.blockSyncOnBackupFailure)}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...giteaConfig, blockSyncOnBackupFailure: e.target.checked };
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className="mt-0.5 rounded border-input"
|
||||
/>
|
||||
<span>
|
||||
Block sync when snapshot fails
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommended for backup-first behavior. If disabled, sync continues even when snapshot creation fails.
|
||||
</p>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile: Show button at bottom */}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
Check,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
@@ -31,7 +33,9 @@ import {
|
||||
ChevronDown,
|
||||
Funnel,
|
||||
HardDrive,
|
||||
FileCode2
|
||||
FileCode2,
|
||||
Plus,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
|
||||
import {
|
||||
@@ -41,7 +45,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { githubApi } from "@/lib/api";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -60,8 +73,42 @@ export function GitHubMirrorSettings({
|
||||
onMirrorOptionsChange,
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
|
||||
const [starListsOpen, setStarListsOpen] = React.useState(false);
|
||||
const [starListSearch, setStarListSearch] = React.useState("");
|
||||
const [customStarListName, setCustomStarListName] = React.useState("");
|
||||
const [availableStarLists, setAvailableStarLists] = React.useState<string[]>([]);
|
||||
const [loadingStarLists, setLoadingStarLists] = React.useState(false);
|
||||
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
|
||||
const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false);
|
||||
|
||||
const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const list of lists) {
|
||||
const trimmed = list.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}, []);
|
||||
|
||||
const selectedStarLists = React.useMemo(
|
||||
() => normalizeStarListNames(githubConfig.starredLists),
|
||||
[githubConfig.starredLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const allKnownStarLists = React.useMemo(
|
||||
() => normalizeStarListNames([...availableStarLists, ...selectedStarLists]),
|
||||
[availableStarLists, selectedStarLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string | string[]) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
@@ -83,6 +130,59 @@ export function GitHubMirrorSettings({
|
||||
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
|
||||
};
|
||||
|
||||
const setSelectedStarLists = React.useCallback((lists: string[]) => {
|
||||
onGitHubConfigChange({
|
||||
...githubConfig,
|
||||
starredLists: normalizeStarListNames(lists),
|
||||
});
|
||||
}, [githubConfig, normalizeStarListNames, onGitHubConfigChange]);
|
||||
|
||||
const loadStarLists = React.useCallback(async () => {
|
||||
if (
|
||||
loadingStarLists ||
|
||||
loadedStarLists ||
|
||||
attemptedStarListLoad ||
|
||||
!githubConfig.mirrorStarred
|
||||
) return;
|
||||
|
||||
setAttemptedStarListLoad(true);
|
||||
setLoadingStarLists(true);
|
||||
try {
|
||||
const response = await githubApi.getStarredLists();
|
||||
setAvailableStarLists(normalizeStarListNames(response.lists));
|
||||
setLoadedStarLists(true);
|
||||
} catch {
|
||||
// Keep UX usable with manual custom input even if list fetch fails.
|
||||
// Allow retry on next popover open.
|
||||
setLoadedStarLists(false);
|
||||
} finally {
|
||||
setLoadingStarLists(false);
|
||||
}
|
||||
}, [
|
||||
attemptedStarListLoad,
|
||||
githubConfig.mirrorStarred,
|
||||
loadedStarLists,
|
||||
loadingStarLists,
|
||||
normalizeStarListNames,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen || !githubConfig.mirrorStarred) return;
|
||||
void loadStarLists();
|
||||
}, [starListsOpen, githubConfig.mirrorStarred, loadStarLists]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!githubConfig.mirrorStarred) {
|
||||
setStarListsOpen(false);
|
||||
}
|
||||
}, [githubConfig.mirrorStarred]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen) {
|
||||
setAttemptedStarListLoad(false);
|
||||
}
|
||||
}, [starListsOpen]);
|
||||
|
||||
// When metadata is disabled, all components should be disabled
|
||||
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
|
||||
|
||||
@@ -98,6 +198,17 @@ export function GitHubMirrorSettings({
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
const totalStarredOptions = 4; // releases, issues, PRs, wiki
|
||||
|
||||
const normalizedStarListSearch = starListSearch.trim();
|
||||
const canAddSearchAsStarList = normalizedStarListSearch.length > 0
|
||||
&& !allKnownStarLists.some((list) => list.toLowerCase() === normalizedStarListSearch.toLowerCase());
|
||||
|
||||
const addCustomStarList = () => {
|
||||
const trimmed = customStarListName.trim();
|
||||
if (!trimmed) return;
|
||||
setSelectedStarLists([...selectedStarLists, trimmed]);
|
||||
setCustomStarListName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
@@ -287,6 +398,168 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-mirror starred repos toggle */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="auto-mirror-starred"
|
||||
checked={advancedOptions.autoMirrorStarred ?? false}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('autoMirrorStarred', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="auto-mirror-starred"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Auto-mirror new starred repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When disabled, starred repos are imported for browsing but not automatically mirrored. You can still mirror individual repos manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star list selection */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Star Lists (optional)
|
||||
</Label>
|
||||
<Popover open={starListsOpen} onOpenChange={setStarListsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={starListsOpen}
|
||||
className="w-full justify-between h-9 text-xs font-normal"
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedStarLists.length === 0
|
||||
? "All starred repositories"
|
||||
: `${selectedStarLists.length} list${selectedStarLists.length === 1 ? "" : "s"} selected`}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[360px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
value={starListSearch}
|
||||
onValueChange={setStarListSearch}
|
||||
placeholder="Search GitHub star lists..."
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loadingStarLists ? "Loading star lists..." : "No matching lists"}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allKnownStarLists.map((list) => {
|
||||
const isSelected = selectedStarLists.some(
|
||||
(selected) => selected.toLowerCase() === list.toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={list}
|
||||
value={list}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedStarLists([...selectedStarLists, list]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{list}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
{canAddSearchAsStarList && (
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => {
|
||||
setSelectedStarLists([...selectedStarLists, normalizedStarListSearch]);
|
||||
setStarListSearch("");
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add "{normalizedStarListSearch}"
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to mirror all starred repositories. Select one or more lists to limit syncing.
|
||||
</p>
|
||||
|
||||
{selectedStarLists.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedStarLists.map((list) => (
|
||||
<Badge key={list} variant="secondary" className="gap-1">
|
||||
<span>{list}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="rounded-sm hover:text-foreground/80"
|
||||
aria-label={`Remove ${list} list`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={customStarListName}
|
||||
onChange={(event) => setCustomStarListName(event.target.value)}
|
||||
placeholder="Add custom list name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addCustomStarList}
|
||||
disabled={!customStarListName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
@@ -377,14 +650,13 @@ export function GitHubMirrorSettings({
|
||||
id="release-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={mirrorOptions.releaseLimit || 10}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 10;
|
||||
const clampedValue = Math.min(100, Math.max(1, value));
|
||||
const clampedValue = Math.max(1, value);
|
||||
handleMirrorChange('releaseLimit', clampedValue);
|
||||
}}
|
||||
className="w-16 px-2 py-1 text-xs border border-input rounded bg-background text-foreground"
|
||||
className="w-20 px-2 py-1 text-xs border border-input rounded bg-background text-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">releases</span>
|
||||
</div>
|
||||
|
||||
@@ -100,9 +100,14 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedValue =
|
||||
type === "checkbox"
|
||||
? checked
|
||||
: value;
|
||||
|
||||
const newConfig = {
|
||||
...config,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
[name]: normalizedValue,
|
||||
};
|
||||
setConfig(newConfig);
|
||||
|
||||
@@ -195,6 +200,27 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-external-url"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Gitea External URL (optional)
|
||||
</label>
|
||||
<input
|
||||
id="gitea-external-url"
|
||||
name="externalUrl"
|
||||
type="url"
|
||||
value={config.externalUrl || ""}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="https://gitea.example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Used only for dashboard links. API sync still uses Gitea URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-token"
|
||||
@@ -224,6 +250,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
starredReposMode={config.starredReposMode}
|
||||
onStrategyChange={setMirrorStrategy}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={config.username}
|
||||
@@ -235,6 +262,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
starredReposMode={config.starredReposMode}
|
||||
personalReposOrg={config.personalReposOrg}
|
||||
visibility={config.visibility}
|
||||
onDestinationOrgChange={(org) => {
|
||||
@@ -247,6 +275,11 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onStarredReposModeChange={(mode) => {
|
||||
const newConfig = { ...config, starredReposMode: mode };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onPersonalReposOrgChange={(org) => {
|
||||
const newConfig = { ...config, personalReposOrg: org };
|
||||
setConfig(newConfig);
|
||||
@@ -258,7 +291,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* Mobile: Show button at bottom */}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bell, Activity, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
notificationConfig: NotificationConfig;
|
||||
onNotificationChange: (config: NotificationConfig) => void;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationSettings({
|
||||
notificationConfig,
|
||||
onNotificationChange,
|
||||
isAutoSaving,
|
||||
}: NotificationSettingsProps) {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const resp = await fetch(withBase("/api/notifications/test"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ notificationConfig }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.success) {
|
||||
toast.success("Test notification sent successfully!");
|
||||
} else {
|
||||
toast.error(`Test failed: ${result.error || "Unknown error"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Test failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
{isAutoSaving && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Enable/disable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifications-enabled" className="text-sm font-medium cursor-pointer">
|
||||
Enable notifications
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Receive alerts when mirror jobs complete or fail
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifications-enabled"
|
||||
checked={notificationConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notificationConfig.enabled && (
|
||||
<>
|
||||
{/* Provider selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notification-provider" className="text-sm font-medium">
|
||||
Notification provider
|
||||
</Label>
|
||||
<Select
|
||||
value={notificationConfig.provider}
|
||||
onValueChange={(value: "ntfy" | "apprise") =>
|
||||
onNotificationChange({ ...notificationConfig, provider: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="notification-provider">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
|
||||
<SelectItem value="apprise">Apprise API</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Ntfy configuration */}
|
||||
{notificationConfig.provider === "ntfy" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Ntfy.sh Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-url" className="text-sm">
|
||||
Server URL
|
||||
</Label>
|
||||
<Input
|
||||
id="ntfy-url"
|
||||
type="url"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={notificationConfig.ntfy?.url || "https://ntfy.sh"}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: e.target.value,
|
||||
topic: notificationConfig.ntfy?.topic || "",
|
||||
priority: notificationConfig.ntfy?.priority || "default",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use https://ntfy.sh for the public server or your self-hosted instance URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-topic" className="text-sm">
|
||||
Topic <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="ntfy-topic"
|
||||
placeholder="gitea-mirror"
|
||||
value={notificationConfig.ntfy?.topic || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
|
||||
topic: e.target.value,
|
||||
priority: notificationConfig.ntfy?.priority || "default",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a unique topic name. Anyone with the topic name can subscribe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-token" className="text-sm">
|
||||
Access token (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="ntfy-token"
|
||||
type="password"
|
||||
placeholder="tk_..."
|
||||
value={notificationConfig.ntfy?.token || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
|
||||
topic: notificationConfig.ntfy?.topic || "",
|
||||
token: e.target.value,
|
||||
priority: notificationConfig.ntfy?.priority || "default",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required if your ntfy server uses authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-priority" className="text-sm">
|
||||
Default priority
|
||||
</Label>
|
||||
<Select
|
||||
value={notificationConfig.ntfy?.priority || "default"}
|
||||
onValueChange={(value: "min" | "low" | "default" | "high" | "urgent") =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
|
||||
topic: notificationConfig.ntfy?.topic || "",
|
||||
priority: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="ntfy-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="min">Min</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error notifications always use "high" priority regardless of this setting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apprise configuration */}
|
||||
{notificationConfig.provider === "apprise" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Apprise API Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-url" className="text-sm">
|
||||
Server URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="apprise-url"
|
||||
type="url"
|
||||
placeholder="http://apprise:8000"
|
||||
value={notificationConfig.apprise?.url || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
apprise: {
|
||||
...notificationConfig.apprise!,
|
||||
url: e.target.value,
|
||||
token: notificationConfig.apprise?.token || "",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL of your Apprise API server (e.g., http://apprise:8000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-token" className="text-sm">
|
||||
Token / path <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="apprise-token"
|
||||
placeholder="gitea-mirror"
|
||||
value={notificationConfig.apprise?.token || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
apprise: {
|
||||
...notificationConfig.apprise!,
|
||||
url: notificationConfig.apprise?.url || "",
|
||||
token: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Apprise API configuration token or key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-tag" className="text-sm">
|
||||
Tag filter (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="apprise-tag"
|
||||
placeholder="all"
|
||||
value={notificationConfig.apprise?.tag || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
apprise: {
|
||||
...notificationConfig.apprise!,
|
||||
url: notificationConfig.apprise?.url || "",
|
||||
token: notificationConfig.apprise?.token || "",
|
||||
tag: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional tag to filter which Apprise services receive notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event toggles */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Notification Events</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-sync-error" className="text-sm font-normal cursor-pointer">
|
||||
Sync errors
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a mirror job fails
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-sync-error"
|
||||
checked={notificationConfig.notifyOnSyncError}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnSyncError: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-sync-success" className="text-sm font-normal cursor-pointer">
|
||||
Sync success
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a mirror job completes successfully
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-sync-success"
|
||||
checked={notificationConfig.notifyOnSyncSuccess}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnSyncSuccess: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-new-repo" className="text-sm font-normal cursor-pointer text-muted-foreground">
|
||||
New repository discovered (coming soon)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a new GitHub repository is auto-imported
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-new-repo"
|
||||
checked={notificationConfig.notifyOnNewRepo}
|
||||
disabled
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnNewRepo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestNotification}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Activity className="h-4 w-4 animate-spin mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Test Notification
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,18 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MirrorStrategy, GiteaOrgVisibility } from "@/types/config";
|
||||
import type { MirrorStrategy, GiteaOrgVisibility, StarredReposMode } from "@/types/config";
|
||||
|
||||
interface OrganizationConfigurationProps {
|
||||
strategy: MirrorStrategy;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
starredReposMode?: StarredReposMode;
|
||||
personalReposOrg?: string;
|
||||
visibility: GiteaOrgVisibility;
|
||||
onDestinationOrgChange: (org: string) => void;
|
||||
onStarredReposOrgChange: (org: string) => void;
|
||||
onStarredReposModeChange: (mode: StarredReposMode) => void;
|
||||
onPersonalReposOrgChange: (org: string) => void;
|
||||
onVisibilityChange: (visibility: GiteaOrgVisibility) => void;
|
||||
}
|
||||
@@ -33,13 +35,19 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
strategy,
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
starredReposMode,
|
||||
personalReposOrg,
|
||||
visibility,
|
||||
onDestinationOrgChange,
|
||||
onStarredReposOrgChange,
|
||||
onStarredReposModeChange,
|
||||
onPersonalReposOrgChange,
|
||||
onVisibilityChange,
|
||||
}) => {
|
||||
const activeStarredMode = starredReposMode || "dedicated-org";
|
||||
const showStarredReposOrgInput = activeStarredMode === "dedicated-org";
|
||||
const showDestinationOrgInput = strategy === "single-org" || strategy === "mixed";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -49,38 +57,94 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* First row - Organization inputs with consistent layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Left column - always shows starred repos org */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Starred Repos Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Starred repositories will be organized separately in this organization</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="starredReposOrg"
|
||||
value={starredReposOrg || ""}
|
||||
onChange={(e) => onStarredReposOrgChange(e.target.value)}
|
||||
placeholder="starred"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Keep starred repos organized separately
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-normal flex items-center gap-2">
|
||||
Starred Repository Destination
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Choose whether starred repos use one org or keep their source Owner/Org paths</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="rounded-lg border bg-muted/20 p-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStarredReposModeChange("dedicated-org")}
|
||||
aria-pressed={activeStarredMode === "dedicated-org"}
|
||||
className={cn(
|
||||
"text-left px-3 py-2 rounded-md border text-sm transition-all",
|
||||
activeStarredMode === "dedicated-org"
|
||||
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
)}
|
||||
>
|
||||
Dedicated Organization
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStarredReposModeChange("preserve-owner")}
|
||||
aria-pressed={activeStarredMode === "preserve-owner"}
|
||||
className={cn(
|
||||
"text-left px-3 py-2 rounded-md border text-sm transition-all",
|
||||
activeStarredMode === "preserve-owner"
|
||||
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
)}
|
||||
>
|
||||
Preserve Source Owner/Org
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 px-1 text-xs text-muted-foreground">
|
||||
{
|
||||
activeStarredMode === "dedicated-org"
|
||||
? "All starred repositories go to a single destination organization."
|
||||
: "Starred repositories keep their original GitHub Owner/Org destination."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - shows destination org for single-org/mixed, personal repos org for preserve, empty div for others */}
|
||||
{strategy === "single-org" || strategy === "mixed" ? (
|
||||
{/* First row - Organization inputs */}
|
||||
{(showStarredReposOrgInput || showDestinationOrgInput) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{showStarredReposOrgInput ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Starred Repos Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Starred repositories will be organized separately in this organization</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="starredReposOrg"
|
||||
value={starredReposOrg || ""}
|
||||
onChange={(e) => onStarredReposOrgChange(e.target.value)}
|
||||
placeholder="starred"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Keep starred repos organized separately
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
|
||||
{showDestinationOrgInput ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
{strategy === "mixed" ? "Personal Repos Organization" : "Destination Organization"}
|
||||
@@ -114,10 +178,11 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Second row - Organization Visibility (always shown) */}
|
||||
<div className="space-y-2">
|
||||
@@ -172,4 +237,3 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { StarredReposMode } from "@/types/config";
|
||||
|
||||
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
|
||||
|
||||
@@ -15,6 +16,7 @@ interface OrganizationStrategyProps {
|
||||
strategy: MirrorStrategy;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
starredReposMode?: StarredReposMode;
|
||||
onStrategyChange: (strategy: MirrorStrategy) => void;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
@@ -76,13 +78,18 @@ const MappingPreview: React.FC<{
|
||||
config: typeof strategyConfig.preserve;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
starredReposMode?: StarredReposMode;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
}> = ({ strategy, config, destinationOrg, starredReposOrg, githubUsername, giteaUsername }) => {
|
||||
}> = ({ strategy, config, destinationOrg, starredReposOrg, starredReposMode, githubUsername, giteaUsername }) => {
|
||||
const displayGithubUsername = githubUsername || "<username>";
|
||||
const displayGiteaUsername = giteaUsername || "<username>";
|
||||
const isGithubPlaceholder = !githubUsername;
|
||||
const isGiteaPlaceholder = !giteaUsername;
|
||||
const starredDestination =
|
||||
(starredReposMode || "dedicated-org") === "preserve-owner"
|
||||
? "awesome/starred-repo"
|
||||
: `${starredReposOrg || "starred"}/starred-repo`;
|
||||
|
||||
if (strategy === "preserve") {
|
||||
return (
|
||||
@@ -122,7 +129,7 @@ const MappingPreview: React.FC<{
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
<span>{starredDestination}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +175,7 @@ const MappingPreview: React.FC<{
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
<span>{starredDestination}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +221,7 @@ const MappingPreview: React.FC<{
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
<span>{starredDestination}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,7 +267,7 @@ const MappingPreview: React.FC<{
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
<span>{starredDestination}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,6 +282,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
strategy,
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
starredReposMode,
|
||||
onStrategyChange,
|
||||
githubUsername,
|
||||
giteaUsername,
|
||||
@@ -339,7 +347,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
<span className="text-xs font-medium">Starred Repositories</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-5">
|
||||
Always go to the configured starred repos organization and cannot be overridden.
|
||||
Follow your starred-repo mode and cannot be overridden per repository.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -415,6 +423,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
config={config}
|
||||
destinationOrg={destinationOrg}
|
||||
starredReposOrg={starredReposOrg}
|
||||
starredReposMode={starredReposMode}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={giteaUsername}
|
||||
/>
|
||||
@@ -434,4 +443,4 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { Badge } from '../ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
||||
try {
|
||||
@@ -100,6 +101,9 @@ export function SSOSettings() {
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
const appOrigin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const buildAbsoluteAppUrl = (path: string) =>
|
||||
appOrigin ? new URL(withBase(path), appOrigin).toString() : withBase(path);
|
||||
|
||||
|
||||
|
||||
@@ -179,8 +183,8 @@ export function SSOSettings() {
|
||||
} else {
|
||||
requestData.entryPoint = providerForm.entryPoint;
|
||||
requestData.cert = providerForm.cert;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
|
||||
requestData.audience = providerForm.audience || window.location.origin;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
|
||||
requestData.audience = providerForm.audience || appOrigin;
|
||||
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
|
||||
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
|
||||
requestData.digestAlgorithm = providerForm.digestAlgorithm;
|
||||
@@ -517,7 +521,7 @@ export function SSOSettings() {
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>Redirect URL: {buildAbsoluteAppUrl(`/api/auth/sso/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||
@@ -563,8 +567,8 @@ export function SSOSettings() {
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -724,4 +728,4 @@ export function SSOSettings() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
// Helper function to format last sync time
|
||||
function formatLastSyncTime(date: Date | null): string {
|
||||
@@ -110,7 +111,7 @@ export function Dashboard() {
|
||||
useEffectForToasts(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
|
||||
const eventSource = new EventSource(`${withBase("/api/events")}?userId=${user.id}`);
|
||||
|
||||
eventSource.addEventListener("rate-limit", (event) => {
|
||||
try {
|
||||
@@ -306,7 +307,7 @@ export function Dashboard() {
|
||||
title="Repositories"
|
||||
value={repoCount}
|
||||
icon={<GitFork className="h-4 w-4" />}
|
||||
description="Total in mirror queue"
|
||||
description="Total imported repositories"
|
||||
/>
|
||||
<StatusCard
|
||||
title="Mirrored"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Activity, Clock } from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: MirrorJob[];
|
||||
@@ -14,7 +15,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/activity">View All</a>
|
||||
<a href={withBase("/activity")}>View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -27,7 +28,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/activity">
|
||||
<a href={withBase("/activity")}>
|
||||
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||
View History
|
||||
</a>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { GitFork } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface RepositoryListProps {
|
||||
repositories: Repository[];
|
||||
@@ -15,10 +17,6 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
|
||||
// Helper function to construct Gitea repository URL
|
||||
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
||||
if (!validStatuses.includes(repository.status)) {
|
||||
@@ -37,12 +35,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
repoPath = `${owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${repoPath}`;
|
||||
return buildGiteaWebUrl(giteaConfig, repoPath);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,7 +43,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/repositories">View All</a>
|
||||
<a href={withBase("/repositories")}>View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -62,7 +55,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
Configure your GitHub connection to start mirroring repositories.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/config">Configure GitHub</a>
|
||||
<a href={withBase("/config")}>Configure GitHub</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface HeaderProps {
|
||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||
@@ -101,14 +102,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPage !== 'dashboard') {
|
||||
window.history.pushState({}, '', '/');
|
||||
window.history.pushState({}, '', withBase('/'));
|
||||
onNavigate?.('dashboard');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-5 w-6"
|
||||
/>
|
||||
@@ -163,7 +164,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/login">Login</a>
|
||||
<a href={withBase('/login')}>Login</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRepoSync } from "@/hooks/useSyncRepo";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { stripBasePath, withBase } from "@/lib/base-path";
|
||||
|
||||
// Navigation context to signal when navigation happens
|
||||
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
|
||||
@@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const path = window.location.pathname;
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
const pageMap: Record<string, AppProps['page']> = {
|
||||
'/': 'dashboard',
|
||||
'/repositories': 'repositories',
|
||||
@@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
if (!authLoading && !user) {
|
||||
// Use window.location for client-side redirect
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = withBase('/login');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -159,7 +160,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
{currentPage === "activity-log" && <ActivityLog />}
|
||||
</section>
|
||||
</div>
|
||||
<Toaster />
|
||||
<Toaster position="top-center" />
|
||||
</main>
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { stripBasePath, withBase } from "@/lib/base-path";
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
@@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
|
||||
useEffect(() => {
|
||||
// Hydration happens here
|
||||
const path = window.location.pathname;
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
setCurrentPath(path);
|
||||
}, []);
|
||||
|
||||
// Listen for URL changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
setCurrentPath(stripBasePath(window.location.pathname));
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
@@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
if (currentPath === href) return;
|
||||
|
||||
// Update URL without page reload
|
||||
window.history.pushState({}, '', href);
|
||||
window.history.pushState({}, '', withBase(href));
|
||||
setCurrentPath(href);
|
||||
|
||||
// Map href to page name for the parent component
|
||||
@@ -163,7 +164,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
|
||||
@@ -177,7 +178,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,9 +20,11 @@ interface AddOrganizationDialogProps {
|
||||
onAddOrganization: ({
|
||||
org,
|
||||
role,
|
||||
force,
|
||||
}: {
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
force?: boolean;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -36,6 +38,14 @@ export default function AddOrganizationDialog({
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setError("");
|
||||
setOrg("");
|
||||
setRole("member");
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -54,7 +64,7 @@ export default function AddOrganizationDialog({
|
||||
setRole("member");
|
||||
setIsDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add repository.");
|
||||
setError(err?.message || "Failed to add organization.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -139,7 +149,7 @@ export default function AddOrganizationDialog({
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Add Repository"
|
||||
"Add Organization"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, Filter } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2 } from "lucide-react";
|
||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||
import { OrganizationList } from "./OrganizationsList";
|
||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||
@@ -37,6 +37,14 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function Organization() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
@@ -52,6 +60,15 @@ export function Organization() {
|
||||
status: "",
|
||||
});
|
||||
const [loadingOrgIds, setLoadingOrgIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
|
||||
const [duplicateOrgCandidate, setDuplicateOrgCandidate] = useState<{
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
} | null>(null);
|
||||
const [isDuplicateOrgDialogOpen, setIsDuplicateOrgDialogOpen] = useState(false);
|
||||
const [isProcessingDuplicateOrg, setIsProcessingDuplicateOrg] = useState(false);
|
||||
const [orgToDelete, setOrgToDelete] = useState<Organization | null>(null);
|
||||
const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false);
|
||||
const [isDeletingOrg, setIsDeletingOrg] = useState(false);
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
@@ -256,19 +273,45 @@ export function Organization() {
|
||||
const handleAddOrganization = async ({
|
||||
org,
|
||||
role,
|
||||
force = false,
|
||||
}: {
|
||||
org: string;
|
||||
role: MembershipRole;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedOrg = org.trim();
|
||||
const normalizedOrg = trimmedOrg.toLowerCase();
|
||||
|
||||
if (!trimmedOrg) {
|
||||
toast.error("Please enter a valid organization name.");
|
||||
throw new Error("Invalid organization name");
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
const alreadyExists = organizations.some(
|
||||
(existing) => existing.name?.trim().toLowerCase() === normalizedOrg
|
||||
);
|
||||
|
||||
if (alreadyExists) {
|
||||
toast.warning("Organization already exists.");
|
||||
setDuplicateOrgCandidate({ org: trimmedOrg, role });
|
||||
setIsDuplicateOrgDialogOpen(true);
|
||||
throw new Error("Organization already exists");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const reqPayload: AddOrganizationApiRequest = {
|
||||
userId: user.id,
|
||||
org,
|
||||
org: trimmedOrg,
|
||||
role,
|
||||
force,
|
||||
};
|
||||
|
||||
const response = await apiRequest<AddOrganizationApiResponse>(
|
||||
@@ -280,25 +323,100 @@ export function Organization() {
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Organization added successfully`);
|
||||
setOrganizations((prev) => [...prev, response.organization]);
|
||||
const message = force
|
||||
? "Organization already exists; using existing entry."
|
||||
: "Organization added successfully";
|
||||
toast.success(message);
|
||||
|
||||
await fetchOrganizations();
|
||||
await fetchOrganizations(false);
|
||||
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
searchTerm: org,
|
||||
searchTerm: trimmedOrg,
|
||||
}));
|
||||
|
||||
if (force) {
|
||||
setIsDuplicateOrgDialogOpen(false);
|
||||
setDuplicateOrgCandidate(null);
|
||||
}
|
||||
} else {
|
||||
showErrorToast(response.error || "Error adding organization", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDuplicateOrganization = async () => {
|
||||
if (!duplicateOrgCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingDuplicateOrg(true);
|
||||
try {
|
||||
await handleAddOrganization({
|
||||
org: duplicateOrgCandidate.org,
|
||||
role: duplicateOrgCandidate.role,
|
||||
force: true,
|
||||
});
|
||||
setIsDialogOpen(false);
|
||||
setDuplicateOrgCandidate(null);
|
||||
setIsDuplicateOrgDialogOpen(false);
|
||||
} catch (error) {
|
||||
// Error already surfaced via toast
|
||||
} finally {
|
||||
setIsProcessingDuplicateOrg(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDuplicateOrganization = () => {
|
||||
setIsDuplicateOrgDialogOpen(false);
|
||||
setDuplicateOrgCandidate(null);
|
||||
};
|
||||
|
||||
const handleRequestDeleteOrganization = (orgId: string) => {
|
||||
const org = organizations.find((item) => item.id === orgId);
|
||||
if (!org) {
|
||||
toast.error("Organization not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setOrgToDelete(org);
|
||||
setIsDeleteOrgDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
if (!user || !user.id || !orgToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeletingOrg(true);
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; error?: string }>(
|
||||
`/organizations/${orgToDelete.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Removed ${orgToDelete.name} from Gitea Mirror.`);
|
||||
await fetchOrganizations(false);
|
||||
} else {
|
||||
showErrorToast(response.error || "Failed to delete organization", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsDeletingOrg(false);
|
||||
setIsDeleteOrgDialogOpen(false);
|
||||
setOrgToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMirrorAllOrgs = async () => {
|
||||
try {
|
||||
if (!user || !user.id || organizations.length === 0) {
|
||||
@@ -711,6 +829,7 @@ export function Organization() {
|
||||
onMirror={handleMirrorOrg}
|
||||
onIgnore={handleIgnoreOrg}
|
||||
onAddOrganization={() => setIsDialogOpen(true)}
|
||||
onDelete={handleRequestDeleteOrganization}
|
||||
onRefresh={async () => {
|
||||
await fetchOrganizations(false);
|
||||
}}
|
||||
@@ -721,6 +840,68 @@ export function Organization() {
|
||||
isDialogOpen={isDialogOpen}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
/>
|
||||
|
||||
<Dialog open={isDuplicateOrgDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancelDuplicateOrganization();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Organization already exists</DialogTitle>
|
||||
<DialogDescription>
|
||||
{duplicateOrgCandidate?.org ?? "This organization"} is already synced in Gitea Mirror.
|
||||
Continuing will reuse the existing entry without creating a duplicate. You can remove it later if needed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelDuplicateOrganization} disabled={isProcessingDuplicateOrg}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmDuplicateOrganization} disabled={isProcessingDuplicateOrg}>
|
||||
{isProcessingDuplicateOrg ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDeleteOrgDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsDeleteOrgDialogOpen(false);
|
||||
setOrgToDelete(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove organization from Gitea Mirror?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{orgToDelete?.name ?? "This organization"} will be deleted from Gitea Mirror only. Nothing will be removed from Gitea; you will need to clean it up manually in Gitea if desired.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setIsDeleteOrgDialogOpen(false);
|
||||
setOrgToDelete(null);
|
||||
}} disabled={isDeletingOrg}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteOrganization} disabled={isDeletingOrg}>
|
||||
{isDeletingOrg ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,17 @@ import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban } from "lucide-react";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, Trash2 } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import Fuse from "fuse.js";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -30,6 +32,7 @@ interface OrganizationListProps {
|
||||
loadingOrgIds: Set<string>;
|
||||
onAddOrganization?: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
onDelete?: (orgId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to get status badge variant and icon
|
||||
@@ -60,15 +63,12 @@ export function OrganizationList({
|
||||
loadingOrgIds,
|
||||
onAddOrganization,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
}: OrganizationListProps) {
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
// Helper function to construct Gitea organization URL
|
||||
const getGiteaOrgUrl = (organization: Organization): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for organizations that have been mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored'];
|
||||
if (!validStatuses.includes(organization.status || '')) {
|
||||
@@ -81,17 +81,12 @@ export function OrganizationList({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${orgName}`;
|
||||
return buildGiteaWebUrl(giteaConfig, orgName);
|
||||
};
|
||||
|
||||
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
|
||||
// Call API to update organization destination
|
||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -195,7 +190,7 @@ export function OrganizationList({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer truncate"
|
||||
>
|
||||
{org.name}
|
||||
@@ -245,6 +240,11 @@ export function OrganizationList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message for failed orgs */}
|
||||
{org.status === "failed" && org.errorMessage && (
|
||||
<p className="text-xs text-destructive line-clamp-2">{org.errorMessage}</p>
|
||||
)}
|
||||
|
||||
{/* Destination override section */}
|
||||
<div>
|
||||
<MirrorDestinationEditor
|
||||
@@ -265,7 +265,7 @@ export function OrganizationList({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="text-xl font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
@@ -301,6 +301,13 @@ export function OrganizationList({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message for failed orgs */}
|
||||
{org.status === "failed" && org.errorMessage && (
|
||||
<div className="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<p className="text-sm text-destructive">{org.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository statistics */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
@@ -310,7 +317,7 @@ export function OrganizationList({
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Repository breakdown - only show non-zero counts */}
|
||||
{(() => {
|
||||
const counts = [];
|
||||
@@ -323,7 +330,7 @@ export function OrganizationList({
|
||||
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
|
||||
counts.push(`${org.forkRepositoryCount} ${org.forkRepositoryCount === 1 ? 'fork' : 'forks'}`);
|
||||
}
|
||||
|
||||
|
||||
return counts.length > 0 ? (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{counts.map((count, index) => (
|
||||
@@ -412,9 +419,9 @@ export function OrganizationList({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "ignored" && org.status !== "mirroring" && (
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10">
|
||||
@@ -422,17 +429,31 @@ export function OrganizationList({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaOrgUrl(org);
|
||||
@@ -561,7 +582,7 @@ export function OrganizationList({
|
||||
)}
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "ignored" && org.status !== "mirroring" && (
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading}>
|
||||
@@ -569,12 +590,26 @@ export function OrganizationList({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,9 +17,13 @@ interface AddRepositoryDialogProps {
|
||||
onAddRepository: ({
|
||||
repo,
|
||||
owner,
|
||||
force,
|
||||
destinationOrg,
|
||||
}: {
|
||||
repo: string;
|
||||
owner: string;
|
||||
force?: boolean;
|
||||
destinationOrg?: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -30,9 +34,19 @@ export default function AddRepositoryDialog({
|
||||
}: AddRepositoryDialogProps) {
|
||||
const [repo, setRepo] = useState<string>("");
|
||||
const [owner, setOwner] = useState<string>("");
|
||||
const [destinationOrg, setDestinationOrg] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setError("");
|
||||
setRepo("");
|
||||
setOwner("");
|
||||
setDestinationOrg("");
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -44,11 +58,16 @@ export default function AddRepositoryDialog({
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await onAddRepository({ repo, owner });
|
||||
await onAddRepository({
|
||||
repo,
|
||||
owner,
|
||||
destinationOrg: destinationOrg.trim() || undefined,
|
||||
});
|
||||
|
||||
setError("");
|
||||
setRepo("");
|
||||
setOwner("");
|
||||
setDestinationOrg("");
|
||||
setIsDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add repository.");
|
||||
@@ -114,6 +133,27 @@ export default function AddRepositoryDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="destinationOrg"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Target Organization{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="destinationOrg"
|
||||
type="text"
|
||||
value={destinationOrg}
|
||||
onChange={(e) => setDestinationOrg(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="Gitea org or user (uses default strategy if empty)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,9 +28,16 @@ export function InlineDestinationEditor({
|
||||
|
||||
// Determine the default destination based on repository properties and config
|
||||
const getDefaultDestination = () => {
|
||||
// Starred repos always go to the configured starredReposOrg
|
||||
if (repository.isStarred && giteaConfig?.starredReposOrg) {
|
||||
return giteaConfig.starredReposOrg;
|
||||
// Starred repos can use either dedicated org or preserved source owner
|
||||
if (repository.isStarred) {
|
||||
const starredReposMode = giteaConfig?.starredReposMode || "dedicated-org";
|
||||
if (starredReposMode === "preserve-owner") {
|
||||
return repository.organization || repository.owner;
|
||||
}
|
||||
if (giteaConfig?.starredReposOrg) {
|
||||
return giteaConfig.starredReposOrg;
|
||||
}
|
||||
return "starred";
|
||||
}
|
||||
|
||||
// Check mirror strategy
|
||||
@@ -60,7 +67,7 @@ export function InlineDestinationEditor({
|
||||
const defaultDestination = getDefaultDestination();
|
||||
const currentDestination = repository.destinationOrg || defaultDestination;
|
||||
const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination;
|
||||
const isStarredRepo = repository.isStarred && giteaConfig?.starredReposOrg;
|
||||
const isStarredRepo = repository.isStarred;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
@@ -184,4 +191,4 @@ export function InlineDestinationEditor({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check, LoaderCircle, Trash2 } from "lucide-react";
|
||||
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
||||
import {
|
||||
Drawer,
|
||||
@@ -30,30 +30,50 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
import { toast } from "sonner";
|
||||
import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync";
|
||||
import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes";
|
||||
import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
|
||||
import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata";
|
||||
import AddRepositoryDialog from "./AddRepositoryDialog";
|
||||
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
const REPOSITORY_SORT_OPTIONS = [
|
||||
{ value: "imported-desc", label: "Recently Imported" },
|
||||
{ value: "imported-asc", label: "Oldest Imported" },
|
||||
{ value: "updated-desc", label: "Recently Updated" },
|
||||
{ value: "updated-asc", label: "Oldest Updated" },
|
||||
{ value: "name-asc", label: "Name (A-Z)" },
|
||||
{ value: "name-desc", label: "Name (Z-A)" },
|
||||
] as const;
|
||||
|
||||
export default function Repository() {
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
||||
const { isGitHubConfigured, isFullyConfigured } = useConfigStatus();
|
||||
const { isGitHubConfigured, isFullyConfigured, autoMirrorStarred, githubOwner } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
const { filter, setFilter } = useFilterParams({
|
||||
searchTerm: "",
|
||||
status: "",
|
||||
organization: "",
|
||||
owner: "",
|
||||
sort: "imported-desc",
|
||||
});
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
|
||||
@@ -69,6 +89,15 @@ export default function Repository() {
|
||||
}, [setFilter]);
|
||||
|
||||
const [loadingRepoIds, setLoadingRepoIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
|
||||
const [duplicateRepoCandidate, setDuplicateRepoCandidate] = useState<{
|
||||
owner: string;
|
||||
repo: string;
|
||||
} | null>(null);
|
||||
const [isDuplicateRepoDialogOpen, setIsDuplicateRepoDialogOpen] = useState(false);
|
||||
const [isProcessingDuplicateRepo, setIsProcessingDuplicateRepo] = useState(false);
|
||||
const [repoToDelete, setRepoToDelete] = useState<Repository | null>(null);
|
||||
const [isDeleteRepoDialogOpen, setIsDeleteRepoDialogOpen] = useState(false);
|
||||
const [isDeletingRepo, setIsDeletingRepo] = useState(false);
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
@@ -215,10 +244,12 @@ export default function Repository() {
|
||||
// Filter out repositories that are already mirroring, mirrored, or ignored
|
||||
const eligibleRepos = repositories.filter(
|
||||
(repo) =>
|
||||
repo.status !== "mirroring" &&
|
||||
repo.status !== "mirrored" &&
|
||||
repo.status !== "mirroring" &&
|
||||
repo.status !== "mirrored" &&
|
||||
repo.status !== "ignored" && // Skip ignored repositories
|
||||
repo.id
|
||||
repo.id &&
|
||||
// Skip starred repos from other owners when autoMirrorStarred is disabled
|
||||
!(repo.isStarred && !autoMirrorStarred && repo.owner !== githubOwner)
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
@@ -274,7 +305,7 @@ export default function Repository() {
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(
|
||||
repo => repo.status === "imported" || repo.status === "failed"
|
||||
repo => repo.status === "imported" || repo.status === "failed" || repo.status === "pending-approval"
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
@@ -283,7 +314,7 @@ export default function Repository() {
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
@@ -361,6 +392,67 @@ export default function Repository() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkRerunMetadata = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(
|
||||
repo => ["mirrored", "synced", "archived"].includes(repo.status)
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info("No eligible repositories to re-run metadata in selection");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
const resetPayload: ResetMetadataRequest = {
|
||||
userId: user?.id || "",
|
||||
repositoryIds: repoIds,
|
||||
};
|
||||
|
||||
const resetResponse = await apiRequest<ResetMetadataResponse>("/job/reset-metadata", {
|
||||
method: "POST",
|
||||
data: resetPayload,
|
||||
});
|
||||
|
||||
if (!resetResponse.success) {
|
||||
showErrorToast(resetResponse.error || "Failed to reset metadata state", toast);
|
||||
return;
|
||||
}
|
||||
|
||||
const syncResponse = await apiRequest<SyncRepoResponse>("/job/sync-repo", {
|
||||
method: "POST",
|
||||
data: { userId: user?.id, repositoryIds: repoIds },
|
||||
});
|
||||
|
||||
if (syncResponse.success) {
|
||||
toast.success(`Re-running metadata for ${repoIds.length} repositories`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
const updated = syncResponse.repositories.find(r => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
})
|
||||
);
|
||||
setSelectedRepoIds(new Set());
|
||||
} else {
|
||||
showErrorToast(syncResponse.error || "Error starting metadata re-sync", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkRetry = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
@@ -615,22 +707,125 @@ export default function Repository() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveSyncAction = async ({ repoId }: { repoId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) return;
|
||||
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
|
||||
|
||||
const response = await apiRequest<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
repositories: Repository[];
|
||||
}>("/job/approve-sync", {
|
||||
method: "POST",
|
||||
data: { repositoryIds: [repoId], action: "approve" },
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success("Sync approved — backup + sync started");
|
||||
setRepositories((prevRepos) =>
|
||||
prevRepos.map((repo) => {
|
||||
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showErrorToast(response.error || "Error approving sync", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(repoId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissSyncAction = async ({ repoId }: { repoId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) return;
|
||||
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
|
||||
|
||||
const response = await apiRequest<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
repositories: Repository[];
|
||||
}>("/job/approve-sync", {
|
||||
method: "POST",
|
||||
data: { repositoryIds: [repoId], action: "dismiss" },
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success("Force-push alert dismissed");
|
||||
setRepositories((prevRepos) =>
|
||||
prevRepos.map((repo) => {
|
||||
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showErrorToast(response.error || "Error dismissing alert", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(repoId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRepository = async ({
|
||||
repo,
|
||||
owner,
|
||||
force = false,
|
||||
destinationOrg,
|
||||
}: {
|
||||
repo: string;
|
||||
owner: string;
|
||||
force?: boolean;
|
||||
destinationOrg?: string;
|
||||
}) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
if (!user || !user.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedRepo = repo.trim();
|
||||
const trimmedOwner = owner.trim();
|
||||
|
||||
if (!trimmedRepo || !trimmedOwner) {
|
||||
toast.error("Please provide both owner and repository name.");
|
||||
throw new Error("Invalid repository details");
|
||||
}
|
||||
|
||||
const normalizedFullName = `${trimmedOwner}/${trimmedRepo}`.toLowerCase();
|
||||
|
||||
if (!force) {
|
||||
const duplicateRepo = repositories.find(
|
||||
(existing) => existing.normalizedFullName?.toLowerCase() === normalizedFullName
|
||||
);
|
||||
|
||||
if (duplicateRepo) {
|
||||
toast.warning("Repository already exists.");
|
||||
setDuplicateRepoCandidate({ repo: trimmedRepo, owner: trimmedOwner });
|
||||
setIsDuplicateRepoDialogOpen(true);
|
||||
throw new Error("Repository already exists");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const reqPayload: AddRepositoriesApiRequest = {
|
||||
userId: user.id,
|
||||
repo,
|
||||
owner,
|
||||
repo: trimmedRepo,
|
||||
owner: trimmedOwner,
|
||||
force,
|
||||
...(destinationOrg ? { destinationOrg } : {}),
|
||||
};
|
||||
|
||||
const response = await apiRequest<AddRepositoriesApiResponse>(
|
||||
@@ -642,20 +837,28 @@ export default function Repository() {
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Repository added successfully`);
|
||||
setRepositories((prevRepos) => [...prevRepos, response.repository]);
|
||||
const message = force
|
||||
? "Repository already exists; metadata refreshed."
|
||||
: "Repository added successfully";
|
||||
toast.success(message);
|
||||
|
||||
await fetchRepositories(false); // Manual refresh after adding repository
|
||||
await fetchRepositories(false);
|
||||
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
searchTerm: repo,
|
||||
searchTerm: trimmedRepo,
|
||||
}));
|
||||
|
||||
if (force) {
|
||||
setDuplicateRepoCandidate(null);
|
||||
setIsDuplicateRepoDialogOpen(false);
|
||||
}
|
||||
} else {
|
||||
showErrorToast(response.error || "Error adding repository", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -673,6 +876,71 @@ export default function Repository() {
|
||||
)
|
||||
).sort();
|
||||
|
||||
const handleConfirmDuplicateRepository = async () => {
|
||||
if (!duplicateRepoCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingDuplicateRepo(true);
|
||||
try {
|
||||
await handleAddRepository({
|
||||
repo: duplicateRepoCandidate.repo,
|
||||
owner: duplicateRepoCandidate.owner,
|
||||
force: true,
|
||||
});
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
// Error already shown
|
||||
} finally {
|
||||
setIsProcessingDuplicateRepo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDuplicateRepository = () => {
|
||||
setDuplicateRepoCandidate(null);
|
||||
setIsDuplicateRepoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleRequestDeleteRepository = (repoId: string) => {
|
||||
const repo = repositories.find((item) => item.id === repoId);
|
||||
if (!repo) {
|
||||
toast.error("Repository not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setRepoToDelete(repo);
|
||||
setIsDeleteRepoDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteRepository = async () => {
|
||||
if (!user || !user.id || !repoToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeletingRepo(true);
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; error?: string }>(
|
||||
`/repositories/${repoToDelete.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Removed ${repoToDelete.fullName} from Gitea Mirror.`);
|
||||
await fetchRepositories(false);
|
||||
} else {
|
||||
showErrorToast(response.error || "Failed to delete repository", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsDeletingRepo(false);
|
||||
setIsDeleteRepoDialogOpen(false);
|
||||
setRepoToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine what actions are available for selected repositories
|
||||
const getAvailableActions = () => {
|
||||
if (selectedRepoIds.size === 0) return [];
|
||||
@@ -682,7 +950,7 @@ export default function Repository() {
|
||||
const actions = [];
|
||||
|
||||
// Check if any selected repos can be mirrored
|
||||
if (selectedRepos.some(repo => repo.status === "imported" || repo.status === "failed")) {
|
||||
if (selectedRepos.some(repo => repo.status === "imported" || repo.status === "failed" || repo.status === "pending-approval")) {
|
||||
actions.push('mirror');
|
||||
}
|
||||
|
||||
@@ -690,6 +958,10 @@ export default function Repository() {
|
||||
if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) {
|
||||
actions.push('sync');
|
||||
}
|
||||
|
||||
if (selectedRepos.some(repo => ["mirrored", "synced", "archived"].includes(repo.status))) {
|
||||
actions.push('rerun-metadata');
|
||||
}
|
||||
|
||||
// Check if any selected repos are failed
|
||||
if (selectedRepos.some(repo => repo.status === "failed")) {
|
||||
@@ -716,8 +988,9 @@ export default function Repository() {
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
|
||||
return {
|
||||
mirror: selectedRepos.filter(repo => repo.status === "imported" || repo.status === "failed").length,
|
||||
mirror: selectedRepos.filter(repo => repo.status === "imported" || repo.status === "failed" || repo.status === "pending-approval").length,
|
||||
sync: selectedRepos.filter(repo => repo.status === "mirrored" || repo.status === "synced").length,
|
||||
rerunMetadata: selectedRepos.filter(repo => ["mirrored", "synced", "archived"].includes(repo.status)).length,
|
||||
retry: selectedRepos.filter(repo => repo.status === "failed").length,
|
||||
ignore: selectedRepos.filter(repo => repo.status !== "ignored").length,
|
||||
include: selectedRepos.filter(repo => repo.status === "ignored").length,
|
||||
@@ -737,6 +1010,7 @@ export default function Repository() {
|
||||
status: "",
|
||||
organization: "",
|
||||
owner: "",
|
||||
sort: filter.sort || "imported-desc",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -877,6 +1151,33 @@ export default function Repository() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sort Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Sort</span>
|
||||
</label>
|
||||
<Select
|
||||
value={filter.sort || "imported-desc"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
sort: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10">
|
||||
<SelectValue placeholder="Sort repositories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPOSITORY_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
|
||||
@@ -979,6 +1280,27 @@ export default function Repository() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.sort || "imported-desc"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
sort: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[190px] h-10">
|
||||
<SelectValue placeholder="Sort repositories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPOSITORY_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -1041,6 +1363,18 @@ export default function Repository() {
|
||||
Sync ({actionCounts.sync})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('rerun-metadata') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleBulkRerunMetadata}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Re-run Metadata ({actionCounts.rerunMetadata})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('retry') && (
|
||||
<Button
|
||||
@@ -1124,6 +1458,18 @@ export default function Repository() {
|
||||
<span className="hidden sm:inline">Sync </span>({actionCounts.sync})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('rerun-metadata') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkRerunMetadata}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Re-run Metadata ({actionCounts.rerunMetadata})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('retry') && (
|
||||
<Button
|
||||
@@ -1173,7 +1519,7 @@ export default function Repository() {
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/config');
|
||||
window.history.pushState({}, '', withBase('/config'));
|
||||
// We need to trigger a page change event for the navigation system
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
@@ -1198,6 +1544,9 @@ export default function Repository() {
|
||||
onRefresh={async () => {
|
||||
await fetchRepositories(false);
|
||||
}}
|
||||
onDelete={handleRequestDeleteRepository}
|
||||
onApproveSync={handleApproveSyncAction}
|
||||
onDismissSync={handleDismissSyncAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1206,6 +1555,77 @@ export default function Repository() {
|
||||
isDialogOpen={isDialogOpen}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={isDuplicateRepoDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancelDuplicateRepository();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Repository already exists</DialogTitle>
|
||||
<DialogDescription>
|
||||
{duplicateRepoCandidate ? `${duplicateRepoCandidate.owner}/${duplicateRepoCandidate.repo}` : "This repository"} is already tracked in Gitea Mirror. Continuing will refresh the existing entry without creating a duplicate.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelDuplicateRepository} disabled={isProcessingDuplicateRepo}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmDuplicateRepository} disabled={isProcessingDuplicateRepo}>
|
||||
{isProcessingDuplicateRepo ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isDeleteRepoDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsDeleteRepoDialogOpen(false);
|
||||
setRepoToDelete(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove repository from Gitea Mirror?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{repoToDelete?.fullName ?? "This repository"} will be deleted from Gitea Mirror only. The mirror on Gitea will remain untouched; remove it manually in Gitea if needed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDeleteRepoDialogOpen(false);
|
||||
setRepoToDelete(null);
|
||||
}}
|
||||
disabled={isDeletingRepo}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteRepository} disabled={isDeletingRepo}>
|
||||
{isDeletingRepo ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown } from "lucide-react";
|
||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2, X } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils";
|
||||
import { formatLastSyncTime } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
@@ -19,10 +28,12 @@ import {
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
@@ -40,6 +51,33 @@ interface RepositoryTableProps {
|
||||
selectedRepoIds: Set<string>;
|
||||
onSelectionChange: (selectedIds: Set<string>) => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
onDelete?: (repoId: string) => void;
|
||||
onApproveSync?: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onDismissSync?: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
function getTimestamp(value: Date | string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getTableSorting(sortOrder: string | undefined): SortingState {
|
||||
switch (sortOrder ?? "imported-desc") {
|
||||
case "imported-asc":
|
||||
return [{ id: "importedAt", desc: false }];
|
||||
case "updated-desc":
|
||||
return [{ id: "updatedAt", desc: true }];
|
||||
case "updated-asc":
|
||||
return [{ id: "updatedAt", desc: false }];
|
||||
case "name-asc":
|
||||
return [{ id: "fullName", desc: false }];
|
||||
case "name-desc":
|
||||
return [{ id: "fullName", desc: true }];
|
||||
case "imported-desc":
|
||||
default:
|
||||
return [{ id: "importedAt", desc: true }];
|
||||
}
|
||||
}
|
||||
|
||||
export default function RepositoryTable({
|
||||
@@ -56,13 +94,16 @@ export default function RepositoryTable({
|
||||
selectedRepoIds,
|
||||
onSelectionChange,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
onApproveSync,
|
||||
onDismissSync,
|
||||
}: RepositoryTableProps) {
|
||||
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
|
||||
// Call API to update repository destination
|
||||
const response = await fetch(`/api/repositories/${repoId}`, {
|
||||
const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -85,10 +126,6 @@ export default function RepositoryTable({
|
||||
|
||||
// Helper function to construct Gitea repository URL
|
||||
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived'];
|
||||
if (!validStatuses.includes(repository.status)) {
|
||||
@@ -105,48 +142,92 @@ export default function RepositoryTable({
|
||||
repoPath = `${owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${repoPath}`;
|
||||
return buildGiteaWebUrl(giteaConfig, repoPath);
|
||||
};
|
||||
|
||||
const hasAnyFilter = Object.values(filter).some(
|
||||
(val) => val?.toString().trim() !== ""
|
||||
);
|
||||
const hasAnyFilter = [
|
||||
filter.searchTerm,
|
||||
filter.status,
|
||||
filter.owner,
|
||||
filter.organization,
|
||||
].some((val) => val?.toString().trim() !== "");
|
||||
|
||||
const filteredRepositories = useMemo(() => {
|
||||
let result = repositories;
|
||||
const columnFilters = useMemo<ColumnFiltersState>(() => {
|
||||
const next: ColumnFiltersState = [];
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((repo) => repo.status === filter.status);
|
||||
next.push({ id: "status", value: filter.status });
|
||||
}
|
||||
|
||||
if (filter.owner) {
|
||||
result = result.filter((repo) => repo.owner === filter.owner);
|
||||
next.push({ id: "owner", value: filter.owner });
|
||||
}
|
||||
|
||||
if (filter.organization) {
|
||||
result = result.filter(
|
||||
(repo) => repo.organization === filter.organization
|
||||
);
|
||||
next.push({ id: "organization", value: filter.organization });
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const fuse = new Fuse(result, {
|
||||
keys: ["name", "fullName", "owner", "organization"],
|
||||
threshold: 0.3,
|
||||
});
|
||||
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||
}
|
||||
return next;
|
||||
}, [filter.status, filter.owner, filter.organization]);
|
||||
|
||||
return result;
|
||||
}, [repositories, filter]);
|
||||
const sorting = useMemo(() => getTableSorting(filter.sort), [filter.sort]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Repository>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "fullName",
|
||||
accessorFn: (row) => row.fullName,
|
||||
},
|
||||
{
|
||||
id: "owner",
|
||||
accessorFn: (row) => row.owner,
|
||||
filterFn: "equalsString",
|
||||
},
|
||||
{
|
||||
id: "organization",
|
||||
accessorFn: (row) => row.organization ?? "",
|
||||
filterFn: "equalsString",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (row) => row.status,
|
||||
filterFn: "equalsString",
|
||||
},
|
||||
{
|
||||
id: "importedAt",
|
||||
accessorFn: (row) => getTimestamp(row.importedAt),
|
||||
enableGlobalFilter: false,
|
||||
enableColumnFilter: false,
|
||||
},
|
||||
{
|
||||
id: "updatedAt",
|
||||
accessorFn: (row) => getTimestamp(row.updatedAt),
|
||||
enableGlobalFilter: false,
|
||||
enableColumnFilter: false,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: repositories,
|
||||
columns,
|
||||
state: {
|
||||
globalFilter: filter.searchTerm ?? "",
|
||||
columnFilters,
|
||||
sorting,
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const visibleRepositories = table
|
||||
.getRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredRepositories.length,
|
||||
count: visibleRepositories.length,
|
||||
getScrollElement: () => tableParentRef.current,
|
||||
estimateSize: () => 65,
|
||||
overscan: 5,
|
||||
@@ -155,7 +236,11 @@ export default function RepositoryTable({
|
||||
// Selection handlers
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id));
|
||||
const allIds = new Set(
|
||||
visibleRepositories
|
||||
.map((repo) => repo.id)
|
||||
.filter((id): id is string => !!id)
|
||||
);
|
||||
onSelectionChange(allIds);
|
||||
} else {
|
||||
onSelectionChange(new Set());
|
||||
@@ -172,8 +257,9 @@ export default function RepositoryTable({
|
||||
onSelectionChange(newSelection);
|
||||
};
|
||||
|
||||
const isAllSelected = filteredRepositories.length > 0 &&
|
||||
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const isAllSelected =
|
||||
visibleRepositories.length > 0 &&
|
||||
visibleRepositories.every((repo) => repo.id && selectedRepoIds.has(repo.id));
|
||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||
|
||||
// Mobile card layout for repository
|
||||
@@ -228,7 +314,7 @@ export default function RepositoryTable({
|
||||
|
||||
{/* Status & Last Mirrored */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
<Badge
|
||||
className={`capitalize
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
|
||||
@@ -236,13 +322,14 @@ export default function RepositoryTable({
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
|
||||
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
|
||||
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
|
||||
repo.status === 'pending-approval' ? 'bg-amber-500/10 text-amber-600 hover:bg-amber-500/20 dark:text-amber-400' :
|
||||
'bg-muted hover:bg-muted/80'}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{repo.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
{formatLastSyncTime(repo.lastMirrored ?? null)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,7 +400,40 @@ export default function RepositoryTable({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{repo.status === "pending-approval" && (
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
size="default"
|
||||
variant="default"
|
||||
onClick={() => repo.id && onApproveSync?.({ repoId: repo.id })}
|
||||
disabled={isLoading}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2 animate-spin" />
|
||||
Approving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Approve Sync
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => repo.id && onDismissSync?.({ repoId: repo.id })}
|
||||
disabled={isLoading}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ignore/Include button */}
|
||||
{repo.status === "ignored" ? (
|
||||
<Button
|
||||
@@ -338,7 +458,7 @@ export default function RepositoryTable({
|
||||
Ignore Repository
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* External links */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
|
||||
@@ -469,7 +589,7 @@ export default function RepositoryTable({
|
||||
{hasAnyFilter && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {filteredRepositories.length} of {repositories.length} repositories
|
||||
Showing {visibleRepositories.length} of {repositories.length} repositories
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -480,6 +600,7 @@ export default function RepositoryTable({
|
||||
status: "",
|
||||
organization: "",
|
||||
owner: "",
|
||||
sort: filter.sort || "imported-desc",
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -488,7 +609,7 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredRepositories.length === 0 ? (
|
||||
{visibleRepositories.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
{hasAnyFilter
|
||||
@@ -509,12 +630,12 @@ export default function RepositoryTable({
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
Select All ({filteredRepositories.length})
|
||||
Select All ({visibleRepositories.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Repository cards */}
|
||||
{filteredRepositories.map((repo) => (
|
||||
{visibleRepositories.map((repo) => (
|
||||
<RepositoryCard key={repo.id} repo={repo} />
|
||||
))}
|
||||
</div>
|
||||
@@ -560,13 +681,14 @@ export default function RepositoryTable({
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
||||
const repo = filteredRepositories[virtualRow.index];
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const repo = visibleRepositories[virtualRow.index];
|
||||
if (!repo) return null;
|
||||
const isLoading = loadingRepoIds.has(repo.id ?? "");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={virtualRow.key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -629,7 +751,7 @@ export default function RepositoryTable({
|
||||
{/* Last Mirrored */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
<p className="text-sm">
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
{formatLastSyncTime(repo.lastMirrored ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -639,7 +761,7 @@ export default function RepositoryTable({
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="cursor-help capitalize"
|
||||
>
|
||||
@@ -652,7 +774,7 @@ export default function RepositoryTable({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Badge
|
||||
<Badge
|
||||
className={`capitalize
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
|
||||
repo.status === 'mirrored' || repo.status === 'synced' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 dark:text-green-400' :
|
||||
@@ -660,6 +782,7 @@ export default function RepositoryTable({
|
||||
repo.status === 'failed' ? 'bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400' :
|
||||
repo.status === 'ignored' ? 'bg-gray-500/10 text-gray-600 hover:bg-gray-500/20 dark:text-gray-400' :
|
||||
repo.status === 'skipped' ? 'bg-orange-500/10 text-orange-600 hover:bg-orange-500/20 dark:text-orange-400' :
|
||||
repo.status === 'pending-approval' ? 'bg-amber-500/10 text-amber-600 hover:bg-amber-500/20 dark:text-amber-400' :
|
||||
'bg-muted hover:bg-muted/80'}`}
|
||||
variant="secondary"
|
||||
>
|
||||
@@ -676,6 +799,9 @@ export default function RepositoryTable({
|
||||
onSync={() => onSync({ repoId: repo.id ?? "" })}
|
||||
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
||||
onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })}
|
||||
onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined}
|
||||
onApproveSync={onApproveSync ? () => onApproveSync({ repoId: repo.id ?? "" }) : undefined}
|
||||
onDismissSync={onDismissSync ? () => onDismissSync({ repoId: repo.id ?? "" }) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/* Links */}
|
||||
@@ -739,7 +865,7 @@ export default function RepositoryTable({
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{hasAnyFilter
|
||||
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
|
||||
? `Showing ${visibleRepositories.length} of ${repositories.length} repositories`
|
||||
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -786,6 +912,9 @@ function RepoActionButton({
|
||||
onSync,
|
||||
onRetry,
|
||||
onSkip,
|
||||
onDelete,
|
||||
onApproveSync,
|
||||
onDismissSync,
|
||||
}: {
|
||||
repo: { id: string; status: string };
|
||||
isLoading: boolean;
|
||||
@@ -793,7 +922,37 @@ function RepoActionButton({
|
||||
onSync: () => void;
|
||||
onRetry: () => void;
|
||||
onSkip: (skip: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onApproveSync?: () => void;
|
||||
onDismissSync?: () => void;
|
||||
}) {
|
||||
// For pending-approval repos, show approve/dismiss actions
|
||||
if (repo.status === "pending-approval") {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={onApproveSync}
|
||||
className="min-w-[70px]"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={onDismissSync}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For ignored repos, show an "Include" action
|
||||
if (repo.status === "ignored") {
|
||||
return (
|
||||
@@ -849,7 +1008,7 @@ function RepoActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
// Show primary action with dropdown for skip option
|
||||
// Show primary action with dropdown for additional actions
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className="flex">
|
||||
@@ -886,6 +1045,18 @@ function RepoActionButton({
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Repository
|
||||
</DropdownMenuItem>
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
---
|
||||
import { BASE_PATH_WINDOW_KEY } from '@/lib/base-path';
|
||||
|
||||
const normalizeBasePath = (value) => {
|
||||
if (!value || !value.trim()) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
let normalized = value.trim();
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/, '');
|
||||
return normalized || '/';
|
||||
};
|
||||
|
||||
const runtimeBasePath = normalizeBasePath(process.env.BASE_URL);
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
<script is:inline define:vars={{ BASE_PATH_WINDOW_KEY, runtimeBasePath }}>
|
||||
window[BASE_PATH_WINDOW_KEY] = runtimeBasePath;
|
||||
|
||||
const getThemePreference = () => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// Export empty collections since docs have been moved
|
||||
export const collections = {};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { authApi } from "@/lib/api";
|
||||
import type { ExtendedUser } from "@/types/user";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface AuthContextType {
|
||||
user: ExtendedUser | null;
|
||||
@@ -61,9 +62,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Redirect user based on error
|
||||
if (err?.message === "No users found") {
|
||||
window.location.href = "/signup";
|
||||
window.location.href = withBase("/signup");
|
||||
} else {
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
}
|
||||
console.error("Auth check failed", err);
|
||||
} finally {
|
||||
@@ -111,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
await authApi.logout();
|
||||
setUser(null);
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
} catch (err) {
|
||||
console.error("Logout error:", err);
|
||||
} finally {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
|
||||
import type { Session, AuthUser } from "@/lib/auth-client";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/",
|
||||
callbackURL: withBase("/"),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
email,
|
||||
password,
|
||||
name: username, // Better Auth uses 'name' field for display name
|
||||
callbackURL: "/",
|
||||
callbackURL: withBase("/"),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -140,4 +141,4 @@ export function useAuth() {
|
||||
}
|
||||
|
||||
// Export the Better Auth session hook for direct use when needed
|
||||
export { useBetterAuthSession };
|
||||
export { useBetterAuthSession };
|
||||
@@ -9,6 +9,8 @@ interface ConfigStatus {
|
||||
isFullyConfigured: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
autoMirrorStarred: boolean;
|
||||
githubOwner: string;
|
||||
}
|
||||
|
||||
// Cache to prevent duplicate API calls across components
|
||||
@@ -33,6 +35,8 @@ export function useConfigStatus(): ConfigStatus {
|
||||
isFullyConfigured: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
autoMirrorStarred: false,
|
||||
githubOwner: '',
|
||||
});
|
||||
|
||||
// Track if this hook has already checked config to prevent multiple calls
|
||||
@@ -46,6 +50,8 @@ export function useConfigStatus(): ConfigStatus {
|
||||
isFullyConfigured: false,
|
||||
isLoading: false,
|
||||
error: 'No user found',
|
||||
autoMirrorStarred: false,
|
||||
githubOwner: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -78,6 +84,8 @@ export function useConfigStatus(): ConfigStatus {
|
||||
isFullyConfigured,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
autoMirrorStarred: configResponse?.advancedOptions?.autoMirrorStarred ?? false,
|
||||
githubOwner: configResponse?.githubConfig?.username ?? '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -119,6 +127,8 @@ export function useConfigStatus(): ConfigStatus {
|
||||
isFullyConfigured,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
autoMirrorStarred: configResponse?.advancedOptions?.autoMirrorStarred ?? false,
|
||||
githubOwner: configResponse?.githubConfig?.username ?? '',
|
||||
});
|
||||
|
||||
hasCheckedRef.current = true;
|
||||
@@ -129,6 +139,8 @@ export function useConfigStatus(): ConfigStatus {
|
||||
isFullyConfigured: false,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check configuration',
|
||||
autoMirrorStarred: false,
|
||||
githubOwner: '',
|
||||
});
|
||||
hasCheckedRef.current = true;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const FILTER_KEYS: (keyof FilterParams)[] = [
|
||||
"membershipRole",
|
||||
"owner",
|
||||
"organization",
|
||||
"sort",
|
||||
"type",
|
||||
"name",
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface UseSSEOptions {
|
||||
userId?: string;
|
||||
@@ -41,7 +42,7 @@ export const useSSE = ({
|
||||
}
|
||||
|
||||
// Create new EventSource connection
|
||||
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
|
||||
const eventSource = new EventSource(`${withBase("/api/sse")}?userId=${userId}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "./useAuth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface UseRepoSyncOptions {
|
||||
userId?: string;
|
||||
@@ -51,7 +52,7 @@ export function useRepoSync({
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/job/schedule-sync-repo", {
|
||||
const response = await fetch(withBase("/api/job/schedule-sync-repo"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import '../styles/global.css';
|
||||
import '../styles/docs.css';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
// Accept title as a prop with a default value
|
||||
const { title = 'Gitea Mirror' } = Astro.props;
|
||||
@@ -11,7 +12,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
|
||||
<title>{title}</title>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
// Base API URL
|
||||
const API_BASE = "/api";
|
||||
const API_BASE = withBase("/api");
|
||||
|
||||
// Helper function for API requests
|
||||
async function apiRequest<T>(
|
||||
@@ -78,6 +80,10 @@ export const githubApi = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
getStarredLists: () =>
|
||||
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
|
||||
method: "GET",
|
||||
}),
|
||||
};
|
||||
|
||||
// Gitea API
|
||||
@@ -91,35 +97,17 @@ export const giteaApi = {
|
||||
|
||||
// Health API
|
||||
export interface HealthResponse {
|
||||
status: "ok" | "error";
|
||||
status: "ok" | "error" | "degraded";
|
||||
timestamp: string;
|
||||
version: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
database: {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
};
|
||||
system: {
|
||||
uptime: {
|
||||
startTime: string;
|
||||
uptimeMs: number;
|
||||
formatted: string;
|
||||
};
|
||||
memory: {
|
||||
rss: string;
|
||||
heapTotal: string;
|
||||
heapUsed: string;
|
||||
external: string;
|
||||
systemTotal: string;
|
||||
systemFree: string;
|
||||
};
|
||||
os: {
|
||||
platform: string;
|
||||
version: string;
|
||||
arch: string;
|
||||
};
|
||||
env: string;
|
||||
recovery?: {
|
||||
status: string;
|
||||
jobsNeedingRecovery: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
function normalizeAuthBaseUrl(url: string): string {
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin;
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
|
||||
@@ -18,9 +24,8 @@ export const authClient = createAuthClient({
|
||||
// Validate and clean the URL if provided
|
||||
if (url && typeof url === 'string' && url.trim() !== '') {
|
||||
try {
|
||||
// Validate URL format and remove trailing slash
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin; // Use origin to ensure clean URL without path
|
||||
// Validate URL format and preserve optional base path
|
||||
return normalizeAuthBaseUrl(url);
|
||||
} catch (e) {
|
||||
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
|
||||
}
|
||||
@@ -34,7 +39,7 @@ export const authClient = createAuthClient({
|
||||
// Default for SSR - always return a valid URL
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
basePath: withBase('/api/auth'), // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
ssoClient(),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
const getSessionMock = mock(async () => null);
|
||||
|
||||
mock.module("@/lib/auth", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: getSessionMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { requireAuthenticatedUserId } from "./auth-guards";
|
||||
|
||||
describe("requireAuthenticatedUserId", () => {
|
||||
test("returns user id from locals session without calling auth api", async () => {
|
||||
getSessionMock.mockImplementation(async () => {
|
||||
throw new Error("should not be called");
|
||||
});
|
||||
|
||||
const result = await requireAuthenticatedUserId({
|
||||
request: new Request("http://localhost/test"),
|
||||
locals: {
|
||||
session: { userId: "local-user-id" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect("userId" in result).toBe(true);
|
||||
if ("userId" in result) {
|
||||
expect(result.userId).toBe("local-user-id");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns user id from auth session when locals are empty", async () => {
|
||||
getSessionMock.mockImplementation(async () => ({
|
||||
user: { id: "session-user-id" },
|
||||
session: { id: "session-id" },
|
||||
}));
|
||||
|
||||
const result = await requireAuthenticatedUserId({
|
||||
request: new Request("http://localhost/test"),
|
||||
locals: {} as any,
|
||||
});
|
||||
|
||||
expect("userId" in result).toBe(true);
|
||||
if ("userId" in result) {
|
||||
expect(result.userId).toBe("session-user-id");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns unauthorized response when auth lookup throws", async () => {
|
||||
getSessionMock.mockImplementation(async () => {
|
||||
throw new Error("session provider unavailable");
|
||||
});
|
||||
|
||||
const result = await requireAuthenticatedUserId({
|
||||
request: new Request("http://localhost/test"),
|
||||
locals: {} as any,
|
||||
});
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
if ("response" in result) {
|
||||
expect(result.response.status).toBe(401);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures request is authenticated and returns the authenticated user ID.
|
||||
* Never trust client-provided userId for authorization decisions.
|
||||
*/
|
||||
export async function requireAuthenticatedUserId(
|
||||
context: Pick<APIContext, "request" | "locals">
|
||||
): Promise<{ userId: string } | { response: Response }> {
|
||||
const localUserId =
|
||||
context.locals?.session?.userId || context.locals?.user?.id;
|
||||
|
||||
if (localUserId) {
|
||||
return { userId: localUserId };
|
||||
}
|
||||
|
||||
let session: Awaited<ReturnType<typeof auth.api.getSession>> | null = null;
|
||||
try {
|
||||
session = await auth.api.getSession({
|
||||
headers: context.request.headers,
|
||||
});
|
||||
} catch {
|
||||
return { response: unauthorizedResponse() };
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { response: unauthorizedResponse() };
|
||||
}
|
||||
|
||||
return { userId: session.user.id };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { resolveTrustedOrigins } from "./auth";
|
||||
|
||||
// Helper to create a mock Request with specific headers
|
||||
function mockRequest(headers: Record<string, string>): Request {
|
||||
return new Request("http://localhost:4321/api/auth/sign-in", {
|
||||
headers: new Headers(headers),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveTrustedOrigins", () => {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
// Save and clear relevant env vars
|
||||
for (const key of ["BETTER_AUTH_URL", "BETTER_AUTH_TRUSTED_ORIGINS"]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env vars
|
||||
for (const [key, val] of Object.entries(savedEnv)) {
|
||||
if (val === undefined) delete process.env[key];
|
||||
else process.env[key] = val;
|
||||
}
|
||||
});
|
||||
|
||||
test("includes localhost defaults when called without request", async () => {
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("http://localhost:4321");
|
||||
expect(origins).toContain("http://localhost:8080");
|
||||
});
|
||||
|
||||
test("includes BETTER_AUTH_URL from env", async () => {
|
||||
process.env.BETTER_AUTH_URL = "https://gitea-mirror.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("https://gitea-mirror.example.com");
|
||||
});
|
||||
|
||||
test("includes BETTER_AUTH_TRUSTED_ORIGINS (comma-separated)", async () => {
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://a.example.com, https://b.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("https://a.example.com");
|
||||
expect(origins).toContain("https://b.example.com");
|
||||
});
|
||||
|
||||
test("skips invalid URLs in env vars", async () => {
|
||||
process.env.BETTER_AUTH_URL = "not-a-url";
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "also-invalid, https://valid.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).not.toContain("not-a-url");
|
||||
expect(origins).not.toContain("also-invalid");
|
||||
expect(origins).toContain("https://valid.example.com");
|
||||
});
|
||||
|
||||
test("auto-detects origin from x-forwarded-host + x-forwarded-proto", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea-mirror.mydomain.tld",
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://gitea-mirror.mydomain.tld");
|
||||
});
|
||||
|
||||
test("falls back to host header when x-forwarded-host is absent", async () => {
|
||||
const req = mockRequest({
|
||||
host: "myserver.local:4321",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("http://myserver.local:4321");
|
||||
});
|
||||
|
||||
test("handles multi-value x-forwarded-host (chained proxies)", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "external.example.com, internal.proxy.local",
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://external.example.com");
|
||||
expect(origins).not.toContain("https://internal.proxy.local");
|
||||
});
|
||||
|
||||
test("handles multi-value x-forwarded-proto (chained proxies)", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.example.com",
|
||||
"x-forwarded-proto": "https, http",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://gitea.example.com");
|
||||
// Should NOT create an origin with "https, http" as proto
|
||||
expect(origins).not.toContain("https, http://gitea.example.com");
|
||||
});
|
||||
|
||||
test("rejects invalid x-forwarded-proto values", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.example.com",
|
||||
"x-forwarded-proto": "ftp",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).not.toContain("ftp://gitea.example.com");
|
||||
});
|
||||
|
||||
test("deduplicates origins", async () => {
|
||||
process.env.BETTER_AUTH_URL = "http://localhost:4321";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
const count = origins.filter(o => o === "http://localhost:4321").length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("defaults proto to http when x-forwarded-proto is absent", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.internal",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("http://gitea.internal");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,73 @@ import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBase } from "./base-path";
|
||||
|
||||
/**
|
||||
* Resolves the list of trusted origins for Better Auth CSRF validation.
|
||||
* Exported for testing. Called per-request with the incoming Request,
|
||||
* or at startup with no request (static origins only).
|
||||
*/
|
||||
export async function resolveTrustedOrigins(request?: Request): Promise<string[]> {
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect origin from the incoming request's Host header when running
|
||||
// behind a reverse proxy. Helps with Better Auth's per-request CSRF check.
|
||||
if (request?.headers) {
|
||||
// Take first value only — headers can be comma-separated in chained proxy setups
|
||||
const rawHost = request.headers.get("x-forwarded-host") || request.headers.get("host");
|
||||
const host = rawHost?.split(",")[0].trim();
|
||||
if (host) {
|
||||
const rawProto = request.headers.get("x-forwarded-proto") || "http";
|
||||
const proto = rawProto.split(",")[0].trim().toLowerCase();
|
||||
if (proto === "http" || proto === "https") {
|
||||
try {
|
||||
const detected = new URL(`${proto}://${host}`);
|
||||
origins.push(detected.origin);
|
||||
} catch {
|
||||
// Malformed header, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
if (!request) {
|
||||
console.info("Trusted origins (static):", uniqueOrigins);
|
||||
}
|
||||
return uniqueOrigins;
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Database configuration
|
||||
@@ -31,7 +98,7 @@ export const auth = betterAuth({
|
||||
try {
|
||||
// Validate URL format and ensure it's a proper origin
|
||||
const validatedUrl = new URL(url.trim());
|
||||
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
|
||||
const cleanUrl = validatedUrl.origin;
|
||||
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
||||
return cleanUrl;
|
||||
} catch (e) {
|
||||
@@ -41,50 +108,13 @@ export const auth = betterAuth({
|
||||
return defaultUrl;
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
basePath: withBase("/api/auth"), // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
// This is where users can specify multiple access URLs
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
// Validate each additional origin
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and empty strings, then return
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
console.info('Trusted origins:', uniqueOrigins);
|
||||
return uniqueOrigins;
|
||||
})(),
|
||||
// Trusted origins - this is how we support multiple access URLs.
|
||||
// Uses the function form so that the origin can be auto-detected from
|
||||
// the incoming request's Host / X-Forwarded-* headers, which makes the
|
||||
// app work behind a reverse proxy without manual env var configuration.
|
||||
trustedOrigins: (request?: Request) => resolveTrustedOrigins(request),
|
||||
|
||||
// Authentication methods
|
||||
emailAndPassword: {
|
||||
@@ -121,8 +151,8 @@ export const auth = betterAuth({
|
||||
plugins: [
|
||||
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
||||
oidcProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/oauth/consent",
|
||||
loginPage: withBase("/login"),
|
||||
consentPage: withBase("/oauth/consent"),
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
@@ -166,6 +196,8 @@ export const auth = betterAuth({
|
||||
defaultOverrideUserInfo: true,
|
||||
// Allow implicit sign up for new users
|
||||
disableImplicitSignUp: false,
|
||||
// Trust email_verified claims from the upstream provider so we can link by matching email
|
||||
trustEmailVerified: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
const originalBaseUrl = process.env.BASE_URL;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
async function loadModule(baseUrl?: string, runtimeWindowBasePath?: string) {
|
||||
if (baseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = baseUrl;
|
||||
}
|
||||
|
||||
if (runtimeWindowBasePath === undefined) {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
const restoredWindow = (globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window;
|
||||
if (typeof restoredWindow === "object" && restoredWindow !== null) {
|
||||
delete restoredWindow.__GITEA_MIRROR_BASE_PATH__;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window = {
|
||||
__GITEA_MIRROR_BASE_PATH__: runtimeWindowBasePath,
|
||||
};
|
||||
}
|
||||
|
||||
return import(`./base-path.ts?case=${encodeURIComponent(baseUrl ?? "default")}-${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBaseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = originalBaseUrl;
|
||||
}
|
||||
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
describe("base-path helpers", () => {
|
||||
test("defaults to root paths", async () => {
|
||||
const mod = await loadModule(undefined);
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/");
|
||||
expect(mod.withBase("/api/health")).toBe("/api/health");
|
||||
expect(mod.withBase("repositories")).toBe("/repositories");
|
||||
expect(mod.stripBasePath("/config")).toBe("/config");
|
||||
});
|
||||
|
||||
test("normalizes prefixed base paths", async () => {
|
||||
const mod = await loadModule("mirror/");
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/mirror");
|
||||
expect(mod.withBase("/api/health")).toBe("/mirror/api/health");
|
||||
expect(mod.withBase("repositories")).toBe("/mirror/repositories");
|
||||
expect(mod.stripBasePath("/mirror/config")).toBe("/config");
|
||||
expect(mod.stripBasePath("/mirror")).toBe("/");
|
||||
});
|
||||
|
||||
test("keeps absolute URLs unchanged", async () => {
|
||||
const mod = await loadModule("/mirror");
|
||||
|
||||
expect(mod.withBase("https://example.com/path")).toBe("https://example.com/path");
|
||||
});
|
||||
|
||||
test("uses browser runtime base path when process env is unset", async () => {
|
||||
const mod = await loadModule(undefined, "/runtime");
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/runtime");
|
||||
expect(mod.withBase("/api/health")).toBe("/runtime/api/health");
|
||||
expect(mod.stripBasePath("/runtime/config")).toBe("/config");
|
||||
});
|
||||
|
||||
test("prefers process env base path over browser runtime value", async () => {
|
||||
const mod = await loadModule("/env", "/runtime");
|
||||
|
||||
expect(mod.BASE_PATH).toBe("/env");
|
||||
expect(mod.withBase("/api/health")).toBe("/env/api/health");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
|
||||
const BASE_PATH_WINDOW_KEY = "__GITEA_MIRROR_BASE_PATH__";
|
||||
|
||||
function normalizeBasePath(basePath: string | null | undefined): string {
|
||||
if (!basePath) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
let normalized = basePath.trim();
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
return normalized || "/";
|
||||
}
|
||||
|
||||
function resolveRuntimeBasePath(): string {
|
||||
if (typeof process !== "undefined" && typeof process.env?.BASE_URL === "string") {
|
||||
return normalizeBasePath(process.env.BASE_URL);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const runtimeBasePath = (window as Window & { [BASE_PATH_WINDOW_KEY]?: string })[BASE_PATH_WINDOW_KEY];
|
||||
if (typeof runtimeBasePath === "string") {
|
||||
return normalizeBasePath(runtimeBasePath);
|
||||
}
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
export function getBasePath(): string {
|
||||
return resolveRuntimeBasePath();
|
||||
}
|
||||
|
||||
export const BASE_PATH = getBasePath();
|
||||
export { BASE_PATH_WINDOW_KEY };
|
||||
|
||||
export function withBase(path: string): string {
|
||||
const basePath = getBasePath();
|
||||
|
||||
if (!path) {
|
||||
return basePath === "/" ? "/" : `${basePath}/`;
|
||||
}
|
||||
|
||||
if (URL_SCHEME_REGEX.test(path) || path.startsWith("//")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
if (basePath === "/") {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return `${basePath}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function stripBasePath(pathname: string): string {
|
||||
const basePath = getBasePath();
|
||||
|
||||
if (!pathname) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (basePath === "/") {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
if (pathname === basePath || pathname === `${basePath}/`) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (pathname.startsWith(`${basePath}/`)) {
|
||||
return pathname.slice(basePath.length) || "/";
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||