Compare commits

...

32 Commits

Author SHA1 Message Date
ARUNAVO RAY
5d2462e5a0 feat: add notification system with Ntfy.sh and Apprise support (#238)
* feat: add notification system with Ntfy.sh and Apprise providers (#231)

Add push notification support for mirror job events with two providers:

- Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support
- Apprise API: aggregator gateway supporting 100+ notification services

Includes database migration (0010), settings UI tab, test endpoint,
auto-save integration, token encryption, and comprehensive tests.
Notifications are fire-and-forget and never block the mirror flow.

* fix: address review findings for notification system

- Fix silent catch in GET handler that returned ciphertext to UI,
  causing double-encryption on next save. Now clears token to ""
  on decryption failure instead.
- Add Zod schema validation to test notification endpoint, following
  project API route pattern guidelines.
- Mark notifyOnNewRepo toggle as "coming soon" with disabled state,
  since the backend doesn't yet emit new_repo events. The schema
  and type support is in place for when it's implemented.

* fix notification gating and config validation

* trim sync notification details
2026-03-18 18:36:51 +05:30
ARUNAVO RAY
0000a03ad6 fix: improve reverse proxy support for subdomain deployments (#237)
* fix: improve reverse proxy support for subdomain deployments (#63)

- Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx
  from buffering the event stream
- Auto-detect trusted origin from Host/X-Forwarded-* request headers
  so the app works behind a proxy without manual env var configuration
- Add prominent reverse proxy documentation to advanced docs page
  explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and
  BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments
- Add reverse proxy env var comments and entries to both
  docker-compose.yml and docker-compose.alt.yml
- Add dedicated reverse proxy configuration section to .env.example

* fix: address review findings for reverse proxy origin detection

- Fix x-forwarded-proto multi-value handling: take first value only
  and validate it is "http" or "https" before using
- Update comment to accurately describe auto-detection scope: helps
  with per-request CSRF checks but not callback URL validation
- Restore startup logging of static trusted origins for debugging

* fix: handle multi-value x-forwarded-host in chained proxy setups

x-forwarded-host can be comma-separated (e.g. "proxy1.example.com,
proxy2.example.com") in chained proxy setups. Take only the first
value, matching the same handling already applied to x-forwarded-proto.

* test: add unit tests for reverse proxy origin detection

Extract resolveTrustedOrigins into a testable exported function and
add 11 tests covering:
- Default localhost origins
- BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars
- Invalid URL handling
- Auto-detection from x-forwarded-host + x-forwarded-proto
- Multi-value header handling (chained proxy setups)
- Invalid proto rejection (only http/https allowed)
- Deduplication
- Fallback to host header when x-forwarded-host absent
2026-03-18 15:47:15 +05:30
ARUNAVO RAY
d697cb2bc9 fix: prevent starred repo name collisions during concurrent mirroring (#236)
* fix: prevent starred repo name collisions during concurrent mirroring (#95)

When multiple starred repos share the same short name (e.g. alice/dotfiles
and bob/dotfiles), concurrent batch mirroring could cause 409 Conflict
errors because generateUniqueRepoName only checked Gitea via HTTP, missing
repos that were claimed in the local DB but not yet created remotely.

Three fixes:
- Add DB-level check in generateUniqueRepoName so it queries the local
  repositories table for existing mirroredLocation claims, preventing two
  concurrent jobs from picking the same target name.
- Clear mirroredLocation on failed mirror so a failed repo doesn't falsely
  hold a location that was never successfully created, which would block
  retries and confuse the uniqueness check.
- Extract isMirroredLocationClaimedInDb helper for the DB lookup, using
  ne() to exclude the current repo's own record from the collision check.

* fix: address review findings for starred repo name collision fix

- Make generateUniqueRepoName immediately claim name by writing
  mirroredLocation to DB, closing the TOCTOU race window between
  name selection and the later status="mirroring" DB update
- Add fullName validation guard (must contain "/")
- Make isMirroredLocationClaimedInDb fail-closed (return true on
  DB error) to be conservative about preventing collisions
- Scope mirroredLocation clear on failure to starred repos only,
  preserving it for non-starred repos that may have partially
  created in Gitea and need the location for recovery

* fix: address P1/P2 review findings for starred repo name collision

P1a: Remove early name claiming from generateUniqueRepoName to prevent
stale claims on early return paths. The function now only checks
availability — the actual claim happens at the status="mirroring" DB
write (after both idempotency checks), which is protected by a new
unique partial index.

P1b: Add unique partial index on (userId, mirroredLocation) WHERE
mirroredLocation != '' via migration 0010. This enforces atomicity at
the DB level: if two concurrent workers try to claim the same name,
the second gets a constraint violation rather than silently colliding.

P2: Only clear mirroredLocation on failure if the Gitea migrate call
itself failed (migrateSucceeded flag). If migrate succeeded but
metadata mirroring failed, preserve the location since the repo
physically exists in Gitea and we need it for recovery/retry.
2026-03-18 15:27:20 +05:30
ARUNAVO RAY
ddd071f7e5 fix: prevent excessive disk usage from repo backups (#235)
* fix: prevent excessive disk usage from repo backups (#234)

Legacy configs with backupBeforeSync: true but no explicit backupStrategy
silently resolved to "always", creating full git bundles on every sync
cycle. This caused repo-backups to grow to 17GB+ for users with many
repositories.

Changes:
- Fix resolveBackupStrategy to map backupBeforeSync: true → "on-force-push"
  instead of "always", so legacy configs only backup when force-push is detected
- Fix config mapper to always set backupStrategy explicitly ("on-force-push")
  preventing the backward-compat fallback from triggering
- Lower default backupRetentionCount from 20 to 5 bundles per repo
- Add time-based retention (backupRetentionDays, default 30 days) alongside
  count-based retention, with safety net to always keep at least 1 bundle
- Add "high disk usage" warning on "Always Backup" UI option
- Update docs and tests to reflect new defaults and behavior

* fix: preserve legacy backupBeforeSync:false on UI round-trip and expose retention days

P1: mapDbToUiConfig now checks backupBeforeSync === false before
defaulting backupStrategy, preventing legacy "disabled" configs from
silently becoming "on-force-push" after any auto-save round-trip.

P3: Added "Snapshot retention days" input field to the backup settings
UI, matching the documented setting in FORCE_PUSH_PROTECTION.md.
2026-03-18 15:05:00 +05:30
Arunavo Ray
4629ab4335 chore: bump version to 3.13.3 2026-03-18 05:20:21 +05:30
Arunavo Ray
0f303c4b79 nix: regenerate bun.nix 2026-03-18 04:47:16 +05:30
ARUNAVO RAY
7c7c259d0a fix repo links to use external gitea url (#233) 2026-03-18 04:36:14 +05:30
Arunavo Ray
fe6bcc5288 chore: bump version to 3.13.2 2026-03-15 14:11:22 +05:30
ARUNAVO RAY
e26ed3aa9c fix: rewrite migration 0009 for SQLite compatibility and add migration validation (#230)
SQLite rejects ALTER TABLE ADD COLUMN with expression defaults like
DEFAULT (unixepoch()), which Drizzle-kit generated for the imported_at
column. This broke upgrades from v3.12.x to v3.13.0 (#228, #229).

Changes:
- Rewrite migration 0009 using table-recreation pattern (CREATE, INSERT
  SELECT, DROP, RENAME) instead of ALTER TABLE
- Add migration validation script with SQLite-specific lint rules that
  catch known invalid patterns before they ship
- Add upgrade-path testing with seeded data and verification fixtures
- Add runtime repair for users whose migration record may be stale
- Add explicit migration validation step to CI workflow

Fixes #228
Fixes #229
2026-03-15 14:10:06 +05:30
Arunavo Ray
efb96b6e60 chore: bump version to 3.13.1 2026-03-15 09:54:44 +05:30
Arunavo Ray
342cafed0e fix: force Go 1.25.8 toolchain and update x/crypto for git-lfs build
The git-lfs go.mod contains a `toolchain go1.25.3` directive which
causes Go to auto-download and use Go 1.25.3 instead of our installed
1.25.8. Set GOTOOLCHAIN=local to force using the installed version.

Also update golang.org/x/crypto to latest before building to resolve
CVE-2025-47913 (needs >= 0.43.0, was pinned at 0.36.0).
2026-03-15 09:35:50 +05:30
Arunavo Ray
fc7c6b59d7 docs: update README to reference Gitea/Forgejo as supported targets 2026-03-15 09:33:41 +05:30
Arunavo Ray
a77ec0447a chore: bump version to 3.13.0 2026-03-15 09:28:01 +05:30
Arunavo Ray
82b5ac8160 fix: build git-lfs from source with Go 1.25.8 to resolve remaining CVEs
Git-lfs v3.7.1 pre-built binaries use Go 1.25.3, which is affected by
CVE-2025-68121 (critical), CVE-2026-27142, CVE-2026-25679, CVE-2025-61729,
CVE-2025-61726, and CVE-2025-47913 (golang.org/x/crypto).

Since no newer git-lfs release exists, compile from source in a dedicated
build stage using Go 1.25.8 (latest patched release). Only the final
binary is copied into the runner image.
2026-03-15 09:22:50 +05:30
ARUNAVO RAY
299659eca2 fix: resolve CVEs, upgrade to Astro v6, and harden API security (#227)
* fix: resolve CVEs, upgrade to Astro v6, and harden API security

Docker image CVE fixes:
- Install git-lfs v3.7.1 from GitHub releases (Go 1.25) instead of
  Debian apt (Go 1.23.12), fixing CVE-2025-68121 and 8 other Go stdlib CVEs
- Strip build-only packages (esbuild, vite, rollup, svgo, tailwindcss)
  from production image, eliminating 9 esbuild Go stdlib CVEs

Dependency upgrades:
- Astro v5 → v6 (includes Vite 7, Zod 4)
- Remove legacy content config (src/content/config.ts)
- Update HealthResponse type for simplified health endpoint
- npm overrides for fast-xml-parser ≥5.3.6, devalue ≥5.6.2,
  node-forge ≥1.3.2, svgo ≥4.0.1, rollup ≥4.59.0

API security hardening:
- /api/auth/debug: dev-only, require auth, remove user-creation POST,
  strip trustedOrigins/databaseConfig from response
- /api/auth/check-users: return boolean hasUsers instead of exact count
- /api/cleanup/auto: require authentication, remove per-user details
- /api/health: remove OS version, memory, uptime from response
- /api/config: validate Gitea URL protocol (http/https only)
- BETTER_AUTH_SECRET: log security warning when using insecure defaults
- generateRandomString: replace Math.random() with crypto.getRandomValues()
- hashValue: add random salt and timing-safe verification

* repositories: migrate table to tanstack

* Revert "repositories: migrate table to tanstack"

This reverts commit a544b29e6d.

* fixed lock file
2026-03-15 09:19:24 +05:30
ARUNAVO RAY
6f53a3ed41 feat: add importedAt-based repository sorting (#226)
* repositories: add importedAt sorting

* repositories: use tanstack table for repo list
2026-03-15 08:52:45 +05:30
ARUNAVO RAY
1bca7df5ab feat: import repo topics and description into Gitea (#224)
* lib: sync repo topics and descriptions

* lib: harden metadata sync for existing repos
2026-03-15 08:22:44 +05:30
Arunavo Ray
b5210c3916 updated packages 2026-03-15 07:52:34 +05:30
ARUNAVO RAY
755647e29c scripts: add startup repair progress logs (#223) 2026-03-14 17:44:52 +05:30
dependabot[bot]
018c9d1a23 build(deps): bump devalue (#220) 2026-03-13 00:17:30 +05:30
Arunavo Ray
c89011819f chore: sync version to 3.12.5 2026-03-07 07:00:30 +05:30
ARUNAVO RAY
c00d48199b fix: gracefully handle SAML-protected orgs during GitHub import (#217) (#218) 2026-03-07 06:57:28 +05:30
ARUNAVO RAY
de28469210 nix: refresh bun deps and ci flake trust (#216) 2026-03-06 12:31:51 +05:30
github-actions[bot]
0e2f83fee0 chore: sync version to 3.12.4 2026-03-06 05:10:04 +00:00
ARUNAVO RAY
1dd3dea231 fix preserve strategy fork owner routing (#215) 2026-03-06 10:15:47 +05:30
Arunavo Ray
db783c4225 nix: reduce bun install CI stalls 2026-03-06 09:41:22 +05:30
github-actions[bot]
8a4716bdbd chore: sync version to 3.12.3 2026-03-06 03:35:40 +00:00
Arunavo Ray
9d37966c10 ci: only run nix flake check when nix files change 2026-03-06 09:03:32 +05:30
Arunavo Ray
ac16ae56ea ci: increase workflow timeouts to 25m and upgrade CodeQL Action to v4 2026-03-06 08:55:11 +05:30
Arunavo Ray
df3e665978 fix: bump Bun to 1.3.10 and harden startup for non-AVX CPUs (#213)
Bun 1.3.9 crashes with a segfault on CPUs without AVX support due to a
WASM IPInt bug (oven-sh/bun#27340), fixed in 1.3.10 via oven-sh/bun#26922.

- Bump Bun from 1.3.9 to 1.3.10 in Dockerfile, CI workflows, and packageManager
- Skip env config script when no GitHub/Gitea env vars are set
- Make startup scripts (env-config, recovery, repair) fault-tolerant so
  a crash in a non-critical script doesn't abort the entrypoint via set -e
2026-03-06 08:19:44 +05:30
github-actions[bot]
8a26764d2c chore: sync version to 3.12.2 2026-03-05 04:34:51 +00:00
ARUNAVO RAY
ce365a706e ci: persist release version to main (#212) 2026-03-05 09:55:59 +05:30
85 changed files with 9691 additions and 3099 deletions

View File

@@ -18,9 +18,26 @@ 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
#
# 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)
# ===========================================

View File

@@ -45,6 +45,7 @@ This workflow builds Docker images on pushes and pull requests, and pushes to Gi
- 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`)

View File

@@ -24,7 +24,7 @@ jobs:
build-and-test:
name: Build and Test Astro Project
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 25
steps:
- name: Checkout repository
@@ -33,7 +33,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: '1.3.6'
bun-version: '1.3.10'
- name: Check lockfile and install dependencies
run: |
@@ -48,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

View File

@@ -36,7 +36,7 @@ env:
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 25
permissions:
contents: write
@@ -253,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}"

View File

@@ -40,13 +40,13 @@ env:
FAKE_GITHUB_PORT: 4580
GIT_SERVER_PORT: 4590
APP_PORT: 4321
BUN_VERSION: "1.3.6"
BUN_VERSION: "1.3.10"
jobs:
e2e-tests:
name: E2E Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 25
steps:
- name: Checkout repository

View File

@@ -21,7 +21,7 @@ jobs:
yamllint:
name: Lint YAML
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -36,7 +36,7 @@ jobs:
helm-template:
name: Helm lint & template
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Setup Helm

View File

@@ -5,18 +5,18 @@ on:
branches: [main, nix]
tags:
- 'v*'
paths-ignore:
- 'README.md'
- 'docs/**'
- 'www/**'
- 'helm/**'
paths:
- 'flake.nix'
- 'flake.lock'
- 'bun.nix'
- '.github/workflows/nix-build.yml'
pull_request:
branches: [main]
paths-ignore:
- 'README.md'
- 'docs/**'
- 'www/**'
- 'helm/**'
paths:
- 'flake.nix'
- 'flake.lock'
- 'bun.nix'
- '.github/workflows/nix-build.yml'
permissions:
contents: read
@@ -24,7 +24,11 @@ permissions:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 45
env:
NIX_CONFIG: |
accept-flake-config = true
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
@@ -36,11 +40,11 @@ jobs:
uses: DeterminateSystems/magic-nix-cache-action@main
- name: Check flake
run: nix flake check
run: nix flake check --accept-flake-config
- name: Show flake info
run: nix flake show
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
run: nix build --print-build-logs --accept-flake-config

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.3.9-debian AS base
FROM oven/bun:1.3.10-debian AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
@@ -26,18 +26,49 @@ COPY bun.lock* ./
RUN bun install --production --omit=peer --frozen-lockfile
# ----------------------------
FROM oven/bun:1.3.9-debian 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 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 install -y --no-install-recommends \
git git-lfs wget sqlite3 openssl ca-certificates \
&& git lfs install \
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/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

View File

@@ -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
@@ -199,12 +199,12 @@ 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**
@@ -217,13 +217,13 @@ bun run dev
### 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
@@ -243,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!
@@ -254,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
@@ -284,16 +284,16 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
- **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 instance takes a long time to complete migrations/imports, aggressive schedules can cause repeated retries and duplicate-looking mirror attempts.
- **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
@@ -309,7 +309,7 @@ For existing pull-mirror repositories, changing the GitHub token in Gitea Mirror
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 from Gitea Mirror so it is recreated with current credentials.
2. Or delete and re-mirror the repository so it is recreated with current credentials.
### Re-sync Metadata After Changing Mirror Options
@@ -334,7 +334,7 @@ If your Gitea/Forgejo server has `mirror.MIN_INTERVAL` set to a higher value (fo
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, because Gitea Mirror will overwrite them on sync.
2. Do not rely on manual per-repository mirror interval edits in Gitea/Forgejo, as they will be overwritten on sync.
## Development
@@ -356,13 +356,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
@@ -456,13 +456,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:
@@ -476,7 +476,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

1125
bun.lock

File diff suppressed because it is too large Load Diff

2318
bun.nix

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,10 @@ 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
# === CORE SETTINGS ===
# These are technically required but have working defaults

View File

@@ -32,6 +32,13 @@ services:
- PORT=4321
- 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
- 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

View File

@@ -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

View File

@@ -328,6 +328,7 @@ git push origin vX.Y.Z
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

View File

@@ -78,7 +78,11 @@ 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: **20**.
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
@@ -96,7 +100,7 @@ The old `backupBeforeSync` boolean is still recognized:
| Old Setting | New Equivalent |
|---|---|
| `backupBeforeSync: true` | `backupStrategy: "always"` |
| `backupBeforeSync: true` | `backupStrategy: "on-force-push"` |
| `backupBeforeSync: false` | `backupStrategy: "disabled"` |
| Neither set | `backupStrategy: "on-force-push"` (new default) |

88
docs/NOTIFICATIONS.md Normal file
View File

@@ -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

View File

@@ -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`);

View File

@@ -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` != '';

View File

@@ -0,0 +1 @@
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,27 @@
"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
}
]
}
}

View File

@@ -49,6 +49,20 @@
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.

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.10.1",
"version": "3.13.3",
"engines": {
"bun": ">=1.2.9"
},
@@ -34,6 +34,7 @@
"start": "bun dist/server/entry.mjs",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
"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",
@@ -44,14 +45,18 @@
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"devalue": "^5.5.0"
"devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5",
"node-forge": "^1.3.3",
"svgo": "^4.0.1",
"rollup": ">=4.59.0"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/mdx": "4.3.13",
"@astrojs/node": "9.5.4",
"@astrojs/react": "^4.4.2",
"@better-auth/sso": "1.4.19",
"@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",
@@ -73,13 +78,14 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@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.14",
"@types/react-dom": "^19.2.3",
"astro": "^5.18.0",
"astro": "^6.0.4",
"bcryptjs": "^3.0.3",
"better-auth": "1.4.19",
"better-auth": "1.5.5",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -89,8 +95,8 @@
"drizzle-orm": "^0.45.1",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.575.0",
"nanoid": "^3.3.11",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -109,15 +115,15 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.9",
"@types/bun": "^1.3.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.2",
"@types/node": "^25.5.0",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "^0.31.9",
"jsdom": "^28.1.0",
"tsx": "^4.21.0",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
},
"packageManager": "bun@1.3.3"
"packageManager": "bun@1.3.10"
}

View File

@@ -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("");

View File

@@ -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);
}

View File

@@ -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';
@@ -30,6 +32,7 @@ type ConfigState = {
cleanupConfig: DatabaseCleanupConfig;
mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions;
notificationConfig: NotificationConfig;
};
export function ConfigTabs() {
@@ -51,7 +54,8 @@ export function ConfigTabs() {
starredReposMode: 'dedicated-org',
preserveOrgStructure: false,
backupStrategy: "on-force-push",
backupRetentionCount: 20,
backupRetentionCount: 5,
backupRetentionDays: 30,
backupDirectory: 'data/repo-backups',
blockSyncOnBackupFailure: true,
},
@@ -85,6 +89,13 @@ export function ConfigTabs() {
starredCodeOnly: false,
autoMirrorStarred: false,
},
notificationConfig: {
enabled: false,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: false,
notifyOnNewRepo: false,
},
});
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true);
@@ -94,10 +105,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;
@@ -124,19 +137,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: ${
@@ -447,6 +472,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('/api/config', {
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 () => {
@@ -462,6 +536,9 @@ export function ConfigTabs() {
if (autoSaveGiteaTimeoutRef.current) {
clearTimeout(autoSaveGiteaTimeoutRef.current);
}
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
};
}, []);
@@ -493,6 +570,8 @@ export function ConfigTabs() {
},
advancedOptions:
response.advancedOptions || config.advancedOptions,
notificationConfig:
(response as any).notificationConfig || config.notificationConfig,
});
}
@@ -622,9 +701,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>
@@ -712,6 +792,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>

View File

@@ -234,7 +234,7 @@ export function GitHubConfigForm({
{
value: "always",
label: "Always Backup",
desc: "Snapshot before every sync",
desc: "Snapshot before every sync (high disk usage)",
},
{
value: "on-force-push",
@@ -272,7 +272,7 @@ export function GitHubConfigForm({
{(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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
@@ -282,11 +282,11 @@ export function GitHubConfigForm({
name="backupRetentionCount"
type="number"
min={1}
value={giteaConfig.backupRetentionCount ?? 20}
value={giteaConfig.backupRetentionCount ?? 5}
onChange={(e) => {
const newConfig = {
...giteaConfig,
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 20),
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
};
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
@@ -294,6 +294,28 @@ export function GitHubConfigForm({
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

View File

@@ -0,0 +1,394 @@
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";
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("/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>
);
}

View File

@@ -4,6 +4,7 @@ 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";
interface RepositoryListProps {
@@ -15,11 +16,6 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
// Helper function to construct Gitea repository URL
const getGiteaRepoUrl = (repository: Repository): string | null => {
const rawBaseUrl = giteaConfig?.externalUrl || giteaConfig?.url;
if (!rawBaseUrl) {
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)) {
@@ -38,12 +34,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
repoPath = `${owner}/${repository.name}`;
}
// Ensure the base URL doesn't have a trailing slash
const baseUrl = rawBaseUrl.endsWith("/")
? rawBaseUrl.slice(0, -1)
: rawBaseUrl;
return `${baseUrl}/${repoPath}`;
return buildGiteaWebUrl(giteaConfig, repoPath);
};
return (

View File

@@ -9,6 +9,7 @@ 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 {
@@ -67,11 +68,6 @@ export function OrganizationList({
// Helper function to construct Gitea organization URL
const getGiteaOrgUrl = (organization: Organization): string | null => {
const rawBaseUrl = giteaConfig?.externalUrl || giteaConfig?.url;
if (!rawBaseUrl) {
return null;
}
// Only provide Gitea links for organizations that have been mirrored
const validStatuses = ['mirroring', 'mirrored'];
if (!validStatuses.includes(organization.status || '')) {
@@ -84,12 +80,7 @@ export function OrganizationList({
return null;
}
// Ensure the base URL doesn't have a trailing slash
const baseUrl = rawBaseUrl.endsWith("/")
? rawBaseUrl.slice(0, -1)
: rawBaseUrl;
return `${baseUrl}/${orgName}`;
return buildGiteaWebUrl(giteaConfig, orgName);
};
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
@@ -248,6 +239,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
@@ -304,6 +300,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">
@@ -313,7 +316,7 @@ export function OrganizationList({
{org.repositoryCount === 1 ? "repository" : "repositories"}
</span>
</div>
{/* Repository breakdown - only show non-zero counts */}
{(() => {
const counts = [];
@@ -326,7 +329,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) => (
@@ -415,7 +418,7 @@ export function OrganizationList({
)}
</>
)}
{/* Dropdown menu for additional actions */}
{org.status !== "mirroring" && (
<DropdownMenu>
@@ -426,7 +429,7 @@ export function OrganizationList({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{org.status !== "ignored" && (
<DropdownMenuItem
<DropdownMenuItem
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
>
<Ban className="h-4 w-4 mr-2" />
@@ -449,7 +452,7 @@ export function OrganizationList({
</DropdownMenu>
)}
</div>
<div className="flex items-center gap-2 justify-center">
{(() => {
const giteaUrl = getGiteaOrgUrl(org);

View File

@@ -51,6 +51,15 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
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);
@@ -63,6 +72,7 @@ export default function Repository() {
status: "",
organization: "",
owner: "",
sort: "imported-desc",
});
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
@@ -999,6 +1009,7 @@ export default function Repository() {
status: "",
organization: "",
owner: "",
sort: filter.sort || "imported-desc",
});
};
@@ -1139,6 +1150,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">
@@ -1241,6 +1279,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"

View File

@@ -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, 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";
@@ -46,6 +55,30 @@ interface RepositoryTableProps {
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({
repositories,
isLoading,
@@ -92,10 +125,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)) {
@@ -112,48 +141,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,
@@ -162,7 +235,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());
@@ -179,8 +256,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
@@ -235,7 +313,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' :
@@ -250,7 +328,7 @@ export default function RepositoryTable({
{repo.status}
</Badge>
<span className="text-xs text-muted-foreground">
{formatLastSyncTime(repo.lastMirrored)}
{formatLastSyncTime(repo.lastMirrored ?? null)}
</span>
</div>
</div>
@@ -379,7 +457,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>
@@ -510,7 +588,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"
@@ -521,6 +599,7 @@ export default function RepositoryTable({
status: "",
organization: "",
owner: "",
sort: filter.sort || "imported-desc",
})
}
>
@@ -529,7 +608,7 @@ export default function RepositoryTable({
</div>
)}
{filteredRepositories.length === 0 ? (
{visibleRepositories.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">
{hasAnyFilter
@@ -550,12 +629,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>
@@ -601,13 +680,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",
@@ -670,7 +750,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>
@@ -680,7 +760,7 @@ export default function RepositoryTable({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
<Badge
variant="destructive"
className="cursor-help capitalize"
>
@@ -693,7 +773,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' :
@@ -784,7 +864,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>

View File

@@ -1,4 +0,0 @@
import { defineCollection, z } from 'astro:content';
// Export empty collections since docs have been moved
export const collections = {};

View File

@@ -7,6 +7,7 @@ const FILTER_KEYS: (keyof FilterParams)[] = [
"membershipRole",
"owner",
"organization",
"sort",
"type",
"name",
];

View File

@@ -91,35 +91,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;
}

View File

@@ -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");
});
});

View File

@@ -6,6 +6,72 @@ import { db, users } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
/**
* 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
database: drizzleAdapter(db, {
@@ -43,48 +109,11 @@ export const auth = betterAuth({
})(),
basePath: "/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: {

View File

@@ -19,8 +19,23 @@ export const ENV = {
},
// Better Auth secret for authentication
BETTER_AUTH_SECRET:
process.env.BETTER_AUTH_SECRET || "your-secret-key-change-this-in-production",
get BETTER_AUTH_SECRET(): string {
const secret = process.env.BETTER_AUTH_SECRET;
const knownInsecureDefaults = [
"your-secret-key-change-this-in-production",
"dev-only-insecure-secret-do-not-use-in-production",
];
if (!secret || knownInsecureDefaults.includes(secret)) {
if (process.env.NODE_ENV === "production") {
console.error(
"\x1b[31m[SECURITY WARNING]\x1b[0m BETTER_AUTH_SECRET is missing or using an insecure default. " +
"Set a strong secret: openssl rand -base64 32"
);
}
return secret || "dev-only-insecure-secret-do-not-use-in-production";
}
return secret;
},
// Server host and port
HOST: process.env.HOST || "localhost",

View File

@@ -35,13 +35,54 @@ if (process.env.NODE_ENV !== "test") {
// Create drizzle instance with the SQLite client
db = drizzle({ client: sqlite });
/**
* Fix migration records that were marked as applied but whose DDL actually
* failed (e.g. the v3.13.0 release where ALTER TABLE with expression default
* was rejected by SQLite). Without this, Drizzle skips the migration on
* retry because it thinks it already ran.
*
* Drizzle tracks migrations by `created_at` (= journal timestamp) and only
* looks at the most recent record. If the last recorded timestamp is >= the
* failed migration's timestamp but the expected column is missing, we delete
* stale records so the migration re-runs.
*/
function repairFailedMigrations() {
try {
const migrationsTableExists = sqlite
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
.get();
if (!migrationsTableExists) return;
// Migration 0009 journal timestamp (from drizzle/meta/_journal.json)
const MIGRATION_0009_TIMESTAMP = 1773542995732;
const lastMigration = sqlite
.query("SELECT id, created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1")
.get() as { id: number; created_at: number } | null;
if (!lastMigration || Number(lastMigration.created_at) < MIGRATION_0009_TIMESTAMP) return;
// Migration 0009 is recorded as applied — verify the column actually exists
const columns = sqlite.query("PRAGMA table_info(repositories)").all() as { name: string }[];
const hasImportedAt = columns.some((c) => c.name === "imported_at");
if (!hasImportedAt) {
console.log("🔧 Detected failed migration 0009 (imported_at column missing). Removing stale record so it can re-run...");
sqlite.prepare("DELETE FROM __drizzle_migrations WHERE created_at >= ?").run(MIGRATION_0009_TIMESTAMP);
}
} catch (error) {
console.warn("⚠️ Migration repair check failed (non-fatal):", error);
}
}
/**
* Run Drizzle migrations
*/
function runDrizzleMigrations() {
try {
console.log("🔄 Checking for pending migrations...");
// Check if migrations table exists
const migrationsTableExists = sqlite
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
@@ -51,9 +92,12 @@ if (process.env.NODE_ENV !== "test") {
console.log("📦 First time setup - running initial migrations...");
}
// Fix any migrations that were recorded but actually failed (e.g. v3.13.0 bug)
repairFailedMigrations();
// Run migrations using Drizzle migrate function
migrate(db, { migrationsFolder: "./drizzle" });
console.log("✅ Database migrations completed successfully");
} catch (error) {
console.error("❌ Error running migrations:", error);

View File

@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
function decodeOutput(output: ArrayBufferLike | Uint8Array | null | undefined) {
if (!output) {
return "";
}
return Buffer.from(output as ArrayBufferLike).toString("utf8");
}
test("migration validation script passes", () => {
const result = Bun.spawnSync({
cmd: ["bun", "scripts/validate-migrations.ts"],
cwd: process.cwd(),
stdout: "pipe",
stderr: "pipe",
});
const stdout = decodeOutput(result.stdout);
const stderr = decodeOutput(result.stderr);
expect(
result.exitCode,
`Migration validation script failed.\nstdout:\n${stdout}\nstderr:\n${stderr}`,
).toBe(0);
});

View File

@@ -75,7 +75,8 @@ export const giteaConfigSchema = z.object({
mirrorMilestones: z.boolean().default(false),
backupStrategy: backupStrategyEnum.default("on-force-push"),
backupBeforeSync: z.boolean().default(true), // Deprecated: kept for backward compat, use backupStrategy
backupRetentionCount: z.number().int().min(1).default(20),
backupRetentionCount: z.number().int().min(1).default(5),
backupRetentionDays: z.number().int().min(0).default(30),
backupDirectory: z.string().optional(),
blockSyncOnBackupFailure: z.boolean().default(true),
});
@@ -121,6 +122,31 @@ export const cleanupConfigSchema = z.object({
nextRun: z.coerce.date().optional(),
});
export const ntfyConfigSchema = z.object({
url: z.string().default("https://ntfy.sh"),
topic: z.string().default(""),
token: z.string().optional(),
priority: z.enum(["min", "low", "default", "high", "urgent"]).default("default"),
});
export const appriseConfigSchema = z.object({
url: z.string().default(""),
token: z.string().default(""),
tag: z.string().optional(),
});
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
});
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
export const configSchema = z.object({
id: z.string(),
userId: z.string(),
@@ -181,6 +207,7 @@ export const repositorySchema = z.object({
errorMessage: z.string().optional().nullable(),
destinationOrg: z.string().optional().nullable(),
metadata: z.string().optional().nullable(), // JSON string for metadata sync state
importedAt: z.coerce.date(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
@@ -335,6 +362,11 @@ export const configs = sqliteTable("configs", {
.$type<z.infer<typeof cleanupConfigSchema>>()
.notNull(),
notificationConfig: text("notification_config", { mode: "json" })
.$type<z.infer<typeof notificationConfigSchema>>()
.notNull()
.default(sql`'{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}'`),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
@@ -395,6 +427,9 @@ export const repositories = sqliteTable("repositories", {
destinationOrg: text("destination_org"),
metadata: text("metadata"), // JSON string storing metadata sync state (issues, PRs, releases, etc.)
importedAt: integer("imported_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
@@ -410,8 +445,10 @@ export const repositories = sqliteTable("repositories", {
index("idx_repositories_organization").on(table.organization),
index("idx_repositories_is_fork").on(table.isForked),
index("idx_repositories_is_starred").on(table.isStarred),
index("idx_repositories_user_imported_at").on(table.userId, table.importedAt),
uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName),
uniqueIndex("uniq_repositories_user_normalized_full_name").on(table.userId, table.normalizedFullName),
index("idx_repositories_mirrored_location").on(table.userId, table.mirroredLocation),
]);
export const mirrorJobs = sqliteTable("mirror_jobs", {

View File

@@ -575,7 +575,7 @@ describe("Enhanced Gitea Operations", () => {
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
backupBeforeSync: true,
backupStrategy: "always",
blockSyncOnBackupFailure: true,
},
};

View File

@@ -720,7 +720,7 @@ export async function syncGiteaRepoEnhanced({
repositoryId: repository.id,
repositoryName: repository.name,
message: `Sync requested for repository: ${repository.name}`,
details: `Mirror sync was requested for ${repository.name}. Gitea/Forgejo performs the actual pull asynchronously; check remote logs for pull errors.`,
details: `Mirror sync was requested for ${repository.name}.`,
status: "synced",
});

45
src/lib/gitea-url.test.ts Normal file
View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test";
import { buildGiteaWebUrl, getGiteaWebBaseUrl } from "@/lib/gitea-url";
describe("getGiteaWebBaseUrl", () => {
it("prefers externalUrl when both urls are present", () => {
const baseUrl = getGiteaWebBaseUrl({
url: "http://gitea:3000",
externalUrl: "https://git.example.com",
});
expect(baseUrl).toBe("https://git.example.com");
});
it("falls back to url when externalUrl is missing", () => {
const baseUrl = getGiteaWebBaseUrl({
url: "http://gitea:3000",
});
expect(baseUrl).toBe("http://gitea:3000");
});
it("trims a trailing slash", () => {
const baseUrl = getGiteaWebBaseUrl({
externalUrl: "https://git.example.com/",
});
expect(baseUrl).toBe("https://git.example.com");
});
});
describe("buildGiteaWebUrl", () => {
it("builds a full repository url and removes leading path slashes", () => {
const url = buildGiteaWebUrl(
{ externalUrl: "https://git.example.com/" },
"/org/repo"
);
expect(url).toBe("https://git.example.com/org/repo");
});
it("returns null when no gitea url is configured", () => {
const url = buildGiteaWebUrl({}, "org/repo");
expect(url).toBeNull();
});
});

28
src/lib/gitea-url.ts Normal file
View File

@@ -0,0 +1,28 @@
interface GiteaUrlConfig {
url?: string | null;
externalUrl?: string | null;
}
export function getGiteaWebBaseUrl(
config?: GiteaUrlConfig | null
): string | null {
const rawBaseUrl = config?.externalUrl || config?.url;
if (!rawBaseUrl) {
return null;
}
return rawBaseUrl.endsWith("/") ? rawBaseUrl.slice(0, -1) : rawBaseUrl;
}
export function buildGiteaWebUrl(
config: GiteaUrlConfig | null | undefined,
path: string
): string | null {
const baseUrl = getGiteaWebBaseUrl(config);
if (!baseUrl) {
return null;
}
const normalizedPath = path.replace(/^\/+/, "");
return normalizedPath ? `${baseUrl}/${normalizedPath}` : baseUrl;
}

View File

@@ -72,10 +72,21 @@ mock.module("./gitea", () => {
const mirrorStrategy =
config?.githubConfig?.mirrorStrategy ||
(config?.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
const configuredGitHubOwner =
(config?.githubConfig?.owner || config?.githubConfig?.username || "")
.trim()
.toLowerCase();
const repoOwner = repository?.owner?.trim().toLowerCase();
switch (mirrorStrategy) {
case "preserve":
return repository?.organization || config?.giteaConfig?.defaultOwner || "giteauser";
if (repository?.organization) {
return repository.organization;
}
if (configuredGitHubOwner && repoOwner && repoOwner !== configuredGitHubOwner) {
return repository.owner;
}
return config?.giteaConfig?.defaultOwner || "giteauser";
case "single-org":
return config?.giteaConfig?.organization || config?.giteaConfig?.defaultOwner || "giteauser";
case "mixed":
@@ -99,7 +110,7 @@ mock.module("./gitea", () => {
return mockDbSelectResult[0].destinationOrg;
}
return config?.giteaConfig?.defaultOwner || "giteauser";
return mockGetGiteaRepoOwner({ config, repository });
});
return {
isRepoPresentInGitea: mockIsRepoPresentInGitea,
@@ -376,6 +387,7 @@ describe("Gitea Repository Mirroring", () => {
describe("getGiteaRepoOwner - Organization Override Tests", () => {
const baseConfig: Partial<Config> = {
githubConfig: {
owner: "testuser",
username: "testuser",
token: "token",
preserveOrgStructure: false,
@@ -484,6 +496,18 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
expect(result).toBe("giteauser");
});
test("preserve strategy: personal repos owned by another user keep source owner namespace", () => {
const repo = {
...baseRepo,
owner: "nice-user",
fullName: "nice-user/test-repo",
organization: undefined,
isForked: true,
};
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
expect(result).toBe("nice-user");
});
test("preserve strategy: org repos go to same org name", () => {
const repo = { ...baseRepo, organization: "myorg" };
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
@@ -589,4 +613,26 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
expect(result).toBe("FOO");
});
test("getGiteaRepoOwnerAsync preserves external personal owner for preserve strategy", async () => {
const configWithUser: Partial<Config> = {
...baseConfig,
userId: "user-id",
};
const repo = {
...baseRepo,
owner: "nice-user",
fullName: "nice-user/test-repo",
organization: undefined,
isForked: true,
};
const result = await getGiteaRepoOwnerAsync({
config: configWithUser,
repository: repo,
});
expect(result).toBe("nice-user");
});
});

View File

@@ -10,7 +10,7 @@ import type { Organization, Repository } from "./db/schema";
import { httpPost, httpGet, httpDelete, httpPut, httpPatch } from "./http-client";
import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import { decryptConfigTokens } from "./utils/config-encryption";
import { formatDateShort } from "./utils";
import {
@@ -138,14 +138,35 @@ export const getGiteaRepoOwner = ({
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
const mirrorStrategy = config.githubConfig.mirrorStrategy ||
(config.giteaConfig.preserveOrgStructure ? "preserve" : "flat-user");
const configuredGitHubOwner =
(
config.githubConfig.owner ||
(config.githubConfig as typeof config.githubConfig & { username?: string }).username ||
""
)
.trim()
.toLowerCase();
switch (mirrorStrategy) {
case "preserve":
// Keep GitHub structure - org repos go to same org, personal repos to user (or override)
// Keep GitHub structure:
// - org repos stay in the same org
// - personal repos owned by other users keep their source owner namespace
// - personal repos owned by the configured account go to defaultOwner
if (repository.organization) {
return repository.organization;
}
// Use personal repos override if configured, otherwise use username
const normalizedRepoOwner = repository.owner.trim().toLowerCase();
if (
normalizedRepoOwner &&
configuredGitHubOwner &&
normalizedRepoOwner !== configuredGitHubOwner
) {
return repository.owner;
}
// Personal repos from the configured GitHub account go to the configured default owner
return config.giteaConfig.defaultOwner;
case "single-org":
@@ -353,6 +374,161 @@ export const checkRepoLocation = async ({
return { present: false, actualOwner: expectedOwner };
};
const sanitizeTopicForGitea = (topic: string): string =>
topic
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
const normalizeTopicsForGitea = (
topics: string[],
topicPrefix?: string
): string[] => {
const normalizedPrefix = topicPrefix ? sanitizeTopicForGitea(topicPrefix) : "";
const transformedTopics = topics
.map((topic) => sanitizeTopicForGitea(topic))
.filter((topic) => topic.length > 0)
.map((topic) => (normalizedPrefix ? `${normalizedPrefix}-${topic}` : topic));
return [...new Set(transformedTopics)];
};
const getSourceRepositoryCoordinates = (repository: Repository) => {
const delimiterIndex = repository.fullName.indexOf("/");
if (
delimiterIndex > 0 &&
delimiterIndex < repository.fullName.length - 1
) {
return {
owner: repository.fullName.slice(0, delimiterIndex),
repo: repository.fullName.slice(delimiterIndex + 1),
};
}
return {
owner: repository.owner,
repo: repository.name,
};
};
const fetchGitHubTopics = async ({
octokit,
repository,
}: {
octokit: Octokit;
repository: Repository;
}): Promise<string[] | null> => {
const { owner, repo } = getSourceRepositoryCoordinates(repository);
try {
const response = await octokit.request("GET /repos/{owner}/{repo}/topics", {
owner,
repo,
headers: {
Accept: "application/vnd.github+json",
},
});
const names = (response.data as { names?: unknown }).names;
if (!Array.isArray(names)) {
console.warn(
`[Metadata] Unexpected topics payload for ${repository.fullName}; skipping topic sync.`
);
return null;
}
return names.filter((topic): topic is string => typeof topic === "string");
} catch (error) {
console.warn(
`[Metadata] Failed to fetch topics from GitHub for ${repository.fullName}: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
};
const syncRepositoryMetadataToGitea = async ({
config,
octokit,
repository,
giteaOwner,
giteaRepoName,
giteaToken,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
giteaOwner: string;
giteaRepoName: string;
giteaToken: string;
}): Promise<void> => {
const giteaBaseUrl = config.giteaConfig?.url;
if (!giteaBaseUrl) {
return;
}
const repoApiUrl = `${giteaBaseUrl}/api/v1/repos/${giteaOwner}/${giteaRepoName}`;
const authHeaders = {
Authorization: `token ${giteaToken}`,
};
const description = repository.description?.trim() || "";
try {
await httpPatch(
repoApiUrl,
{ description },
authHeaders
);
console.log(
`[Metadata] Synced description for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}`
);
} catch (error) {
console.warn(
`[Metadata] Failed to sync description for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
if (config.giteaConfig?.addTopics === false) {
return;
}
const sourceTopics = await fetchGitHubTopics({ octokit, repository });
if (sourceTopics === null) {
console.warn(
`[Metadata] Skipping topic sync for ${repository.fullName} because GitHub topics could not be fetched.`
);
return;
}
const topics = normalizeTopicsForGitea(
sourceTopics,
config.giteaConfig?.topicPrefix
);
try {
await httpPut(
`${repoApiUrl}/topics`,
{ topics },
authHeaders
);
console.log(
`[Metadata] Synced ${topics.length} topic(s) for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}`
);
} catch (error) {
console.warn(
`[Metadata] Failed to sync topics for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
};
export const mirrorGithubRepoToGitea = async ({
octokit,
repository,
@@ -376,6 +552,23 @@ export const mirrorGithubRepoToGitea = async ({
// Get the correct owner based on the strategy (with organization overrides)
let repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
const mirrorStrategy = config.githubConfig.mirrorStrategy ||
(config.giteaConfig.preserveOrgStructure ? "preserve" : "flat-user");
const configuredGitHubOwner = (
config.githubConfig.owner ||
(config.githubConfig as typeof config.githubConfig & { username?: string }).username ||
""
)
.trim()
.toLowerCase();
const normalizedRepoOwner = repository.owner.trim().toLowerCase();
const isExternalPersonalRepoInPreserveMode =
mirrorStrategy === "preserve" &&
!repository.organization &&
!repository.isStarred &&
normalizedRepoOwner !== "" &&
configuredGitHubOwner !== "" &&
normalizedRepoOwner !== configuredGitHubOwner;
// Determine the actual repository name to use (handle duplicates for starred repos)
let targetRepoName = repository.name;
@@ -393,6 +586,7 @@ export const mirrorGithubRepoToGitea = async ({
orgName: repoOwner,
baseName: repository.name,
githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy,
});
@@ -427,36 +621,66 @@ export const mirrorGithubRepoToGitea = async ({
});
if (isExisting) {
console.log(
`Repository ${targetRepoName} already exists in Gitea under ${repoOwner}. Updating database status.`
);
// Update database to reflect that the repository is already mirrored
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${repoOwner}/${targetRepoName}`,
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirrored" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Repository ${repository.name} already exists in Gitea`,
details: `Repository ${repository.name} was found to already exist in Gitea under ${repoOwner} and database status was updated.`,
status: "mirrored",
const { getGiteaRepoInfo, handleExistingNonMirrorRepo } = await import("./gitea-enhanced");
const existingRepoInfo = await getGiteaRepoInfo({
config,
owner: repoOwner,
repoName: targetRepoName,
});
console.log(
`Repository ${repository.name} database status updated to mirrored`
);
return;
if (existingRepoInfo && !existingRepoInfo.mirror) {
console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`);
await handleExistingNonMirrorRepo({
config,
repository,
repoInfo: existingRepoInfo,
strategy: "delete", // Can be configured: "skip", "delete", or "rename"
});
} else if (existingRepoInfo?.mirror) {
console.log(
`Repository ${targetRepoName} already exists in Gitea under ${repoOwner}. Updating database status.`
);
await syncRepositoryMetadataToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
giteaToken: decryptedConfig.giteaConfig.token,
});
// Update database to reflect that the repository is already mirrored
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${repoOwner}/${targetRepoName}`,
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirrored" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Repository ${repository.name} already exists in Gitea`,
details: `Repository ${repository.name} was found to already exist in Gitea under ${repoOwner} and database status was updated.`,
status: "mirrored",
});
console.log(
`Repository ${repository.name} database status updated to mirrored`
);
return;
} else {
console.warn(
`[Mirror] Repository ${repoOwner}/${targetRepoName} exists but mirror status could not be verified. Continuing with mirror creation flow.`
);
}
}
console.log(`Mirroring repository ${repository.name}`);
@@ -520,6 +744,13 @@ export const mirrorGithubRepoToGitea = async ({
(orgError.message.includes('Permission denied') ||
orgError.message.includes('Authentication failed') ||
orgError.message.includes('does not have permission'))) {
if (isExternalPersonalRepoInPreserveMode) {
throw new Error(
`Cannot create/access namespace "${repoOwner}" for ${repository.fullName}. ` +
`Refusing fallback to "${config.giteaConfig.defaultOwner}" in preserve mode to avoid cross-owner overwrite.`
);
}
console.warn(`[Fallback] Organization creation/access failed. Attempting to mirror to user account instead.`);
// Update the repository owner to use the user account
@@ -595,6 +826,10 @@ export const mirrorGithubRepoToGitea = async ({
migratePayload.auth_token = decryptedConfig.githubConfig.token;
}
// Track whether the Gitea migrate call succeeded so the catch block
// knows whether to clear mirroredLocation (only safe before migrate succeeds)
let migrateSucceeded = false;
const response = await httpPost(
apiUrl,
migratePayload,
@@ -603,6 +838,17 @@ export const mirrorGithubRepoToGitea = async ({
}
);
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
giteaToken: decryptedConfig.giteaConfig.token,
});
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
const skipMetadataForStarred =
@@ -836,14 +1082,21 @@ export const mirrorGithubRepoToGitea = async ({
}`
);
// Mark repos as "failed" in DB
// Mark repos as "failed" in DB. Only clear mirroredLocation if the Gitea
// migrate call itself failed (repo doesn't exist in Gitea). If migrate
// succeeded but metadata mirroring failed, preserve the location since
// the repo physically exists and we need the location for recovery/retry.
const failureUpdate: Record<string, any> = {
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
};
if (!migrateSucceeded) {
failureUpdate.mirroredLocation = "";
}
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.set(failureUpdate)
.where(eq(repositories.id, repository.id!));
// Append log for failure
@@ -894,29 +1147,103 @@ export async function getOrCreateGiteaOrg({
}
/**
* Generate a unique repository name for starred repos with duplicate names
* Check if a candidate mirroredLocation is already claimed by another repository
* in the local database. This prevents race conditions during concurrent batch
* mirroring where two repos could both claim the same name before either
* finishes creating in Gitea.
*/
async function isMirroredLocationClaimedInDb({
userId,
candidateLocation,
excludeFullName,
}: {
userId: string;
candidateLocation: string;
excludeFullName: string;
}): Promise<boolean> {
try {
const existing = await db
.select({ id: repositories.id })
.from(repositories)
.where(
and(
eq(repositories.userId, userId),
eq(repositories.mirroredLocation, candidateLocation),
ne(repositories.fullName, excludeFullName)
)
)
.limit(1);
return existing.length > 0;
} catch (error) {
console.error(
`Error checking DB for mirroredLocation "${candidateLocation}":`,
error
);
// Fail-closed: assume claimed to be conservative and prevent collisions
return true;
}
}
/**
* Generate a unique repository name for starred repos with duplicate names.
* Checks both the Gitea instance (HTTP) and the local DB (mirroredLocation)
* to reduce collisions during concurrent batch mirroring.
*
* NOTE: This function only checks availability — it does NOT claim the name.
* The actual claim happens later when mirroredLocation is written at the
* status="mirroring" DB update, which is protected by a unique partial index
* on (userId, mirroredLocation) WHERE mirroredLocation != ''.
*/
async function generateUniqueRepoName({
config,
orgName,
baseName,
githubOwner,
fullName,
strategy,
}: {
config: Partial<Config>;
orgName: string;
baseName: string;
githubOwner: string;
fullName: string;
strategy?: string;
}): Promise<string> {
if (!fullName?.includes("/")) {
throw new Error(
`Invalid fullName "${fullName}" for starred repo dedup — expected "owner/repo" format`
);
}
const duplicateStrategy = strategy || "suffix";
const userId = config.userId || "";
// Helper: check both Gitea and local DB for a candidate name
const isNameTaken = async (candidateName: string): Promise<boolean> => {
const existsInGitea = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: candidateName,
});
if (existsInGitea) return true;
// Also check local DB to catch concurrent batch operations
// where another repo claimed this location but hasn't created it in Gitea yet
if (userId) {
const claimedInDb = await isMirroredLocationClaimedInDb({
userId,
candidateLocation: `${orgName}/${candidateName}`,
excludeFullName: fullName,
});
if (claimedInDb) return true;
}
return false;
};
// First check if base name is available
const baseExists = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: baseName,
});
const baseExists = await isNameTaken(baseName);
if (!baseExists) {
return baseName;
@@ -948,11 +1275,7 @@ async function generateUniqueRepoName({
break;
}
const exists = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: candidateName,
});
const exists = await isNameTaken(candidateName);
if (!exists) {
console.log(`Found unique name for duplicate starred repo: ${candidateName}`);
@@ -1015,6 +1338,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
orgName,
baseName: repository.name,
githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy,
});
@@ -1049,36 +1373,66 @@ export async function mirrorGitHubRepoToGiteaOrg({
});
if (isExisting) {
console.log(
`Repository ${targetRepoName} already exists in Gitea organization ${orgName}. Updating database status.`
);
// Update database to reflect that the repository is already mirrored
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${orgName}/${targetRepoName}`,
})
.where(eq(repositories.id, repository.id!));
// Create a mirror job log entry
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Repository ${targetRepoName} already exists in Gitea organization ${orgName}`,
details: `Repository ${targetRepoName} was found to already exist in Gitea organization ${orgName} and database status was updated.`,
status: "mirrored",
const { getGiteaRepoInfo, handleExistingNonMirrorRepo } = await import("./gitea-enhanced");
const existingRepoInfo = await getGiteaRepoInfo({
config,
owner: orgName,
repoName: targetRepoName,
});
console.log(
`Repository ${targetRepoName} database status updated to mirrored in organization ${orgName}`
);
return;
if (existingRepoInfo && !existingRepoInfo.mirror) {
console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`);
await handleExistingNonMirrorRepo({
config,
repository,
repoInfo: existingRepoInfo,
strategy: "delete", // Can be configured: "skip", "delete", or "rename"
});
} else if (existingRepoInfo?.mirror) {
console.log(
`Repository ${targetRepoName} already exists in Gitea organization ${orgName}. Updating database status.`
);
await syncRepositoryMetadataToGitea({
config,
octokit,
repository,
giteaOwner: orgName,
giteaRepoName: targetRepoName,
giteaToken: decryptedConfig.giteaConfig.token,
});
// Update database to reflect that the repository is already mirrored
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${orgName}/${targetRepoName}`,
})
.where(eq(repositories.id, repository.id!));
// Create a mirror job log entry
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Repository ${targetRepoName} already exists in Gitea organization ${orgName}`,
details: `Repository ${targetRepoName} was found to already exist in Gitea organization ${orgName} and database status was updated.`,
status: "mirrored",
});
console.log(
`Repository ${targetRepoName} database status updated to mirrored in organization ${orgName}`
);
return;
} else {
console.warn(
`[Mirror] Repository ${orgName}/${targetRepoName} exists but mirror status could not be verified. Continuing with mirror creation flow.`
);
}
}
console.log(
@@ -1137,6 +1491,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
wiki: shouldMirrorWiki || false,
lfs: config.giteaConfig?.lfs || false,
private: repository.isPrivate,
description: repository.description?.trim() || "",
};
// Add authentication for private repositories
@@ -1151,6 +1506,8 @@ export async function mirrorGitHubRepoToGiteaOrg({
migratePayload.auth_token = decryptedConfig.githubConfig.token;
}
let migrateSucceeded = false;
const migrateRes = await httpPost(
apiUrl,
migratePayload,
@@ -1159,6 +1516,17 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
);
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({
config,
octokit,
repository,
giteaOwner: orgName,
giteaRepoName: targetRepoName,
giteaToken: decryptedConfig.giteaConfig.token,
});
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
const skipMetadataForStarred =
@@ -1397,14 +1765,23 @@ export async function mirrorGitHubRepoToGiteaOrg({
error instanceof Error ? error.message : String(error)
}`
);
// Mark repos as "failed" in DB
// Mark repos as "failed" in DB. For starred repos, clear mirroredLocation
// to release the name claim for retry. For non-starred repos, preserve it
// since the Gitea repo may partially exist and we need the location for recovery.
const failureUpdate2: Record<string, any> = {
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
};
// Only clear mirroredLocation if the Gitea migrate call itself failed.
// If migrate succeeded but metadata mirroring failed, preserve the
// location since the repo physically exists in Gitea.
if (!migrateSucceeded) {
failureUpdate2.mirroredLocation = "";
}
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.set(failureUpdate2)
.where(eq(repositories.id, repository.id!));
// Append log for failure

View File

@@ -287,6 +287,7 @@ export async function getGithubRepositories({
lastMirrored: undefined,
errorMessage: undefined,
importedAt: new Date(),
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
}));
@@ -348,6 +349,7 @@ export async function getGithubStarredRepositories({
lastMirrored: undefined,
errorMessage: undefined,
importedAt: new Date(),
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
}));
@@ -369,7 +371,7 @@ export async function getGithubOrganizations({
}: {
octokit: Octokit;
config: Partial<Config>;
}): Promise<GitOrg[]> {
}): Promise<{ organizations: GitOrg[]; failedOrgs: { name: string; avatarUrl: string; reason: string }[] }> {
try {
const { data: orgs } = await octokit.orgs.listForAuthenticatedUser({
per_page: 100,
@@ -392,30 +394,47 @@ export async function getGithubOrganizations({
return true;
});
const organizations = await Promise.all(
const failedOrgs: { name: string; avatarUrl: string; reason: string }[] = [];
const results = await Promise.all(
filteredOrgs.map(async (org) => {
const [{ data: orgDetails }, { data: membership }] = await Promise.all([
octokit.orgs.get({ org: org.login }),
octokit.orgs.getMembershipForAuthenticatedUser({ org: org.login }),
]);
try {
const [{ data: orgDetails }, { data: membership }] = await Promise.all([
octokit.orgs.get({ org: org.login }),
octokit.orgs.getMembershipForAuthenticatedUser({ org: org.login }),
]);
const totalRepos =
orgDetails.public_repos + (orgDetails.total_private_repos ?? 0);
const totalRepos =
orgDetails.public_repos + (orgDetails.total_private_repos ?? 0);
return {
name: org.login,
avatarUrl: org.avatar_url,
membershipRole: membership.role as MembershipRole,
isIncluded: false,
status: "imported" as RepoStatus,
repositoryCount: totalRepos,
createdAt: new Date(),
updatedAt: new Date(),
};
return {
name: org.login,
avatarUrl: org.avatar_url,
membershipRole: membership.role as MembershipRole,
isIncluded: false,
status: "imported" as RepoStatus,
repositoryCount: totalRepos,
createdAt: new Date(),
updatedAt: new Date(),
};
} catch (error: any) {
// Capture organizations that return 403 (SAML enforcement, insufficient token scope, etc.)
if (error?.status === 403) {
const reason = error?.message || "access denied";
console.warn(
`Failed to import organization ${org.login} - ${reason}`,
);
failedOrgs.push({ name: org.login, avatarUrl: org.avatar_url, reason });
return null;
}
throw error;
}
}),
);
return organizations;
return {
organizations: results.filter((org): org is NonNullable<typeof org> => org !== null),
failedOrgs,
};
} catch (error) {
throw new Error(
`Error fetching organizations: ${
@@ -475,6 +494,7 @@ export async function getGithubOrganizationRepositories({
lastMirrored: undefined,
errorMessage: undefined,
importedAt: new Date(),
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
}));

View File

@@ -3,6 +3,7 @@ import { db, mirrorJobs } from "./db";
import { eq, and, or, lt, isNull } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { publishEvent } from "./events";
import { triggerJobNotification } from "./notification-service";
export async function createMirrorJob({
userId,
@@ -19,6 +20,7 @@ export async function createMirrorJob({
itemIds,
inProgress,
skipDuplicateEvent,
skipNotification,
}: {
userId: string;
organizationId?: string;
@@ -34,6 +36,7 @@ export async function createMirrorJob({
itemIds?: string[];
inProgress?: boolean;
skipDuplicateEvent?: boolean; // Option to skip event publishing for internal operations
skipNotification?: boolean; // Option to skip push notifications for specific internal operations
}) {
const jobId = uuidv4();
const currentTimestamp = new Date();
@@ -67,7 +70,7 @@ export async function createMirrorJob({
// Insert the job into the database
await db.insert(mirrorJobs).values(job);
// Publish the event using SQLite instead of Redis (unless skipped)
// Publish realtime status events unless explicitly skipped
if (!skipDuplicateEvent) {
const channel = `mirror-status:${userId}`;
@@ -89,6 +92,15 @@ export async function createMirrorJob({
});
}
// Trigger push notifications for terminal statuses (never blocks the mirror flow).
// Keep this independent from skipDuplicateEvent so event-stream suppression does not
// silently disable user-facing notifications.
if (!skipNotification && (status === "failed" || status === "mirrored" || status === "synced")) {
triggerJobNotification({ userId, status, repositoryName, organizationName, message, details }).catch(err => {
console.error("[NotificationService] Background notification failed:", err);
});
}
return jobId;
} catch (error) {
console.error("Error creating mirror job:", error);

View File

@@ -0,0 +1,221 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
// Mock fetch globally before importing the module
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
// Mock encryption module
mock.module("@/lib/utils/encryption", () => ({
encrypt: (val: string) => val,
decrypt: (val: string) => val,
isEncrypted: () => false,
}));
// Import after mocks are set up — db is already mocked via setup.bun.ts
import { sendNotification, testNotification } from "./notification-service";
import type { NotificationConfig } from "@/types/config";
describe("sendNotification", () => {
test("sends ntfy notification when provider is ntfy", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/test-topic");
});
test("sends apprise notification when provider is apprise", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "apprise",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
apprise: {
url: "http://apprise:8000",
token: "my-token",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
// Should not throw
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
});
test("skips notification when ntfy topic is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "",
priority: "default",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when apprise URL is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "apprise",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
apprise: {
url: "",
token: "my-token",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
test("returns success when notification is sent", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
});
test("returns error when topic is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toContain("topic");
});
test("returns error when fetch fails", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("bad request", { status: 400 }))
);
globalThis.fetch = mockFetch as any;
const config: NotificationConfig = {
enabled: true,
provider: "ntfy",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
ntfy: {
url: "https://ntfy.sh",
topic: "test-topic",
priority: "default",
},
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
test("returns error for unknown provider", async () => {
const config = {
enabled: true,
provider: "unknown" as any,
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown provider");
});
});

View File

@@ -0,0 +1,165 @@
import type { NotificationConfig } from "@/types/config";
import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
/**
* Sends a notification using the configured provider.
* NEVER throws -- all errors are caught and logged.
*/
export async function sendNotification(
config: NotificationConfig,
event: NotificationEvent,
): Promise<void> {
try {
if (config.provider === "ntfy") {
if (!config.ntfy?.topic) {
console.warn("[NotificationService] Ntfy topic is not configured, skipping notification");
return;
}
await sendNtfyNotification(config.ntfy, event);
} else if (config.provider === "apprise") {
if (!config.apprise?.url || !config.apprise?.token) {
console.warn("[NotificationService] Apprise URL or token is not configured, skipping notification");
return;
}
await sendAppriseNotification(config.apprise, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
}
}
/**
* Sends a test notification and returns the result.
* Unlike sendNotification, this propagates the success/error status
* so the UI can display the outcome.
*/
export async function testNotification(
notificationConfig: NotificationConfig,
): Promise<{ success: boolean; error?: string }> {
const event: NotificationEvent = {
title: "Gitea Mirror - Test Notification",
message: "This is a test notification from Gitea Mirror. If you see this, notifications are working correctly!",
type: "sync_success",
};
try {
if (notificationConfig.provider === "ntfy") {
if (!notificationConfig.ntfy?.topic) {
return { success: false, error: "Ntfy topic is required" };
}
await sendNtfyNotification(notificationConfig.ntfy, event);
} else if (notificationConfig.provider === "apprise") {
if (!notificationConfig.apprise?.url || !notificationConfig.apprise?.token) {
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
/**
* Loads the user's notification config from the database and triggers
* a notification if the event type matches the user's preferences.
*
* NEVER throws -- all errors are caught and logged. This function is
* designed to be called fire-and-forget from the mirror job system.
*/
export async function triggerJobNotification({
userId,
status,
repositoryName,
organizationName,
message,
details,
}: {
userId: string;
status: string;
repositoryName?: string | null;
organizationName?: string | null;
message?: string;
details?: string;
}): Promise<void> {
try {
// Only trigger for terminal statuses
if (status !== "failed" && status !== "mirrored" && status !== "synced") {
return;
}
// Fetch user's config from database
const configResults = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (configResults.length === 0) {
return;
}
const userConfig = configResults[0];
const notificationConfig = userConfig.notificationConfig as NotificationConfig | undefined;
if (!notificationConfig?.enabled) {
return;
}
// Check event type against user preferences
const isError = status === "failed";
const isSuccess = status === "mirrored" || status === "synced";
if (isError && !notificationConfig.notifyOnSyncError) {
return;
}
if (isSuccess && !notificationConfig.notifyOnSyncSuccess) {
return;
}
// Only decrypt the active provider's token to avoid failures from stale
// credentials on the inactive provider dropping the entire notification
const decryptedConfig = { ...notificationConfig };
if (decryptedConfig.provider === "ntfy" && decryptedConfig.ntfy?.token) {
decryptedConfig.ntfy = {
...decryptedConfig.ntfy,
token: decrypt(decryptedConfig.ntfy.token),
};
}
if (decryptedConfig.provider === "apprise" && decryptedConfig.apprise?.token) {
decryptedConfig.apprise = {
...decryptedConfig.apprise,
token: decrypt(decryptedConfig.apprise.token),
};
}
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
const eventType: NotificationEvent["type"] = isError ? "sync_error" : "sync_success";
const event: NotificationEvent = {
title: isError
? `Mirror Failed: ${repoLabel}`
: `Mirror Success: ${repoLabel}`,
message: [
message || `Repository ${repoLabel} ${isError ? "failed to mirror" : "mirrored successfully"}`,
details ? `\nDetails: ${details}` : "",
]
.filter(Boolean)
.join(""),
type: eventType,
};
await sendNotification(decryptedConfig, event);
} catch (error) {
console.error("[NotificationService] Background notification failed:", error);
}
}

View File

@@ -0,0 +1,98 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendAppriseNotification } from "./apprise";
import type { NotificationEvent } from "./ntfy";
import type { AppriseConfig } from "@/types/config";
describe("sendAppriseNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: AppriseConfig = {
url: "http://apprise:8000",
token: "gitea-mirror",
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/gitea-mirror");
});
test("strips trailing slash from URL", async () => {
await sendAppriseNotification(
{ ...baseConfig, url: "http://apprise:8000/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/gitea-mirror");
});
test("sends correct JSON body format", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.body).toBe("This is a test");
expect(body.type).toBe("success");
});
test("maps sync_error to failure type", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendAppriseNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.type).toBe("failure");
});
test("includes tag when configured", async () => {
await sendAppriseNotification(
{ ...baseConfig, tag: "urgent" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.tag).toBe("urgent");
});
test("omits tag when not configured", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.tag).toBeUndefined();
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("server error", { status: 500 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendAppriseNotification(baseConfig, baseEvent)
).rejects.toThrow("Apprise error: 500");
});
});

View File

@@ -0,0 +1,15 @@
import type { AppriseConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendAppriseNotification(config: AppriseConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/notify/${config.token}`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
const body = JSON.stringify({
title: event.title,
body: event.message,
type: event.type === "sync_error" ? "failure" : "success",
tag: config.tag || undefined,
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Apprise error: ${resp.status} ${await resp.text()}`);
}

View File

@@ -0,0 +1,95 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendNtfyNotification, type NotificationEvent } from "./ntfy";
import type { NtfyConfig } from "@/types/config";
describe("sendNtfyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: NtfyConfig = {
url: "https://ntfy.sh",
topic: "gitea-mirror",
priority: "default",
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendNtfyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/gitea-mirror");
});
test("strips trailing slash from URL", async () => {
await sendNtfyNotification(
{ ...baseConfig, url: "https://ntfy.sh/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/gitea-mirror");
});
test("includes Authorization header when token is present", async () => {
await sendNtfyNotification(
{ ...baseConfig, token: "tk_secret" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Authorization"]).toBe("Bearer tk_secret");
});
test("does not include Authorization header when no token", async () => {
await sendNtfyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Authorization"]).toBeUndefined();
});
test("uses high priority for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendNtfyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Priority"]).toBe("high");
expect(opts.headers["Tags"]).toBe("warning");
});
test("uses config priority for non-error events", async () => {
await sendNtfyNotification(
{ ...baseConfig, priority: "low" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Priority"]).toBe("low");
expect(opts.headers["Tags"]).toBe("white_check_mark");
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("rate limited", { status: 429 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendNtfyNotification(baseConfig, baseEvent)
).rejects.toThrow("Ntfy error: 429");
});
});

21
src/lib/providers/ntfy.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NtfyConfig } from "@/types/config";
export interface NotificationEvent {
title: string;
message: string;
type: "sync_error" | "sync_success" | "new_repo";
}
export async function sendNtfyNotification(config: NtfyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/${config.topic}`;
const headers: Record<string, string> = {
"Title": event.title,
"Priority": event.type === "sync_error" ? "high" : (config.priority || "default"),
"Tags": event.type === "sync_error" ? "warning" : "white_check_mark",
};
if (config.token) {
headers["Authorization"] = `Bearer ${config.token}`;
}
const resp = await fetch(url, { method: "POST", body: event.message, headers });
if (!resp.ok) throw new Error(`Ntfy error: ${resp.status} ${await resp.text()}`);
}

View File

@@ -162,8 +162,8 @@ describe("resolveBackupStrategy", () => {
expect(resolveBackupStrategy(makeConfig({ backupStrategy: "block-on-force-push" }))).toBe("block-on-force-push");
});
test("maps backupBeforeSync: true → 'always' (backward compat)", () => {
expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("always");
test("maps backupBeforeSync: true → 'on-force-push' (backward compat, prevents silent always-backup)", () => {
expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("on-force-push");
});
test("maps backupBeforeSync: false → 'disabled' (backward compat)", () => {

View File

@@ -65,13 +65,17 @@ async function runGit(args: string[], tokenToMask: string): Promise<void> {
}
}
async function enforceRetention(repoBackupDir: string, keepCount: number): Promise<void> {
async function enforceRetention(
repoBackupDir: string,
keepCount: number,
retentionDays: number = 0,
): Promise<void> {
const entries = await readdir(repoBackupDir);
const bundleFiles = entries
.filter((name) => name.endsWith(".bundle"))
.map((name) => path.join(repoBackupDir, name));
if (bundleFiles.length <= keepCount) return;
if (bundleFiles.length === 0) return;
const filesWithMtime = await Promise.all(
bundleFiles.map(async (filePath) => ({
@@ -81,9 +85,33 @@ async function enforceRetention(repoBackupDir: string, keepCount: number): Promi
);
filesWithMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
const toDelete = filesWithMtime.slice(keepCount);
await Promise.all(toDelete.map((entry) => rm(entry.filePath, { force: true })));
const toDelete = new Set<string>();
// Count-based retention: keep only the N most recent
if (filesWithMtime.length > keepCount) {
for (const entry of filesWithMtime.slice(keepCount)) {
toDelete.add(entry.filePath);
}
}
// Time-based retention: delete bundles older than retentionDays
if (retentionDays > 0) {
const cutoffMs = Date.now() - retentionDays * 86_400_000;
for (const entry of filesWithMtime) {
if (entry.mtimeMs < cutoffMs) {
toDelete.add(entry.filePath);
}
}
// Always keep at least 1 bundle even if it's old
if (toDelete.size === filesWithMtime.length && filesWithMtime.length > 0) {
toDelete.delete(filesWithMtime[0].filePath);
}
}
if (toDelete.size > 0) {
await Promise.all([...toDelete].map((fp) => rm(fp, { force: true })));
}
}
export function isPreSyncBackupEnabled(): boolean {
@@ -126,9 +154,12 @@ export function resolveBackupStrategy(config: Partial<Config>): BackupStrategy {
}
// 2. Legacy backupBeforeSync boolean → map to strategy
// Note: backupBeforeSync: true now maps to "on-force-push" (not "always")
// because mappers default backupBeforeSync to true, causing every legacy config
// to silently resolve to "always" and create full git bundles on every sync.
const legacy = config.giteaConfig?.backupBeforeSync;
if (legacy !== undefined) {
return legacy ? "always" : "disabled";
return legacy ? "on-force-push" : "disabled";
}
// 3. Env var (new)
@@ -251,7 +282,13 @@ export async function createPreSyncBundleBackup({
1,
Number.isFinite(config.giteaConfig?.backupRetentionCount)
? Number(config.giteaConfig?.backupRetentionCount)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 20)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 5)
);
const retentionDays = Math.max(
0,
Number.isFinite(config.giteaConfig?.backupRetentionDays)
? Number(config.giteaConfig?.backupRetentionDays)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_RETENTION_DAYS, 30)
);
await mkdir(repoBackupDir, { recursive: true });
@@ -268,7 +305,7 @@ export async function createPreSyncBundleBackup({
await runGit(["clone", "--mirror", authCloneUrl, mirrorClonePath], giteaToken);
await runGit(["-C", mirrorClonePath, "bundle", "create", bundlePath, "--all"], giteaToken);
await enforceRetention(repoBackupDir, retention);
await enforceRetention(repoBackupDir, retention, retentionDays);
return { bundlePath };
} finally {
await rm(tmpDir, { recursive: true, force: true });

View File

@@ -28,6 +28,7 @@ function sampleRepo(overrides: Partial<GitRepo> = {}): GitRepo {
status: 'imported',
lastMirrored: undefined,
errorMessage: undefined,
importedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};

View File

@@ -56,6 +56,7 @@ export function normalizeGitRepoToInsert(
status: 'imported',
lastMirrored: repo.lastMirrored ?? null,
errorMessage: repo.errorMessage ?? null,
importedAt: repo.importedAt || new Date(),
createdAt: repo.createdAt || new Date(),
updatedAt: repo.updatedAt || new Date(),
};

View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import type { Repository } from "@/lib/db/schema";
import { sortRepositories } from "@/lib/repository-sorting";
function makeRepo(overrides: Partial<Repository>): Repository {
return {
id: "id",
userId: "user-1",
configId: "config-1",
name: "repo",
fullName: "owner/repo",
normalizedFullName: "owner/repo",
url: "https://github.com/owner/repo",
cloneUrl: "https://github.com/owner/repo.git",
owner: "owner",
organization: null,
mirroredLocation: "",
isPrivate: false,
isForked: false,
forkedFrom: null,
hasIssues: true,
isStarred: false,
isArchived: false,
size: 1,
hasLFS: false,
hasSubmodules: false,
language: null,
description: null,
defaultBranch: "main",
visibility: "public",
status: "imported",
lastMirrored: null,
errorMessage: null,
destinationOrg: null,
metadata: null,
importedAt: new Date("2026-01-01T00:00:00.000Z"),
createdAt: new Date("2020-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
...overrides,
};
}
describe("sortRepositories", () => {
test("defaults to recently imported first", () => {
const repos = [
makeRepo({ id: "a", fullName: "owner/a", importedAt: new Date("2026-01-01T00:00:00.000Z") }),
makeRepo({ id: "b", fullName: "owner/b", importedAt: new Date("2026-03-01T00:00:00.000Z") }),
makeRepo({ id: "c", fullName: "owner/c", importedAt: new Date("2025-12-01T00:00:00.000Z") }),
];
const sorted = sortRepositories(repos, undefined);
expect(sorted.map((repo) => repo.id)).toEqual(["b", "a", "c"]);
});
test("supports name and updated sorting", () => {
const repos = [
makeRepo({ id: "a", fullName: "owner/zeta", updatedAt: new Date("2026-01-01T00:00:00.000Z") }),
makeRepo({ id: "b", fullName: "owner/alpha", updatedAt: new Date("2026-03-01T00:00:00.000Z") }),
makeRepo({ id: "c", fullName: "owner/middle", updatedAt: new Date("2025-12-01T00:00:00.000Z") }),
];
const nameSorted = sortRepositories(repos, "name-asc");
expect(nameSorted.map((repo) => repo.id)).toEqual(["b", "c", "a"]);
const updatedSorted = sortRepositories(repos, "updated-desc");
expect(updatedSorted.map((repo) => repo.id)).toEqual(["b", "a", "c"]);
});
});

View File

@@ -0,0 +1,40 @@
import type { Repository } from "@/lib/db/schema";
export type RepositorySortOrder =
| "imported-desc"
| "imported-asc"
| "updated-desc"
| "updated-asc"
| "name-asc"
| "name-desc";
function getTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export function sortRepositories(
repositories: Repository[],
sortOrder: string | undefined,
): Repository[] {
const order = (sortOrder ?? "imported-desc") as RepositorySortOrder;
return [...repositories].sort((a, b) => {
switch (order) {
case "imported-asc":
return getTimestamp(a.importedAt) - getTimestamp(b.importedAt);
case "updated-desc":
return getTimestamp(b.updatedAt) - getTimestamp(a.updatedAt);
case "updated-asc":
return getTimestamp(a.updatedAt) - getTimestamp(b.updatedAt);
case "name-asc":
return a.fullName.localeCompare(b.fullName, undefined, { sensitivity: "base" });
case "name-desc":
return b.fullName.localeCompare(a.fullName, undefined, { sensitivity: "base" });
case "imported-desc":
default:
return getTimestamp(b.importedAt) - getTimestamp(a.importedAt);
}
});
}

View File

@@ -11,9 +11,11 @@ export function cn(...inputs: ClassValue[]) {
export function generateRandomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const randomValues = new Uint32Array(length);
crypto.getRandomValues(randomValues);
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
result += chars.charAt(randomValues[i] % chars.length);
}
return result;
}

View File

@@ -95,7 +95,8 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
pullRequestConcurrency: 5,
backupStrategy: "on-force-push",
backupBeforeSync: true, // Deprecated: kept for backward compat
backupRetentionCount: 20,
backupRetentionCount: 5,
backupRetentionDays: 30,
backupDirectory: "data/repo-backups",
blockSyncOnBackupFailure: true,
},

View File

@@ -101,9 +101,10 @@ export function mapUiToDbConfig(
mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
mirrorLabels: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.labels,
mirrorMilestones: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.milestones,
backupStrategy: giteaConfig.backupStrategy,
backupStrategy: giteaConfig.backupStrategy || "on-force-push",
backupBeforeSync: giteaConfig.backupBeforeSync ?? true,
backupRetentionCount: giteaConfig.backupRetentionCount ?? 20,
backupRetentionCount: giteaConfig.backupRetentionCount ?? 5,
backupRetentionDays: giteaConfig.backupRetentionDays ?? 30,
backupDirectory: giteaConfig.backupDirectory?.trim() || undefined,
blockSyncOnBackupFailure: giteaConfig.blockSyncOnBackupFailure ?? true,
};
@@ -146,9 +147,12 @@ export function mapDbToUiConfig(dbConfig: any): {
personalReposOrg: undefined, // Not stored in current schema
issueConcurrency: dbConfig.giteaConfig?.issueConcurrency ?? 3,
pullRequestConcurrency: dbConfig.giteaConfig?.pullRequestConcurrency ?? 5,
backupStrategy: dbConfig.giteaConfig?.backupStrategy || undefined,
backupStrategy: dbConfig.giteaConfig?.backupStrategy ||
// Respect legacy backupBeforeSync: false → "disabled" mapping on round-trip
(dbConfig.giteaConfig?.backupBeforeSync === false ? "disabled" : "on-force-push"),
backupBeforeSync: dbConfig.giteaConfig?.backupBeforeSync ?? true,
backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 20,
backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 5,
backupRetentionDays: dbConfig.giteaConfig?.backupRetentionDays ?? 30,
backupDirectory: dbConfig.giteaConfig?.backupDirectory || "data/repo-backups",
blockSyncOnBackupFailure: dbConfig.giteaConfig?.blockSyncOnBackupFailure ?? true,
};

View File

@@ -160,10 +160,23 @@ export function generateSecureToken(length: number = 32): string {
}
/**
* Hashes a value using SHA-256 (for non-reversible values like API keys for comparison)
* Hashes a value using SHA-256 with a random salt (for non-reversible values like API keys)
* @param value The value to hash
* @returns Hex encoded hash
* @returns Salt and hash in format "salt:hash"
*/
export function hashValue(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.createHash('sha256').update(salt + value).digest('hex');
return `${salt}:${hash}`;
}
/**
* Verifies a value against a salted hash produced by hashValue()
* Uses constant-time comparison to prevent timing attacks
*/
export function verifyHash(value: string, saltedHash: string): boolean {
const [salt, expectedHash] = saltedHash.split(':');
if (!salt || !expectedHash) return false;
const actualHash = crypto.createHash('sha256').update(salt + value).digest('hex');
return crypto.timingSafeEqual(Buffer.from(actualHash, 'hex'), Buffer.from(expectedHash, 'hex'));
}

View File

@@ -7,17 +7,10 @@ export const GET: APIRoute = async () => {
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const userCount = userCountResult[0].count;
if (userCount === 0) {
return new Response(JSON.stringify({ error: "No users found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const hasUsers = userCountResult[0].count > 0;
return new Response(JSON.stringify({ userCount }), {
return new Response(JSON.stringify({ hasUsers }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -27,4 +20,4 @@ export const GET: APIRoute = async () => {
headers: { "Content-Type": "application/json" },
});
}
};
};

View File

@@ -1,79 +1,42 @@
import type { APIRoute } from "astro";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { nanoid } from "nanoid";
import { ENV } from "@/lib/config";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request, locals }) => {
// Only available in development
if (ENV.NODE_ENV === "production") {
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
export const GET: APIRoute = async ({ request }) => {
try {
// Get Better Auth configuration info
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const info = {
baseURL: auth.options.baseURL,
basePath: auth.options.basePath,
trustedOrigins: auth.options.trustedOrigins,
emailPasswordEnabled: auth.options.emailAndPassword?.enabled,
userFields: auth.options.user?.additionalFields,
databaseConfig: {
usePlural: true,
provider: "sqlite"
}
};
return new Response(JSON.stringify({
success: true,
config: info
config: info,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
// Log full error details server-side for debugging
console.error("Debug endpoint error:", error);
// Only return safe error information to the client
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "An unexpected error occurred"
error: "An unexpected error occurred",
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};
export const POST: APIRoute = async ({ request }) => {
try {
// Test creating a user directly
const userId = nanoid();
const now = new Date();
await db.insert(users).values({
id: userId,
email: "test2@example.com",
emailVerified: false,
username: "test2",
// Let the database handle timestamps with defaults
});
return new Response(JSON.stringify({
success: true,
userId,
message: "User created successfully"
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
// Log full error details server-side for debugging
console.error("Debug endpoint error:", error);
// Only return safe error information to the client
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : "An unexpected error occurred"
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -6,19 +6,23 @@
import type { APIRoute } from 'astro';
import { runAutomaticCleanup } from '@/lib/cleanup-service';
import { createSecureErrorResponse } from '@/lib/utils';
import { requireAuthenticatedUserId } from '@/lib/auth-guards';
export const POST: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
console.log('Manual cleanup trigger requested');
// Run the automatic cleanup
const results = await runAutomaticCleanup();
// Calculate totals
const totalEventsDeleted = results.reduce((sum, result) => sum + result.eventsDeleted, 0);
const totalJobsDeleted = results.reduce((sum, result) => sum + result.mirrorJobsDeleted, 0);
const errors = results.filter(result => result.error);
return new Response(
JSON.stringify({
success: true,
@@ -28,7 +32,6 @@ export const POST: APIRoute = async ({ request }) => {
totalEventsDeleted,
totalJobsDeleted,
errors: errors.length,
details: results,
},
}),
{

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import { POST } from "./index";
describe("POST /api/config notification validation", () => {
test("returns 400 for invalid notificationConfig payload", async () => {
const request = new Request("http://localhost/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
githubConfig: { username: "octo", token: "ghp_x" },
giteaConfig: { url: "https://gitea.example.com", token: "gt_x", username: "octo" },
scheduleConfig: { enabled: true, interval: 3600 },
cleanupConfig: { enabled: false, retentionDays: 604800 },
mirrorOptions: {
mirrorReleases: false,
releaseLimit: 10,
mirrorLFS: false,
mirrorMetadata: false,
metadataComponents: {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
},
},
advancedOptions: {
skipForks: false,
starredCodeOnly: false,
autoMirrorStarred: false,
},
notificationConfig: {
enabled: true,
provider: "invalid-provider",
},
}),
});
const response = await POST({
request,
locals: {
session: { userId: "user-1" },
},
} as any);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.message).toContain("Invalid notificationConfig");
});
});

View File

@@ -14,6 +14,7 @@ import {
import { encrypt, decrypt } from "@/lib/utils/encryption";
import { createDefaultConfig } from "@/lib/utils/config-defaults";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
import { notificationConfigSchema } from "@/lib/db/schema";
export const POST: APIRoute = async ({ request, locals }) => {
try {
@@ -22,7 +23,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
const userId = authResult.userId;
const body = await request.json();
const { githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, advancedOptions } = body;
const {
githubConfig,
giteaConfig,
scheduleConfig,
cleanupConfig,
mirrorOptions,
advancedOptions,
notificationConfig,
} = body;
if (!githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) {
return new Response(
@@ -38,6 +47,42 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
let validatedNotificationConfig: any = undefined;
if (notificationConfig !== undefined) {
const parsed = notificationConfigSchema.safeParse(notificationConfig);
if (!parsed.success) {
return new Response(
JSON.stringify({
success: false,
message: `Invalid notificationConfig: ${parsed.error.message}`,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
validatedNotificationConfig = parsed.data;
}
// Validate Gitea URL format and protocol
if (giteaConfig.url) {
try {
const giteaUrl = new URL(giteaConfig.url);
if (!['http:', 'https:'].includes(giteaUrl.protocol)) {
return new Response(
JSON.stringify({ success: false, message: "Gitea URL must use http or https protocol." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
} catch {
return new Response(
JSON.stringify({ success: false, message: "Invalid Gitea URL format." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
}
// Fetch existing config
const existingConfigResult = await db
.select()
@@ -97,17 +142,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
const processedCleanupConfig = mapUiCleanupToDb(cleanupConfig);
// Process notification config if provided
let processedNotificationConfig: any = undefined;
if (validatedNotificationConfig) {
processedNotificationConfig = { ...validatedNotificationConfig };
// Encrypt ntfy token if present
if (processedNotificationConfig.ntfy?.token) {
processedNotificationConfig.ntfy = {
...processedNotificationConfig.ntfy,
token: encrypt(processedNotificationConfig.ntfy.token),
};
}
// Encrypt apprise token if present
if (processedNotificationConfig.apprise?.token) {
processedNotificationConfig.apprise = {
...processedNotificationConfig.apprise,
token: encrypt(processedNotificationConfig.apprise.token),
};
}
}
if (existingConfig) {
// Update path
const updateFields: Record<string, any> = {
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
scheduleConfig: processedScheduleConfig,
cleanupConfig: processedCleanupConfig,
updatedAt: new Date(),
};
if (processedNotificationConfig) {
updateFields.notificationConfig = processedNotificationConfig;
}
await db
.update(configs)
.set({
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
scheduleConfig: processedScheduleConfig,
cleanupConfig: processedCleanupConfig,
updatedAt: new Date(),
})
.set(updateFields)
.where(eq(configs.id, existingConfig.id));
return new Response(
@@ -145,7 +214,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Create new config
const configId = uuidv4();
await db.insert(configs).values({
const insertValues: Record<string, any> = {
id: configId,
userId,
name: "Default Configuration",
@@ -158,7 +227,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
cleanupConfig: processedCleanupConfig,
createdAt: new Date(),
updatedAt: new Date(),
});
};
if (processedNotificationConfig) {
insertValues.notificationConfig = processedNotificationConfig;
}
await db.insert(configs).values(insertValues);
return new Response(
JSON.stringify({
@@ -240,13 +313,34 @@ export const GET: APIRoute = async ({ request, locals }) => {
githubConfig,
giteaConfig
};
const uiConfig = mapDbToUiConfig(decryptedConfig);
// Map schedule and cleanup configs to UI format
const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig);
const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig);
// Decrypt notification config tokens
let notificationConfig = dbConfig.notificationConfig;
if (notificationConfig) {
notificationConfig = { ...notificationConfig };
if (notificationConfig.ntfy?.token) {
try {
notificationConfig.ntfy = { ...notificationConfig.ntfy, token: decrypt(notificationConfig.ntfy.token) };
} catch {
// Clear token on decryption failure to prevent double-encryption on next save
notificationConfig.ntfy = { ...notificationConfig.ntfy, token: "" };
}
}
if (notificationConfig.apprise?.token) {
try {
notificationConfig.apprise = { ...notificationConfig.apprise, token: decrypt(notificationConfig.apprise.token) };
} catch {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
}
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
@@ -260,6 +354,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
lastRun: dbConfig.cleanupConfig.lastRun,
nextRun: dbConfig.cleanupConfig.nextRun,
},
notificationConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -270,7 +365,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
const uiConfig = mapDbToUiConfig(dbConfig);
const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig);
const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig);
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
@@ -284,6 +379,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
lastRun: dbConfig.cleanupConfig.lastRun,
nextRun: dbConfig.cleanupConfig.nextRun,
},
notificationConfig: dbConfig.notificationConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },

View File

@@ -49,7 +49,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
.select()
.from(repositories)
.where(and(...conditions))
.orderBy(sql`name COLLATE NOCASE`);
.orderBy(sql`${repositories.importedAt} DESC`, sql`name COLLATE NOCASE`);
const response: RepositoryApiResponse = {
success: true,

View File

@@ -1,14 +1,9 @@
import type { APIRoute } from "astro";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { db } from "@/lib/db";
import { ENV } from "@/lib/config";
import { getRecoveryStatus, hasJobsNeedingRecovery } from "@/lib/recovery";
import os from "os";
import { httpGet } from "@/lib/http-client";
// Track when the server started
const serverStartTime = new Date();
// Cache for the latest version to avoid frequent GitHub API calls
interface VersionCache {
latestVersion: string;
@@ -23,18 +18,6 @@ export const GET: APIRoute = async () => {
// Check database connection by running a simple query
const dbStatus = await checkDatabaseConnection();
// Get system information
const systemInfo = {
uptime: getUptime(),
memory: getMemoryUsage(),
os: {
platform: os.platform(),
version: os.version(),
arch: os.arch(),
},
env: ENV.NODE_ENV,
};
// Get current and latest versions
const currentVersion = process.env.npm_package_version || "unknown";
const latestVersion = await checkLatestVersion();
@@ -50,7 +33,7 @@ export const GET: APIRoute = async () => {
overallStatus = "degraded";
}
// Build response
// Build response (no OS/memory details to avoid information disclosure)
const healthData = {
status: overallStatus,
timestamp: new Date().toISOString(),
@@ -59,9 +42,11 @@ export const GET: APIRoute = async () => {
updateAvailable: latestVersion !== "unknown" &&
currentVersion !== "unknown" &&
compareVersions(currentVersion, latestVersion) < 0,
database: dbStatus,
recovery: recoveryStatus,
system: systemInfo,
database: { connected: dbStatus.connected },
recovery: {
status: recoveryStatus.status,
jobsNeedingRecovery: recoveryStatus.jobsNeedingRecovery,
},
};
return jsonResponse({
@@ -125,55 +110,6 @@ async function getRecoverySystemStatus() {
}
}
/**
* Get server uptime information
*/
function getUptime() {
const now = new Date();
const uptimeMs = now.getTime() - serverStartTime.getTime();
// Convert to human-readable format
const seconds = Math.floor(uptimeMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
return {
startTime: serverStartTime.toISOString(),
uptimeMs,
formatted: `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s`,
};
}
/**
* Get memory usage information
*/
function getMemoryUsage() {
const memoryUsage = process.memoryUsage();
return {
rss: formatBytes(memoryUsage.rss),
heapTotal: formatBytes(memoryUsage.heapTotal),
heapUsed: formatBytes(memoryUsage.heapUsed),
external: formatBytes(memoryUsage.external),
systemTotal: formatBytes(os.totalmem()),
systemFree: formatBytes(os.freemem()),
};
}
/**
* Format bytes to human-readable format
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Compare semantic versions
* Returns:

View File

@@ -0,0 +1,42 @@
import type { APIRoute } from "astro";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
import { testNotification } from "@/lib/notification-service";
import { notificationConfigSchema } from "@/lib/db/schema";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const body = await request.json();
const { notificationConfig } = body;
if (!notificationConfig) {
return new Response(
JSON.stringify({ success: false, error: "notificationConfig is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const parsed = notificationConfigSchema.safeParse(notificationConfig);
if (!parsed.success) {
return new Response(
JSON.stringify({ success: false, error: `Invalid config: ${parsed.error.message}` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const result = await testNotification(parsed.data);
return new Response(
JSON.stringify(result),
{
status: result.success ? 200 : 400,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
return createSecureErrorResponse(error, "notification test", 500);
}
};

View File

@@ -95,6 +95,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // Prevent Nginx from buffering SSE stream
},
});
};

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, organizations, repositories, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { createMirrorJob } from "@/lib/helpers";
import {
@@ -47,13 +47,14 @@ export const POST: APIRoute = async ({ request, locals }) => {
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
// Fetch GitHub data in parallel
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
const [basicAndForkedRepos, starredRepos, orgResult] = await Promise.all([
getGithubRepositories({ octokit, config }),
config.githubConfig?.includeStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
getGithubOrganizations({ octokit, config }),
]);
const { organizations: gitOrgs, failedOrgs } = orgResult;
// Merge and de-duplicate by fullName, preferring starred variant when duplicated
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
@@ -89,6 +90,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
status: repo.status,
lastMirrored: repo.lastMirrored ?? null,
errorMessage: repo.errorMessage ?? null,
importedAt: repo.importedAt,
createdAt: repo.createdAt,
updatedAt: repo.updatedAt,
}));
@@ -108,8 +110,27 @@ export const POST: APIRoute = async ({ request, locals }) => {
updatedAt: new Date(),
}));
// Prepare failed org records for DB insertion
const failedOrgRecords = failedOrgs.map((org) => ({
id: uuidv4(),
userId,
configId: config.id,
name: org.name,
normalizedName: org.name.toLowerCase(),
avatarUrl: org.avatarUrl,
membershipRole: "member" as const,
isIncluded: false,
status: "failed" as const,
errorMessage: org.reason,
repositoryCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
}));
let insertedRepos: typeof newRepos = [];
let insertedOrgs: typeof newOrgs = [];
let insertedFailedOrgs: typeof failedOrgRecords = [];
let recoveredOrgCount = 0;
// Transaction to insert only new items
await db.transaction(async (tx) => {
@@ -119,18 +140,62 @@ export const POST: APIRoute = async ({ request, locals }) => {
.from(repositories)
.where(eq(repositories.userId, userId)),
tx
.select({ normalizedName: organizations.normalizedName })
.select({ normalizedName: organizations.normalizedName, status: organizations.status })
.from(organizations)
.where(eq(organizations.userId, userId)),
]);
const existingRepoNames = new Set(existingRepos.map((r) => r.normalizedFullName));
const existingOrgNames = new Set(existingOrgs.map((o) => o.normalizedName));
const existingOrgMap = new Map(existingOrgs.map((o) => [o.normalizedName, o.status]));
insertedRepos = newRepos.filter(
(r) => !existingRepoNames.has(r.normalizedFullName)
);
insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.normalizedName));
insertedOrgs = newOrgs.filter((o) => !existingOrgMap.has(o.normalizedName));
// Update previously failed orgs that now succeeded
const recoveredOrgs = newOrgs.filter(
(o) => existingOrgMap.get(o.normalizedName) === "failed"
);
for (const org of recoveredOrgs) {
await tx
.update(organizations)
.set({
status: "imported",
errorMessage: null,
repositoryCount: org.repositoryCount,
avatarUrl: org.avatarUrl,
membershipRole: org.membershipRole,
updatedAt: new Date(),
})
.where(
and(
eq(organizations.userId, userId),
eq(organizations.normalizedName, org.normalizedName),
)
);
}
recoveredOrgCount = recoveredOrgs.length;
// Insert or update failed orgs (only update orgs already in "failed" state — don't overwrite good state)
insertedFailedOrgs = failedOrgRecords.filter((o) => !existingOrgMap.has(o.normalizedName));
const stillFailedOrgs = failedOrgRecords.filter(
(o) => existingOrgMap.get(o.normalizedName) === "failed"
);
for (const org of stillFailedOrgs) {
await tx
.update(organizations)
.set({
errorMessage: org.errorMessage,
updatedAt: new Date(),
})
.where(
and(
eq(organizations.userId, userId),
eq(organizations.normalizedName, org.normalizedName),
)
);
}
// Batch insert repositories to avoid SQLite parameter limit (dynamic by column count)
const sample = newRepos[0];
@@ -148,9 +213,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Batch insert organizations (they have fewer fields, so we can use larger batches)
const ORG_BATCH_SIZE = 100;
if (insertedOrgs.length > 0) {
for (let i = 0; i < insertedOrgs.length; i += ORG_BATCH_SIZE) {
const batch = insertedOrgs.slice(i, i + ORG_BATCH_SIZE);
const allNewOrgs = [...insertedOrgs, ...insertedFailedOrgs];
if (allNewOrgs.length > 0) {
for (let i = 0; i < allNewOrgs.length; i += ORG_BATCH_SIZE) {
const batch = allNewOrgs.slice(i, i + ORG_BATCH_SIZE);
await tx.insert(organizations).values(batch);
}
}
@@ -189,6 +255,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
newRepositories: insertedRepos.length,
newOrganizations: insertedOrgs.length,
skippedDisabledRepositories: allGithubRepos.length - mirrorableGithubRepos.length,
failedOrgs: failedOrgs.map((o) => o.name),
recoveredOrgs: recoveredOrgCount,
},
});
} catch (error) {

View File

@@ -187,6 +187,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
status: "imported" as RepoStatus,
lastMirrored: null,
errorMessage: null,
importedAt: new Date(),
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
};

View File

@@ -155,6 +155,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
errorMessage: null,
mirroredLocation: "",
destinationOrg: null,
importedAt: new Date(),
createdAt: repoData.created_at
? new Date(repoData.created_at)
: new Date(),

View File

@@ -202,13 +202,55 @@ import MainLayout from '../../layouts/main.astro';
<!-- Reverse Proxy Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2>
<p class="text-muted-foreground mb-6">
For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.
</p>
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-red-600 dark:text-red-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<div>
<p class="font-semibold text-red-600 dark:text-red-500 mb-1">Required Environment Variables for Reverse Proxy</p>
<p class="text-sm mb-3">
When running Gitea Mirror behind a reverse proxy, you <strong>must</strong> set these environment variables to your external URL.
Without them, sign-in will fail with "invalid origin" errors and pages may appear blank.
</p>
<div class="bg-muted/30 rounded p-3">
<pre class="text-sm"><code>{`# All three MUST be set 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`}</code></pre>
</div>
<ul class="mt-3 space-y-1 text-sm">
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth base URL for callbacks and redirects</li>
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
</ul>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Docker Compose Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- 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
# ... other settings ...`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Nginx Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`server {
listen 80;
@@ -242,13 +284,16 @@ server {
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Caddy Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror.example.com {
reverse_proxy localhost:4321

View File

@@ -75,6 +75,7 @@ export interface GitRepo {
lastMirrored?: Date;
errorMessage?: string;
importedAt: Date;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -22,6 +22,7 @@ export interface GiteaConfig {
backupStrategy?: BackupStrategy;
backupBeforeSync?: boolean; // Deprecated: kept for backward compat, use backupStrategy
backupRetentionCount?: number;
backupRetentionDays?: number;
backupDirectory?: string;
blockSyncOnBackupFailure?: boolean;
}
@@ -84,6 +85,7 @@ export interface SaveConfigApiRequest {
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
notificationConfig?: NotificationConfig;
mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions;
}
@@ -93,6 +95,29 @@ export interface SaveConfigApiResponse {
message: string;
}
export interface NtfyConfig {
url: string;
topic: string;
token?: string;
priority: "min" | "low" | "default" | "high" | "urgent";
}
export interface AppriseConfig {
url: string;
token: string;
tag?: string;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
}
export interface Config extends ConfigType {}
export interface ConfigApiRequest {
@@ -108,6 +133,7 @@ export interface ConfigApiResponse {
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
notificationConfig?: NotificationConfig;
mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions;
include: string[];

View File

@@ -7,6 +7,7 @@ export interface FilterParams {
membershipRole?: MembershipRole | ""; //membership role in orgs
owner?: string; // owner of the repos
organization?: string; // organization of the repos
sort?: string; // repository sort order
type?: string; //types in activity log
name?: string; // name in activity log
}

View File

@@ -1,7 +1,7 @@
{
"name": "www",
"type": "module",
"version": "1.1.0",
"version": "1.2.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -9,20 +9,20 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.3.13",
"@astrojs/react": "^4.4.2",
"@astrojs/mdx": "^5.0.0",
"@astrojs/react": "^5.0.0",
"@radix-ui/react-slot": "^1.2.4",
"@splinetool/react-spline": "^4.1.0",
"@splinetool/runtime": "^1.12.60",
"@splinetool/runtime": "^1.12.69",
"@tailwindcss/vite": "^4.2.1",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^5.17.3",
"astro": "^6.0.4",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
@@ -31,5 +31,5 @@
"devDependencies": {
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.24.0"
"packageManager": "pnpm@10.32.1"
}

902
www/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff