Compare commits

...
36 Commits
Author SHA1 Message Date
Arunavo Ray 92bb38b122 chore: bump version to 3.15.4 2026-04-22 08:11:17 +05:30
dependabot[bot] e7ac54a72a build(deps): bump astro (#274)
Bumps the npm_and_yarn group with 1 update in the /www directory: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro).


Updates `astro` from 6.0.4 to 6.1.6
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.1.6/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.1.6
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 08:10:43 +05:30
Arunavo Ray 2ea250f081 fix: prefer active config when reading user settings (fixes #271)
Multiple "select from configs where userId" queries had no ORDER BY,
so when a user's database accidentally contained more than one config
row for the same user (e.g. from an env-loader insert path or a partial
default-config create), SQLite returned a non-deterministic row.

In the reported case this caused /api/config to hand back an empty stub
while /api/dashboard's repo/org counts came from the populated active
row. The dashboard's useConfigStatus hook then saw missing username/
token, treated config as incomplete, and never fetched dashboard data —
the UI rendered with all zeros even though 868 repos were sitting in
the database, mirroring fine in the background.

Add `ORDER BY isActive DESC, updatedAt DESC` before LIMIT 1 to every
"fetch the user's config" query so the active and most-recently-updated
row consistently wins. Also order env-config-loader's first-user pick
by createdAt for deterministic behavior across restarts.

Already-safe call sites that explicitly filter on isActive=true or
iterate all active configs (cleanup/scheduler/repositories/orgs/cleanup
trigger/sync-organization) are left unchanged.

Updates the mirror-repo test mock to match the new orderBy().limit()
chain.

Closes #271
2026-04-22 08:01:22 +05:30
Arunavo Ray c1712bc670 chore: bump version to 3.15.3 2026-04-20 13:03:28 +05:30
ARUNAVO RAY c4550196e9 fix: honor GH_API_URL across all Octokit call sites (#269) (#273)
* fix: honor GH_API_URL across all Octokit call sites

Six Octokit call sites constructed `new Octokit(...)` directly instead of
going through `createGitHubClient()`, so `GH_API_URL` (and the
`GITHUB_API_URL` fallback) only applied to the handful of flows that used
the helper. For GHES / GHEC-with-data-residency users this surfaced most
visibly as the "Test Connection" button hitting `api.github.com/user`
and failing with 401 even when `GH_API_URL` was set correctly (#269).

Route everything through `createGitHubClient()`:
- src/pages/api/github/test-connection.ts (the reported failure)
- src/pages/api/sync/repository.ts (public-repo sync)
- src/lib/gitea-enhanced.ts (force-push detection + metadata octokit)
- src/lib/scheduler-service.ts (auto-discovery, auto-mirror, auto-start)
- src/tests/test-metadata-mirroring.ts (dev harness, for consistency)

Side benefit: scheduler + sync paths now also get throttling, rate-limit
tracking, and the standard User-Agent, which they were missing.

`createGitHubClient`'s `token` parameter is made optional so the
public-repo sync path (`new Octokit()` with no auth) can keep working.

Fixes #269

* fix: address review findings

- scheduler: pass config.githubConfig?.owner (the real DB field) instead
  of ?.username, which doesn't exist on the DB row and was silently
  resolving to undefined — matches every other DB-reading call site.
- sync/repository.ts: revert to bare Octokit for the unauthenticated
  public-repo lookup to preserve fast-fail on the 60 req/hr limit.
  Still reads GH_API_URL / GITHUB_API_URL inline so GHES / GHEC
  data-residency users benefit. The throttling plugin's retry-with-
  backoff is wrong UX for a one-shot button click.
- github.ts: revert createGitHubClient token back to required (no
  remaining callers pass undefined after the above).
- gitea-enhanced.ts: make the leftover Octokit import type-only.
- test-connection.test.ts: replace mid-test mock.module re-call with a
  mutable stub reference — safer against ESM live-binding semantics.
2026-04-20 13:02:34 +05:30
Arunavo Ray 8cb8fd6fe1 docs: document GH_API_URL for GitHub Enterprise and SERVER_CERT_PATH/SERVER_KEY_PATH for native HTTPS
- README + env reference + .env.example now cover using GH_API_URL to
  target GitHub Enterprise Server or GHEC with data residency.
- Env reference + .env.example now cover SERVER_CERT_PATH and
  SERVER_KEY_PATH, which @astrojs/node reads at runtime to terminate
  TLS directly without a reverse proxy.

Closes #269
Closes #272
2026-04-20 09:30:08 +05:30
Arunavo Ray 4b4ea9614b chore: bump runtime and patch npm CVEs (v3.15.2)
Dockerfile:
- oven/bun 1.3.10 → 1.3.12
- Go 1.25.8 → 1.25.9 (fixes CVE-2026-32280/32281/32283 in git-lfs stdlib)

npm:
- drizzle-orm ^0.45.1 → ^0.45.2 (fixes CVE-2026-39356)
- override defu ^6.1.7 (fixes CVE-2026-35209)
- override @xmldom/xmldom ^0.8.12 (fixes CVE-2026-34601)
- override picomatch ^4.0.4 (fixes CVE-2026-33671, both instances)
- override kysely ^0.28.16 (fixes CVE-2026-33442, CVE-2026-33468)
- override lodash ^4.18.1 (fixes CVE-2026-4800)

Remaining unfixable alert: CVE-2026-27135 (libnghttp2) has no upstream
fix yet. System openssl CVEs are resolved by the Debian base image's
apt-get upgrade step.
2026-04-17 00:47:25 +05:30
Arunavo Ray 1644505043 chore: bump version to 3.15.1 2026-04-17 00:30:48 +05:30
ARUNAVO RAY e142524bfc fix: prevent duplicate issue/PR mirroring, enforce release retention, fix public repo auth, preserve env-loader backup settings
- Add metadata state guards for issues/PRs in syncGiteaRepoEnhanced to
  prevent re-mirroring on every sync (fixes #262)
- Fix httpPut → httpPatch for Gitea release updates (HTTP 405 error)
- Add paginated release retention cleanup to enforce releaseLimit by
  deleting the oldest excess releases (fixes #264)
- Always send auth credentials during migration for all repos, not just
  private ones, preventing "terminal prompts disabled" on Forgejo (fixes #263)
- Add missing service: "git" to org migration payload
- Make buildGithubSourceAuthPayload return {} instead of throwing when
  token is missing, allowing unconditional use
- Preserve existing backup settings in env-config-loader so UI-configured
  backup strategy and retention values survive restart (fixes #267)

Closes #262, #263, #264, #267
2026-04-17 00:30:15 +05:30
Arunavo Ray 8fac30fc02 docs: clarify BETTER_AUTH_URL should be origin only, not include base path
Update README, ENVIRONMENT_VARIABLES.md, and advanced docs page to
explicitly state that BETTER_AUTH_URL and PUBLIC_BETTER_AUTH_URL must be
origin only (scheme + host). The BASE_URL path prefix is applied
automatically — any path accidentally included is stripped.
2026-04-09 20:11:00 +05:30
Arunavo Ray c3b1f933b1 chore: bump version to 3.15.0 2026-04-09 12:45:24 +05:30
dependabot[bot] a839915e3e build(deps): bump vite (#261)
Bumps the npm_and_yarn group with 1 update in the /www directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 12:44:27 +05:30
dependabot[bot] 5d95f4cd39 build(deps): bump defu (#259)
Bumps the npm_and_yarn group with 1 update in the /www directory: [defu](https://github.com/unjs/defu).


Updates `defu` from 6.1.4 to 6.1.6
- [Release notes](https://github.com/unjs/defu/releases)
- [Changelog](https://github.com/unjs/defu/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unjs/defu/compare/v6.1.4...v6.1.6)

---
updated-dependencies:
- dependency-name: defu
  dependency-version: 6.1.6
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 12:33:12 +05:30
ARUNAVO RAY 01a3b08dac feat: support reverse proxy path prefix deployments (#257)
* feat: support reverse proxy path prefixes

* fix: respect BASE_URL in SAML callback fallback

* fix: make BASE_URL runtime configurable
2026-04-09 12:32:59 +05:30
Arunavo Ray c87513b648 chore: bump version to 3.14.2 2026-03-27 13:55:56 +05:30
ARUNAVO RAY 4f3cbc866e fix private github mirror auth (#255) 2026-03-27 13:49:36 +05:30
ARUNAVO RAY 60548f2062 fix sync target resolution for mirrored repos (#249) 2026-03-27 12:33:59 +05:30
dependabot[bot] 74dab43e89 build(deps): bump picomatch (#251)
Bumps the npm_and_yarn group with 1 update in the /www directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 09:39:17 +05:30
dependabot[bot] 01a8025140 build(deps): bump smol-toml (#250)
Bumps the npm_and_yarn group with 1 update in the /www directory: [smol-toml](https://github.com/squirrelchat/smol-toml).


Updates `smol-toml` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.6.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 22:35:57 +05:30
Arunavo Ray 8346748f5a fix: move --accept-flake-config before -- in bun2nix step
The flag was being passed to bun2nix instead of nix, causing
"unexpected argument" error.
2026-03-24 08:22:04 +05:30
Arunavo Ray 38002019ea fix: regenerate bun.nix in CI to prevent stale dependency errors
The Nix build has been failing since v3.9.6 because bun.nix fell out
of sync with bun.lock. During the sandboxed build bun install cannot
fetch missing packages, causing ConnectionRefused errors.

- Add bun2nix regeneration step before nix build in CI
- Trigger workflow on bun.lock and package.json changes
- Update flake.nix version from 3.9.6 to 3.14.1
2026-03-24 08:20:26 +05:30
Arunavo Ray 32eb27c8a6 chore: bump version to 3.14.1 2026-03-24 07:35:36 +05:30
dependabot[bot] d33b4ff64f build(deps): bump h3 (#244)
Bumps the npm_and_yarn group with 1 update in the /www directory: [h3](https://github.com/h3js/h3).


Updates `h3` from 1.15.8 to 1.15.9
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/v1.15.9/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v1.15.8...v1.15.9)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 07:34:40 +05:30
ARUNAVO RAY 6f2e0cbca0 Add GitHub starred-list filtering with searchable selector (#247)
* feat: add starred list filtering and selector UI

* docs: add starred lists UI screenshot

* lib: improve starred list name matching
2026-03-24 07:33:46 +05:30
dependabot[bot] 95e6eb7602 build(deps): bump h3 (#242)
Bumps the npm_and_yarn group with 1 update in the /www directory: [h3](https://github.com/h3js/h3).


Updates `h3` from 1.15.5 to 1.15.8
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v1.15.5...v1.15.8)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 08:24:09 +05:30
Arunavo Ray f50f49fc41 chore: bump version to 3.14.0 2026-03-19 00:59:59 +05:30
ARUNAVO RAY 5ea2abff85 feat: custom sync start time and frequency scheduling (#241)
* feat: add custom sync start time scheduling

* Updated UI

* docs: add updated issue 240 UI screenshot

* fix: improve schedule UI with client-side next run calc and timezone handling

- Compute next scheduled run client-side via useMemo to avoid permanent
  "Calculating..." state when server hasn't set nextRun yet
- Default to browser timezone when enabling syncing (not UTC)
- Show actual saved timezone in badge, use it consistently in all handlers
- Match time input background to select trigger in dark mode
- Add clock icon to time picker with hidden native indicator
2026-03-19 00:58:10 +05:30
Arunavo Ray 9d131b9a09 fix security alerts 2026-03-18 20:10:45 +05:30
github-actions[bot] 5f77fceaca chore: sync version to 3.13.4 2026-03-18 13:15:13 +00:00
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
127 changed files with 9435 additions and 2292 deletions

No files matched your search

+41 -1
View File
@@ -9,6 +9,8 @@
NODE_ENV=production
HOST=0.0.0.0
PORT=4321
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
BASE_URL=/
# Database Configuration
# For self-hosted, SQLite is used by default
@@ -18,9 +20,40 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
BETTER_AUTH_URL=http://localhost:4321
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
# ===========================================
# REVERSE PROXY CONFIGURATION
# ===========================================
# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.).
# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank.
#
# Set all three to your external URL, e.g.:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
#
# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set:
# BASE_URL=/mirror
# BETTER_AUTH_URL=https://git.example.com
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
#
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
# (e.g. https://gitea-mirror.example.com,https://alt.example.com)
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
# BETTER_AUTH_TRUSTED_ORIGINS=
# ===========================================
# HTTPS / TLS (Optional)
# ===========================================
# Set BOTH to have the server terminate TLS directly (no reverse proxy needed).
# Leave unset when TLS is handled upstream by Nginx/Traefik/Caddy.
# SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
# SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
# ===========================================
# DOCKER CONFIGURATION (Optional)
# ===========================================
@@ -40,12 +73,19 @@ DOCKER_TAG=latest
# GITHUB_TOKEN=your-github-personal-access-token
# GITHUB_TYPE=personal # Options: personal, organization
# GitHub Enterprise (GHES / GHEC with data residency)
# Leave unset for standard github.com. Examples:
# GHES (self-hosted): https://ghe.example.com/api/v3
# GHEC data residency: https://api.TENANT.ghe.com
# GH_API_URL=https://ghe.example.com/api/v3
# Repository Selection
# PRIVATE_REPOSITORIES=false
# PUBLIC_REPOSITORIES=true
# INCLUDE_ARCHIVED=false
# SKIP_FORKS=false
# MIRROR_STARRED=false
# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos
# STARRED_REPOS_ORG=starred # Organization name for starred repos
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner
+7
View File
@@ -9,6 +9,8 @@ on:
- 'flake.nix'
- 'flake.lock'
- 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml'
pull_request:
branches: [main]
@@ -16,6 +18,8 @@ on:
- 'flake.nix'
- 'flake.lock'
- 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml'
permissions:
@@ -39,6 +43,9 @@ jobs:
- name: Setup Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@main
- name: Regenerate bun.nix from bun.lock
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
- name: Check flake
run: nix flake check --accept-flake-config
+10 -7
View File
@@ -1,8 +1,8 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.3.10-debian AS base
FROM oven/bun:1.3.12-debian AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -16,6 +16,7 @@ COPY . .
RUN bun run build
RUN mkdir -p dist/scripts && \
for script in scripts/*.ts; do \
if [ "$(basename "$script")" = "runtime-server.ts" ]; then continue; fi; \
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
done
@@ -28,10 +29,10 @@ RUN bun install --production --omit=peer --frozen-lockfile
# ----------------------------
# 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 \
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
wget ca-certificates git make \
&& rm -rf /var/lib/apt/lists/*
ARG GO_VERSION=1.25.8
ARG GO_VERSION=1.25.9
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" \
@@ -48,9 +49,9 @@ RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lf
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
# ----------------------------
FROM oven/bun:1.3.10-debian AS runner
FROM oven/bun:1.3.12-debian AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
git wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
@@ -59,6 +60,7 @@ COPY --from=pruner /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
COPY --from=builder /app/scripts/runtime-server.ts ./scripts/runtime-server.ts
COPY --from=builder /app/drizzle ./drizzle
# Remove build-only packages that are not needed at runtime
@@ -73,6 +75,7 @@ ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db
ENV BASE_URL=/
# Create directories and setup permissions
RUN mkdir -p /app/certs && \
@@ -90,6 +93,6 @@ VOLUME /app/data
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
ENTRYPOINT ["./docker-entrypoint.sh"]
+31 -1
View File
@@ -29,6 +29,7 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
## ✨ Features
- 🔁 Mirror public, private, and starred GitHub repos to Gitea/Forgejo
- 🏛️ **GitHub Enterprise support** - Works with GHES and GHEC with data residency via `GH_API_URL`
- 🏢 Mirror entire organizations with flexible strategies
- 🎯 Custom destination control for repos and organizations
- 📦 **Git LFS support** - Mirror large files with Git LFS
@@ -296,11 +297,40 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
- **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`
### GitHub Enterprise (GHES / GHEC with Data Residency)
Gitea Mirror works with non-`github.com` GitHub deployments. Point the client at your Enterprise API via the `GH_API_URL` environment variable:
```bash
# GitHub Enterprise Server (self-hosted)
GH_API_URL=https://ghe.example.com/api/v3
# GitHub Enterprise Cloud with data residency
GH_API_URL=https://api.TENANT.ghe.com
```
Standard GitHub Enterprise Cloud on `github.com` needs no override. Use a token issued by the target Enterprise instance for `GITHUB_TOKEN`.
## Troubleshooting
### Reverse Proxy Configuration
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure:
```bash
# BASE_URL handles the path prefix — auth URLs stay as origin only
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
```
Notes:
- `BASE_URL` sets the application path prefix.
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` should be **origin only** (e.g. `https://git.example.com`). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
- `BETTER_AUTH_TRUSTED_ORIGINS` should also contain origins only (no path).
- `BASE_URL` is runtime configuration, so prebuilt registry images can be reused across different subpaths.
### Mirror Token Rotation (GitHub Token Changed)
+15 -12
View File
@@ -45,7 +45,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "^0.45.2",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
@@ -82,9 +82,14 @@
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"@xmldom/xmldom": "^0.8.12",
"defu": "^6.1.7",
"devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5",
"fast-xml-parser": "^5.5.6",
"kysely": "^0.28.16",
"lodash": "^4.18.1",
"node-forge": "^1.3.3",
"picomatch": "^4.0.4",
"rollup": ">=4.59.0",
"svgo": "^4.0.1",
},
@@ -721,7 +726,7 @@
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -859,7 +864,7 @@
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
@@ -893,7 +898,7 @@
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -957,9 +962,9 @@
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-builder": ["fast-xml-builder@1.1.3", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg=="],
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"fast-xml-parser": ["fast-xml-parser@5.5.5", "", { "dependencies": { "fast-xml-builder": "^1.1.3", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg=="],
"fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -1085,7 +1090,7 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1111,7 +1116,7 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -1325,7 +1330,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
@@ -1745,8 +1750,6 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"astro/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"astro/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+1024 -1280
View File
File diff suppressed because it is too large. Load diff
+804
View File
@@ -0,0 +1,804 @@
{
"version": "2.9",
"children": [
{
"type": "frame",
"id": "eIiDx",
"x": 0,
"y": 0,
"name": "Scheduling Settings - Redesign",
"width": 1080,
"fill": "#09090B",
"cornerRadius": 16,
"gap": 24,
"padding": 32,
"children": [
{
"type": "frame",
"id": "7r0Wv",
"name": "Automatic Syncing Card",
"clip": true,
"width": "fill_container",
"fill": "#18181B",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#27272A"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "gyCPG",
"name": "Header",
"width": "fill_container",
"gap": 12,
"padding": [
20,
24
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "OunzZ",
"name": "headerIcon",
"width": 20,
"height": 20,
"iconFontName": "refresh-cw",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "fMdlX",
"name": "headerTitle",
"fill": "#FAFAFA",
"content": "Automatic Syncing",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
},
{
"type": "rectangle",
"id": "4cX02",
"name": "divider1",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "Kiezh",
"name": "Toggle Section",
"width": "fill_container",
"gap": 14,
"padding": [
20,
24
],
"children": [
{
"type": "frame",
"id": "QCPzN",
"name": "Checkbox",
"width": 20,
"height": 20,
"fill": "#6366F1",
"cornerRadius": 4,
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "4FTax",
"x": 3,
"y": 3,
"name": "checkIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#FFFFFF"
}
]
},
{
"type": "frame",
"id": "FTzs6",
"name": "toggleText",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "1nJKC",
"name": "toggleLabel",
"fill": "#FAFAFA",
"content": "Enable automatic repository syncing",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "r1O5t",
"name": "toggleDesc",
"fill": "#71717A",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Periodically sync GitHub changes to Gitea",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "rectangle",
"id": "nvQ6R",
"name": "divider2",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "FOoBn",
"name": "Schedule Builder",
"width": "fill_container",
"layout": "vertical",
"gap": 20,
"padding": 24,
"children": [
{
"type": "frame",
"id": "IqHEu",
"name": "schedHeader",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "RnVoM",
"name": "schedTitle",
"fill": "#A1A1AA",
"content": "SCHEDULE",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600",
"letterSpacing": 1
},
{
"type": "frame",
"id": "aVtIZ",
"name": "tzBadge",
"fill": "#27272A",
"cornerRadius": 20,
"gap": 6,
"padding": [
4,
10
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "iXpYV",
"name": "tzIcon",
"width": 12,
"height": 12,
"iconFontName": "globe",
"iconFontFamily": "lucide",
"fill": "#71717A"
},
{
"type": "text",
"id": "WjPMl",
"name": "tzText",
"fill": "#A1A1AA",
"content": "UTC",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "500"
}
]
}
]
},
{
"type": "frame",
"id": "P02fk",
"name": "formRow",
"width": "fill_container",
"gap": 12,
"children": [
{
"type": "frame",
"id": "kcYK5",
"name": "Frequency",
"width": "fill_container",
"layout": "vertical",
"gap": 6,
"children": [
{
"type": "text",
"id": "vMvsN",
"name": "label2",
"fill": "#A1A1AA",
"content": "Frequency",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "frame",
"id": "3prth",
"name": "select2",
"width": "fill_container",
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "ANY36",
"name": "sel2Text",
"fill": "#FAFAFA",
"content": "Daily",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "GUWfd",
"name": "sel2Icon",
"width": 16,
"height": 16,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
},
{
"type": "frame",
"id": "xphp0",
"name": "Start Time",
"width": "fill_container",
"layout": "vertical",
"gap": 6,
"children": [
{
"type": "text",
"id": "l6VkR",
"name": "label3",
"fill": "#A1A1AA",
"content": "Start Time",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "frame",
"id": "lWBDi",
"name": "timeInput",
"width": "fill_container",
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "fbuMi",
"name": "timeText",
"fill": "#FAFAFA",
"content": "10:00 PM",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "5xKW7",
"name": "timeIcon",
"width": 16,
"height": 16,
"iconFontName": "clock-4",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
}
]
}
]
},
{
"type": "rectangle",
"id": "BtYt7",
"name": "divider3",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "520Kb",
"name": "Status Bar",
"width": "fill_container",
"padding": [
16,
24
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "J8JzX",
"name": "lastSync",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "MS5VM",
"name": "lastIcon",
"width": 14,
"height": 14,
"iconFontName": "history",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "8KJHY",
"name": "lastLabel",
"fill": "#52525B",
"content": "Last sync",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "Fz116",
"name": "lastValue",
"fill": "#A1A1AA",
"content": "Never",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "ZbRFN",
"name": "nextSync",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "wIKSk",
"name": "nextIcon",
"width": 14,
"height": 14,
"iconFontName": "calendar",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "ejqSP",
"name": "nextLabel",
"fill": "#52525B",
"content": "Next sync",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "M4oJ7",
"name": "nextValue",
"fill": "#6366F1",
"content": "Calculating...",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "7PK7H",
"name": "Database Maintenance Card",
"clip": true,
"width": "fill_container",
"height": "fill_container",
"fill": "#18181B",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#27272A"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "FAaon",
"name": "Header",
"width": "fill_container",
"gap": 12,
"padding": [
20,
24
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "64CaE",
"name": "rHeaderIcon",
"width": 20,
"height": 20,
"iconFontName": "database",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "rvZlC",
"name": "rHeaderTitle",
"fill": "#FAFAFA",
"content": "Database Maintenance",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
},
{
"type": "rectangle",
"id": "nsM0M",
"name": "rDivider1",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "8zhPi",
"name": "Toggle Section",
"width": "fill_container",
"gap": 14,
"padding": [
20,
24
],
"children": [
{
"type": "frame",
"id": "eQbZk",
"name": "Checkbox",
"width": 20,
"height": 20,
"fill": "#6366F1",
"cornerRadius": 4,
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "t6PbY",
"x": 3,
"y": 3,
"name": "rCheckIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#FFFFFF"
}
]
},
{
"type": "frame",
"id": "lpBPI",
"name": "rToggleText",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "Kuy1S",
"name": "rToggleLabel",
"fill": "#FAFAFA",
"content": "Enable automatic database cleanup",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "OviVY",
"name": "rToggleDesc",
"fill": "#71717A",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Remove old activity logs to optimize storage",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "rectangle",
"id": "1og3D",
"name": "rDivider2",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "J7576",
"name": "Retention Section",
"width": "fill_container",
"layout": "vertical",
"gap": 16,
"padding": 24,
"children": [
{
"type": "frame",
"id": "JZA6R",
"name": "retLabelRow",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "Diiak",
"name": "retLabel",
"fill": "#FAFAFA",
"content": "Data retention period",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "1qqCe",
"name": "retInfoIcon",
"width": 14,
"height": 14,
"iconFontName": "info",
"iconFontFamily": "lucide",
"fill": "#52525B"
}
]
},
{
"type": "frame",
"id": "kfUjs",
"name": "retRow",
"width": "fill_container",
"gap": 16,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "9bhls",
"name": "retSelect",
"width": 180,
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "3NOod",
"name": "retSelText",
"fill": "#FAFAFA",
"content": "1 month",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "8QBA8",
"name": "retSelIcon",
"width": 16,
"height": 16,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
},
{
"type": "text",
"id": "GA6ye",
"name": "retHelper",
"fill": "#52525B",
"content": "Cleanup runs every 2 days",
"fontFamily": "Inter",
"fontSize": 12
}
]
}
]
},
{
"type": "rectangle",
"id": "WfXVB",
"name": "rDivider3",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "WpXnI",
"name": "Cleanup Status",
"width": "fill_container",
"layout": "vertical",
"gap": 12,
"padding": [
16,
24
],
"children": [
{
"type": "frame",
"id": "fbpm5",
"name": "lastCleanup",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "DdLix",
"name": "lastCleanupLeft",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "FN2cj",
"name": "lastCleanIcon",
"width": 14,
"height": 14,
"iconFontName": "history",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "JjmMa",
"name": "lastCleanLabel",
"fill": "#52525B",
"content": "Last cleanup",
"fontFamily": "Inter",
"fontSize": 12
}
]
},
{
"type": "text",
"id": "l1Kph",
"name": "lastCleanValue",
"fill": "#A1A1AA",
"content": "Never",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "AWHY8",
"name": "nextCleanup",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "sj0qN",
"name": "nextCleanupLeft",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "V6RTK",
"name": "nextCleanIcon",
"width": 14,
"height": 14,
"iconFontName": "calendar",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "wf0b4",
"name": "nextCleanLabel",
"fill": "#52525B",
"content": "Next cleanup",
"fontFamily": "Inter",
"fontSize": 12
}
]
},
{
"type": "text",
"id": "YWZGH",
"name": "nextCleanValue",
"fill": "#6366F1",
"content": "March 20, 2026 at 12:19 AM",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
}
]
}
]
}
]
}
]
}
+11 -1
View File
@@ -18,6 +18,11 @@ services:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
# REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL.
# === CORE SETTINGS ===
# These are technically required but have working defaults
@@ -25,6 +30,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
# Optional concurrency controls (defaults match in-app defaults)
# If you want perfect ordering of issues and PRs, set these at 1
@@ -32,7 +38,11 @@ services:
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s
timeout: 10s
retries: 5
+6 -1
View File
@@ -66,6 +66,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
@@ -89,7 +90,11 @@ services:
# Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s
timeout: 5s
retries: 3
+18 -1
View File
@@ -30,8 +30,21 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
# you MUST set these three variables to your external URL. Example:
# BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# If deployed under a path prefix (e.g. https://git.example.com/mirror), also set:
# BASE_URL=/mirror
# BETTER_AUTH_URL=https://git.example.com
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
# GitHub/Gitea Mirror Config
@@ -74,7 +87,11 @@ services:
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
test:
[
"CMD-SHELL",
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
]
interval: 30s
timeout: 10s
retries: 5
+7 -1
View File
@@ -229,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP
# Start the application
echo "Starting Gitea Mirror..."
bun ./dist/server/entry.mjs &
if [ -f "./scripts/runtime-server.ts" ]; then
bun ./scripts/runtime-server.ts &
elif [ -f "./dist/scripts/runtime-server.js" ]; then
bun ./dist/scripts/runtime-server.js &
else
bun ./dist/server/entry.mjs &
fi
APP_PID=$!
# Wait for the application to finish
+63 -2
View File
@@ -16,6 +16,7 @@ When environment variables are set:
## Table of Contents
- [Core Configuration](#core-configuration)
- [HTTPS / TLS](#https--tls)
- [GitHub Configuration](#github-configuration)
- [Gitea Configuration](#gitea-configuration)
- [Mirror Options](#mirror-options)
@@ -33,13 +34,38 @@ Essential application settings required for running Gitea Mirror.
| `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | No |
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
## HTTPS / TLS
Gitea Mirror can terminate TLS directly via the underlying `@astrojs/node` adapter — useful when you don't want a separate reverse proxy. When both variables below are set, the server starts as a real HTTPS listener instead of HTTP.
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `SERVER_CERT_PATH` | Absolute path to the TLS certificate (PEM). Set together with `SERVER_KEY_PATH` to enable HTTPS. | - | No |
| `SERVER_KEY_PATH` | Absolute path to the TLS private key (PEM). Set together with `SERVER_CERT_PATH` to enable HTTPS. | - | No |
**Example (systemd or `.env`):**
```bash
SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
PORT=443
BETTER_AUTH_URL=https://mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://mirror.example.com
```
Notes:
- The process must have read access to both files. When binding to `PORT=443`, grant the binary the `CAP_NET_BIND_SERVICE` capability (or run as a user allowed to bind privileged ports) rather than running as root.
- If you already terminate TLS at a reverse proxy (nginx, Traefik, Caddy), leave these unset and let the proxy handle certificates.
- Works in Docker too — mount your certs and set both paths to locations inside the container.
## GitHub Configuration
Settings for connecting to and configuring GitHub repository sources.
@@ -51,6 +77,21 @@ Settings for connecting to and configuring GitHub repository sources.
| `GITHUB_USERNAME` | Your GitHub username | - | - |
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
| `GITHUB_TYPE` | GitHub account type | `personal` | `personal`, `organization` |
| `GH_API_URL` | GitHub API base URL. Override this to point at GitHub Enterprise Server or Enterprise Cloud with data residency. | `https://api.github.com` | e.g. `https://ghe.example.com/api/v3`, `https://api.TENANT.ghe.com` |
### GitHub Enterprise (GHES / GHEC with data residency)
Set `GH_API_URL` to point Octokit at a non-`github.com` API endpoint:
```bash
# GitHub Enterprise Server (self-hosted)
GH_API_URL=https://ghe.example.com/api/v3
# GitHub Enterprise Cloud with data residency
GH_API_URL=https://api.TENANT.ghe.com
```
Standard GitHub Enterprise Cloud on `github.com` works with the default — no override needed. Use a personal access token issued by the target Enterprise instance for `GITHUB_TOKEN`.
### Repository Selection
@@ -61,6 +102,7 @@ Settings for connecting to and configuring GitHub repository sources.
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
@@ -301,6 +343,7 @@ services:
environment:
# Core Configuration
- NODE_ENV=production
- BASE_URL=/
- DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here
# Primary access URL:
@@ -369,6 +412,24 @@ This setup allows you to:
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
### Path Prefix Deployments
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
```bash
# BASE_URL handles the path prefix — auth URLs stay as origin only
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
```
Notes:
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` must be **origin only** (scheme + host). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
- `BETTER_AUTH_TRUSTED_ORIGINS` must also contain origins only (no path).
- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
+6 -2
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
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+9
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` != '';
+1
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
+15 -1
View File
@@ -71,6 +71,20 @@
"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
}
]
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
# Build the application
gitea-mirror = pkgs.stdenv.mkDerivation {
pname = "gitea-mirror";
version = "3.9.6";
version = "3.14.1";
src = ./.;
+12 -7
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.13.2",
"version": "3.15.4",
"engines": {
"bun": ">=1.2.9"
},
@@ -31,8 +31,8 @@
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
"preview": "bunx --bun astro preview",
"start": "bun dist/server/entry.mjs",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
"start": "bun scripts/runtime-server.ts",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts",
"test": "bun test",
"test:migrations": "bun scripts/validate-migrations.ts",
"test:watch": "bun test --watch",
@@ -45,11 +45,16 @@
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"@xmldom/xmldom": "^0.8.12",
"defu": "^6.1.7",
"devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5",
"fast-xml-parser": "^5.5.6",
"kysely": "^0.28.16",
"lodash": "^4.18.1",
"node-forge": "^1.3.3",
"svgo": "^4.0.1",
"rollup": ">=4.59.0"
"picomatch": "^4.0.4",
"rollup": ">=4.59.0",
"svgo": "^4.0.1"
},
"dependencies": {
"@astrojs/check": "^0.9.7",
@@ -92,7 +97,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "^0.45.2",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
+76
View File
@@ -0,0 +1,76 @@
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
function normalizeBasePath(basePath: string | undefined): string {
if (!basePath || !basePath.trim()) {
return "/";
}
let normalized = basePath.trim();
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, "");
return normalized || "/";
}
function rewriteRequestUrl(rawUrl: string, basePath: string): string | null {
if (basePath === "/") {
return rawUrl;
}
const url = new URL(rawUrl, "http://localhost");
const pathname = url.pathname;
if (pathname === basePath || pathname === `${basePath}/`) {
url.pathname = "/";
return `${url.pathname}${url.search}`;
}
if (pathname.startsWith(`${basePath}/`)) {
url.pathname = pathname.slice(basePath.length) || "/";
return `${url.pathname}${url.search}`;
}
return null;
}
const basePath = normalizeBasePath(process.env.BASE_URL);
const host = process.env.HOST || "0.0.0.0";
const port = Number.parseInt(process.env.PORT || "4321", 10);
process.env.ASTRO_NODE_AUTOSTART = "disabled";
const { handler } = await import("../dist/server/entry.mjs");
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (!req.url) {
res.statusCode = 400;
res.end("Bad Request");
return;
}
const rewrittenUrl = rewriteRequestUrl(req.url, basePath);
if (rewrittenUrl === null) {
res.statusCode = 404;
res.end("Not Found");
return;
}
req.url = rewrittenUrl;
req.headers["x-gitea-mirror-base-rewritten"] = "1";
Promise.resolve((handler as unknown as (request: IncomingMessage, response: ServerResponse) => unknown)(req, res)).catch((error) => {
console.error("Unhandled runtime server error:", error);
if (!res.headersSent) {
res.statusCode = 500;
res.end("Internal Server Error");
} else {
res.end();
}
});
});
server.listen(port, host, () => {
console.log(`Runtime server listening on http://${host}:${port} (BASE_URL=${basePath})`);
});
+53
View File
@@ -143,11 +143,64 @@ function verify0009Migration(db: Database) {
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[]) {
+6 -5
View File
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
import { withBase } from "@/lib/base-path";
export function NotFound() {
return (
@@ -21,7 +22,7 @@ export function NotFound() {
{/* Action Buttons */}
<div className="flex flex-col gap-3">
<Button asChild className="w-full">
<a href="/">
<a href={withBase("/")}>
<Home className="mr-2 h-4 w-4" />
Go to Dashboard
</a>
@@ -45,21 +46,21 @@ export function NotFound() {
{/* Quick Links */}
<div className="grid grid-cols-3 gap-3">
<a
href="/repositories"
href={withBase("/repositories")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<GitBranch className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Repositories</span>
</a>
<a
href="/config"
href={withBase("/config")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Config</span>
</a>
<a
href="/docs"
href={withBase("/docs")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<BookOpen className="h-5 w-5 text-muted-foreground" />
@@ -77,4 +78,4 @@ export function NotFound() {
</Card>
</div>
);
}
}
+2 -1
View File
@@ -36,6 +36,7 @@ import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout';
import { withBase } from '@/lib/base-path';
import {
Drawer,
DrawerClose,
@@ -321,7 +322,7 @@ export function ActivityLog() {
setIsInitialLoading(true);
setShowCleanupDialog(false);
const response = await fetch('/api/activities/cleanup', {
const response = await fetch(withBase('/api/activities/cleanup'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }),
+8 -4
View File
@@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function LoginForm() {
@@ -47,7 +48,7 @@ export function LoginForm() {
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
window.location.href = withBase('/');
}, 1000);
} catch (error) {
showErrorToast(error, toast);
@@ -64,12 +65,15 @@ export function LoginForm() {
return;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
const callbackURL =
typeof window !== 'undefined'
? new URL(withBase('/'), window.location.origin).toString()
: `http://localhost:4321${withBase('/')}`;
await authClient.signIn.sso({
email: ssoEmail || undefined,
domain: domain,
providerId: providerId,
callbackURL: `${baseURL}/`,
callbackURL,
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
});
} catch (error) {
@@ -85,7 +89,7 @@ export function LoginForm() {
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img
src="/logo.png"
src={withBase('/logo.png')}
alt="Gitea Mirror Logo"
className="h-8 w-10"
/>
+3 -2
View File
@@ -7,6 +7,7 @@ import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function SignupForm() {
const [isLoading, setIsLoading] = useState(false);
@@ -42,7 +43,7 @@ export function SignupForm() {
toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
window.location.href = withBase('/');
}, 1500);
} catch (error) {
showErrorToast(error, toast);
@@ -57,7 +58,7 @@ export function SignupForm() {
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img
src="/logo.png"
src={withBase('/logo.png')}
alt="Gitea Mirror Logo"
className="h-8 w-10"
/>
+146 -70
View File
@@ -1,7 +1,8 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -19,6 +20,7 @@ import {
Zap,
Info,
Archive,
Globe,
} from "lucide-react";
import {
Tooltip,
@@ -28,6 +30,10 @@ import {
} from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import {
buildClockCronExpression,
getNextCronOccurrence,
} from "@/lib/utils/schedule-utils";
interface AutomationSettingsProps {
scheduleConfig: ScheduleConfig;
@@ -38,15 +44,13 @@ interface AutomationSettingsProps {
isAutoSavingCleanup?: boolean;
}
const scheduleIntervals = [
{ label: "Every hour", value: 3600 },
{ label: "Every 2 hours", value: 7200 },
{ label: "Every 4 hours", value: 14400 },
{ label: "Every 8 hours", value: 28800 },
{ label: "Every 12 hours", value: 43200 },
{ label: "Daily", value: 86400 },
{ label: "Every 2 days", value: 172800 },
{ label: "Weekly", value: 604800 },
const clockFrequencies = [
{ label: "Every hour", value: 1 },
{ label: "Every 2 hours", value: 2 },
{ label: "Every 4 hours", value: 4 },
{ label: "Every 8 hours", value: 8 },
{ label: "Every 12 hours", value: 12 },
{ label: "Daily", value: 24 },
];
const retentionPeriods = [
@@ -85,6 +89,27 @@ export function AutomationSettings({
isAutoSavingSchedule,
isAutoSavingCleanup,
}: AutomationSettingsProps) {
const browserTimezone =
typeof Intl !== "undefined"
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
: "UTC";
// Use saved timezone, but treat "UTC" as unset for users who never chose it
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
const nextScheduledRun = useMemo(() => {
if (!scheduleConfig.enabled) return null;
const startTime = scheduleConfig.startTime || "22:00";
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
if (!cronExpression) return null;
try {
return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone);
} catch {
return null;
}
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
// Update nextRun for cleanup when settings change
useEffect(() => {
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
@@ -125,7 +150,7 @@ export function AutomationSettings({
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Automatic Syncing Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-primary" />
@@ -136,14 +161,21 @@ export function AutomationSettings({
)}
</div>
<div className="space-y-4">
<div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-mirror"
checked={scheduleConfig.enabled}
className="mt-1.25"
onCheckedChange={(checked) =>
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
onScheduleChange({
...scheduleConfig,
enabled: !!checked,
timezone: checked ? browserTimezone : scheduleConfig.timezone,
startTime: scheduleConfig.startTime || "22:00",
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
scheduleMode: "clock",
})
}
/>
<div className="space-y-0.5 flex-1">
@@ -154,79 +186,123 @@ export function AutomationSettings({
Enable automatic repository syncing
</Label>
<p className="text-xs text-muted-foreground">
Periodically check GitHub for changes and mirror them to Gitea
Periodically sync GitHub changes to Gitea
</p>
</div>
</div>
{scheduleConfig.enabled && (
<div className="ml-6 space-y-3">
<div>
<Label htmlFor="mirror-interval" className="text-sm">
Sync frequency
</Label>
<Select
value={scheduleConfig.interval.toString()}
onValueChange={(value) =>
onScheduleChange({
...scheduleConfig,
interval: parseInt(value, 10),
})
}
>
<SelectTrigger id="mirror-interval" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scheduleIntervals.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Schedule
</p>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
<Globe className="h-3 w-3" />
{effectiveTimezone}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label
htmlFor="clock-frequency"
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Frequency
</Label>
<Select
value={String(scheduleConfig.clockFrequencyHours || 24)}
onValueChange={(value) =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
clockFrequencyHours: parseInt(value, 10),
startTime: scheduleConfig.startTime || "22:00",
timezone: effectiveTimezone,
})
}
>
<SelectTrigger id="clock-frequency" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{clockFrequencies.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label
htmlFor="clock-start-time"
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Start time
</Label>
<div className="relative">
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
<Clock className="size-4" />
</div>
<Input
id="clock-start-time"
type="time"
value={scheduleConfig.startTime || "22:00"}
onChange={(event) =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
startTime: event.target.value,
clockFrequencyHours:
scheduleConfig.clockFrequencyHours || 24,
timezone: effectiveTimezone,
})
}
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
</div>
)}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last sync
</span>
<span className="font-medium text-muted-foreground">
<div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last sync{" "}
<span className="font-medium">
{scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun)
: "Never"}
</span>
</div>
</span>
{scheduleConfig.enabled ? (
scheduleConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next sync
</span>
<span className="font-medium">
{formatDate(scheduleConfig.nextRun)}
</span>
</div>
)
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next sync{" "}
<span className="font-medium text-primary">
{scheduleConfig.nextRun
? formatDate(scheduleConfig.nextRun)
: nextScheduledRun
? formatDate(nextScheduledRun)
: "Calculating..."}
</span>
</span>
) : (
<div className="text-xs text-muted-foreground">
Enable automatic syncing to schedule periodic repository updates
</div>
<span>Enable syncing to schedule updates</span>
)}
</div>
</div>
</div>
</div>
{/* Database Cleanup Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
@@ -237,7 +313,7 @@ export function AutomationSettings({
)}
</div>
<div className="space-y-4">
<div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-cleanup"
@@ -255,13 +331,13 @@ export function AutomationSettings({
Enable automatic database cleanup
</Label>
<p className="text-xs text-muted-foreground">
Remove old activity logs and events to optimize storage
Remove old activity logs to optimize storage
</p>
</div>
</div>
{cleanupConfig.enabled && (
<div className="ml-6 space-y-5">
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
Data retention period
@@ -312,7 +388,7 @@ export function AutomationSettings({
</div>
)}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
<div className="mt-auto space-y-2 pt-3 border-t border-border/50">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
+91 -8
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';
@@ -22,6 +24,7 @@ import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { withBase } from '@/lib/base-path';
type ConfigState = {
githubConfig: GitHubConfig;
@@ -30,8 +33,11 @@ type ConfigState = {
cleanupConfig: DatabaseCleanupConfig;
mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions;
notificationConfig: NotificationConfig;
};
const CONFIG_API_PATH = withBase('/api/config');
export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({
githubConfig: {
@@ -39,6 +45,7 @@ export function ConfigTabs() {
token: '',
privateRepositories: false,
mirrorStarred: false,
starredLists: [],
},
giteaConfig: {
url: '',
@@ -51,7 +58,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 +93,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 +109,12 @@ export function ConfigTabs() {
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
const [isAutoSavingNotification, setIsAutoSavingNotification] = useState<boolean>(false);
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const autoSaveNotificationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isConfigFormValid = (): boolean => {
const { githubConfig, giteaConfig } = config;
@@ -184,7 +201,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -250,7 +267,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -315,7 +332,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -364,7 +381,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -404,7 +421,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -439,7 +456,7 @@ export function ConfigTabs() {
};
try {
const response = await fetch('/api/config', {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
@@ -459,6 +476,55 @@ export function ConfigTabs() {
}
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
// Auto-save function for notification config changes
const autoSaveNotificationConfig = useCallback(async (notifConfig: NotificationConfig) => {
if (!user?.id) return;
// Clear any existing timeout
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
// Debounce the auto-save to prevent excessive API calls
autoSaveNotificationTimeoutRef.current = setTimeout(async () => {
setIsAutoSavingNotification(true);
const reqPayload = {
userId: user.id!,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: config.advancedOptions,
notificationConfig: notifConfig,
};
try {
const response = await fetch(CONFIG_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
// Silent success - no toast for auto-save
invalidateConfigCache();
} else {
showErrorToast(
`Auto-save failed: ${result.message || 'Unknown error'}`,
toast
);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsAutoSavingNotification(false);
}
}, 500); // 500ms debounce
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions, config.advancedOptions]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
@@ -474,6 +540,9 @@ export function ConfigTabs() {
if (autoSaveGiteaTimeoutRef.current) {
clearTimeout(autoSaveGiteaTimeoutRef.current);
}
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
};
}, []);
@@ -505,6 +574,8 @@ export function ConfigTabs() {
},
advancedOptions:
response.advancedOptions || config.advancedOptions,
notificationConfig:
(response as any).notificationConfig || config.notificationConfig,
});
}
@@ -634,9 +705,10 @@ export function ConfigTabs() {
{/* Content section - Tabs layout */}
<Tabs defaultValue="connections" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="sso">Authentication</TabsTrigger>
</TabsList>
@@ -724,6 +796,17 @@ export function ConfigTabs() {
/>
</TabsContent>
<TabsContent value="notifications" className="space-y-4">
<NotificationSettings
notificationConfig={config.notificationConfig}
onNotificationChange={(newConfig) => {
setConfig(prev => ({ ...prev, notificationConfig: newConfig }));
autoSaveNotificationConfig(newConfig);
}}
isAutoSaving={isAutoSavingNotification}
/>
</TabsContent>
<TabsContent value="sso" className="space-y-4">
<SSOSettings />
</TabsContent>
+26 -4
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
+251 -3
View File
@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
@@ -17,6 +18,7 @@ import {
} from "@/components/ui/popover";
import {
Info,
Check,
GitBranch,
Star,
Lock,
@@ -31,7 +33,9 @@ import {
ChevronDown,
Funnel,
HardDrive,
FileCode2
FileCode2,
Plus,
X
} from "lucide-react";
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
import {
@@ -41,7 +45,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { githubApi } from "@/lib/api";
interface GitHubMirrorSettingsProps {
githubConfig: GitHubConfig;
@@ -60,8 +73,42 @@ export function GitHubMirrorSettings({
onMirrorOptionsChange,
onAdvancedOptionsChange,
}: GitHubMirrorSettingsProps) {
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
const [starListsOpen, setStarListsOpen] = React.useState(false);
const [starListSearch, setStarListSearch] = React.useState("");
const [customStarListName, setCustomStarListName] = React.useState("");
const [availableStarLists, setAvailableStarLists] = React.useState<string[]>([]);
const [loadingStarLists, setLoadingStarLists] = React.useState(false);
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false);
const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => {
if (!Array.isArray(lists)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
for (const list of lists) {
const trimmed = list.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(trimmed);
}
return normalized;
}, []);
const selectedStarLists = React.useMemo(
() => normalizeStarListNames(githubConfig.starredLists),
[githubConfig.starredLists, normalizeStarListNames],
);
const allKnownStarLists = React.useMemo(
() => normalizeStarListNames([...availableStarLists, ...selectedStarLists]),
[availableStarLists, selectedStarLists, normalizeStarListNames],
);
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string | string[]) => {
onGitHubConfigChange({ ...githubConfig, [field]: value });
};
@@ -83,6 +130,59 @@ export function GitHubMirrorSettings({
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
};
const setSelectedStarLists = React.useCallback((lists: string[]) => {
onGitHubConfigChange({
...githubConfig,
starredLists: normalizeStarListNames(lists),
});
}, [githubConfig, normalizeStarListNames, onGitHubConfigChange]);
const loadStarLists = React.useCallback(async () => {
if (
loadingStarLists ||
loadedStarLists ||
attemptedStarListLoad ||
!githubConfig.mirrorStarred
) return;
setAttemptedStarListLoad(true);
setLoadingStarLists(true);
try {
const response = await githubApi.getStarredLists();
setAvailableStarLists(normalizeStarListNames(response.lists));
setLoadedStarLists(true);
} catch {
// Keep UX usable with manual custom input even if list fetch fails.
// Allow retry on next popover open.
setLoadedStarLists(false);
} finally {
setLoadingStarLists(false);
}
}, [
attemptedStarListLoad,
githubConfig.mirrorStarred,
loadedStarLists,
loadingStarLists,
normalizeStarListNames,
]);
React.useEffect(() => {
if (!starListsOpen || !githubConfig.mirrorStarred) return;
void loadStarLists();
}, [starListsOpen, githubConfig.mirrorStarred, loadStarLists]);
React.useEffect(() => {
if (!githubConfig.mirrorStarred) {
setStarListsOpen(false);
}
}, [githubConfig.mirrorStarred]);
React.useEffect(() => {
if (!starListsOpen) {
setAttemptedStarListLoad(false);
}
}, [starListsOpen]);
// When metadata is disabled, all components should be disabled
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
@@ -98,6 +198,17 @@ export function GitHubMirrorSettings({
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
const totalStarredOptions = 4; // releases, issues, PRs, wiki
const normalizedStarListSearch = starListSearch.trim();
const canAddSearchAsStarList = normalizedStarListSearch.length > 0
&& !allKnownStarLists.some((list) => list.toLowerCase() === normalizedStarListSearch.toLowerCase());
const addCustomStarList = () => {
const trimmed = customStarListName.trim();
if (!trimmed) return;
setSelectedStarLists([...selectedStarLists, trimmed]);
setCustomStarListName("");
};
return (
<div className="space-y-6">
{/* Repository Selection Section */}
@@ -312,6 +423,143 @@ export function GitHubMirrorSettings({
</div>
)}
{/* Star list selection */}
{githubConfig.mirrorStarred && (
<div className="mt-4 space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Star Lists (optional)
</Label>
<Popover open={starListsOpen} onOpenChange={setStarListsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={starListsOpen}
className="w-full justify-between h-9 text-xs font-normal"
>
<span className="truncate text-left">
{selectedStarLists.length === 0
? "All starred repositories"
: `${selectedStarLists.length} list${selectedStarLists.length === 1 ? "" : "s"} selected`}
</span>
<ChevronDown className="ml-2 h-3 w-3 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[360px] p-0" align="start">
<Command>
<CommandInput
value={starListSearch}
onValueChange={setStarListSearch}
placeholder="Search GitHub star lists..."
/>
<CommandList>
<CommandEmpty>
{loadingStarLists ? "Loading star lists..." : "No matching lists"}
</CommandEmpty>
<CommandGroup>
{allKnownStarLists.map((list) => {
const isSelected = selectedStarLists.some(
(selected) => selected.toLowerCase() === list.toLowerCase(),
);
return (
<CommandItem
key={list}
value={list}
onSelect={() => {
if (isSelected) {
setSelectedStarLists(
selectedStarLists.filter(
(selected) => selected.toLowerCase() !== list.toLowerCase(),
),
);
} else {
setSelectedStarLists([...selectedStarLists, list]);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{list}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
{canAddSearchAsStarList && (
<div className="border-t p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-xs"
onClick={() => {
setSelectedStarLists([...selectedStarLists, normalizedStarListSearch]);
setStarListSearch("");
}}
>
<Plus className="mr-2 h-3.5 w-3.5" />
Add "{normalizedStarListSearch}"
</Button>
</div>
)}
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
Leave empty to mirror all starred repositories. Select one or more lists to limit syncing.
</p>
{selectedStarLists.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedStarLists.map((list) => (
<Badge key={list} variant="secondary" className="gap-1">
<span>{list}</span>
<button
type="button"
onClick={() =>
setSelectedStarLists(
selectedStarLists.filter(
(selected) => selected.toLowerCase() !== list.toLowerCase(),
),
)
}
className="rounded-sm hover:text-foreground/80"
aria-label={`Remove ${list} list`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={customStarListName}
onChange={(event) => setCustomStarListName(event.target.value)}
placeholder="Add custom list name"
className="h-8 text-xs"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={addCustomStarList}
disabled={!customStarListName.trim()}
>
Add
</Button>
</div>
</div>
)}
{/* Duplicate name handling for starred repos */}
{githubConfig.mirrorStarred && (
<div className="mt-4 space-y-2">
@@ -0,0 +1,395 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Bell, Activity, Send } from "lucide-react";
import { toast } from "sonner";
import type { NotificationConfig } from "@/types/config";
import { withBase } from "@/lib/base-path";
interface NotificationSettingsProps {
notificationConfig: NotificationConfig;
onNotificationChange: (config: NotificationConfig) => void;
isAutoSaving?: boolean;
}
export function NotificationSettings({
notificationConfig,
onNotificationChange,
isAutoSaving,
}: NotificationSettingsProps) {
const [isTesting, setIsTesting] = useState(false);
const handleTestNotification = async () => {
setIsTesting(true);
try {
const resp = await fetch(withBase("/api/notifications/test"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationConfig }),
});
const result = await resp.json();
if (result.success) {
toast.success("Test notification sent successfully!");
} else {
toast.error(`Test failed: ${result.error || "Unknown error"}`);
}
} catch (error) {
toast.error(
`Test failed: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsTesting(false);
}
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
{isAutoSaving && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Enable/disable toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifications-enabled" className="text-sm font-medium cursor-pointer">
Enable notifications
</Label>
<p className="text-xs text-muted-foreground">
Receive alerts when mirror jobs complete or fail
</p>
</div>
<Switch
id="notifications-enabled"
checked={notificationConfig.enabled}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, enabled: checked })
}
/>
</div>
{notificationConfig.enabled && (
<>
{/* Provider selector */}
<div className="space-y-2">
<Label htmlFor="notification-provider" className="text-sm font-medium">
Notification provider
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
<SelectTrigger id="notification-provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
</SelectContent>
</Select>
</div>
{/* Ntfy configuration */}
{notificationConfig.provider === "ntfy" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Ntfy.sh Settings</h3>
<div className="space-y-2">
<Label htmlFor="ntfy-url" className="text-sm">
Server URL
</Label>
<Input
id="ntfy-url"
type="url"
placeholder="https://ntfy.sh"
value={notificationConfig.ntfy?.url || "https://ntfy.sh"}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: e.target.value,
topic: notificationConfig.ntfy?.topic || "",
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
<p className="text-xs text-muted-foreground">
Use https://ntfy.sh for the public server or your self-hosted instance URL
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-topic" className="text-sm">
Topic <span className="text-destructive">*</span>
</Label>
<Input
id="ntfy-topic"
placeholder="gitea-mirror"
value={notificationConfig.ntfy?.topic || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
<p className="text-xs text-muted-foreground">
Choose a unique topic name. Anyone with the topic name can subscribe.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-token" className="text-sm">
Access token (optional)
</Label>
<Input
id="ntfy-token"
type="password"
placeholder="tk_..."
value={notificationConfig.ntfy?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
token: e.target.value,
priority: notificationConfig.ntfy?.priority || "default",
},
})
}
/>
<p className="text-xs text-muted-foreground">
Required if your ntfy server uses authentication
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-priority" className="text-sm">
Default priority
</Label>
<Select
value={notificationConfig.ntfy?.priority || "default"}
onValueChange={(value: "min" | "low" | "default" | "high" | "urgent") =>
onNotificationChange({
...notificationConfig,
ntfy: {
...notificationConfig.ntfy!,
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
topic: notificationConfig.ntfy?.topic || "",
priority: value,
},
})
}
>
<SelectTrigger id="ntfy-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="min">Min</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Error notifications always use "high" priority regardless of this setting
</p>
</div>
</div>
)}
{/* Apprise configuration */}
{notificationConfig.provider === "apprise" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Apprise API Settings</h3>
<div className="space-y-2">
<Label htmlFor="apprise-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<Input
id="apprise-url"
type="url"
placeholder="http://apprise:8000"
value={notificationConfig.apprise?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: e.target.value,
token: notificationConfig.apprise?.token || "",
},
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Apprise API server (e.g., http://apprise:8000)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apprise-token" className="text-sm">
Token / path <span className="text-destructive">*</span>
</Label>
<Input
id="apprise-token"
placeholder="gitea-mirror"
value={notificationConfig.apprise?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: e.target.value,
},
})
}
/>
<p className="text-xs text-muted-foreground">
The Apprise API configuration token or key
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apprise-tag" className="text-sm">
Tag filter (optional)
</Label>
<Input
id="apprise-tag"
placeholder="all"
value={notificationConfig.apprise?.tag || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
apprise: {
...notificationConfig.apprise!,
url: notificationConfig.apprise?.url || "",
token: notificationConfig.apprise?.token || "",
tag: e.target.value,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Optional tag to filter which Apprise services receive notifications
</p>
</div>
</div>
)}
{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-sync-error" className="text-sm font-normal cursor-pointer">
Sync errors
</Label>
<p className="text-xs text-muted-foreground">
Notify when a mirror job fails
</p>
</div>
<Switch
id="notify-sync-error"
checked={notificationConfig.notifyOnSyncError}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnSyncError: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-sync-success" className="text-sm font-normal cursor-pointer">
Sync success
</Label>
<p className="text-xs text-muted-foreground">
Notify when a mirror job completes successfully
</p>
</div>
<Switch
id="notify-sync-success"
checked={notificationConfig.notifyOnSyncSuccess}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnSyncSuccess: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-repo" className="text-sm font-normal cursor-pointer text-muted-foreground">
New repository discovered (coming soon)
</Label>
<p className="text-xs text-muted-foreground">
Notify when a new GitHub repository is auto-imported
</p>
</div>
<Switch
id="notify-new-repo"
checked={notificationConfig.notifyOnNewRepo}
disabled
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnNewRepo: checked })
}
/>
</div>
</div>
{/* Test button */}
<div className="flex justify-end">
<Button
variant="outline"
onClick={handleTestNotification}
disabled={isTesting}
>
{isTesting ? (
<>
<Activity className="h-4 w-4 animate-spin mr-2" />
Sending...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Test Notification
</>
)}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}
+10 -6
View File
@@ -14,6 +14,7 @@ import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { MultiSelect } from '@/components/ui/multi-select';
import { withBase } from '@/lib/base-path';
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
try {
@@ -100,6 +101,9 @@ export function SSOSettings() {
digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
});
const appOrigin = typeof window !== 'undefined' ? window.location.origin : '';
const buildAbsoluteAppUrl = (path: string) =>
appOrigin ? new URL(withBase(path), appOrigin).toString() : withBase(path);
@@ -179,8 +183,8 @@ export function SSOSettings() {
} else {
requestData.entryPoint = providerForm.entryPoint;
requestData.cert = providerForm.cert;
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
requestData.audience = providerForm.audience || window.location.origin;
requestData.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
requestData.audience = providerForm.audience || appOrigin;
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
requestData.digestAlgorithm = providerForm.digestAlgorithm;
@@ -517,7 +521,7 @@ export function SSOSettings() {
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
<p>Redirect URL: {buildAbsoluteAppUrl(`/api/auth/sso/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
<p className="text-xs text-muted-foreground">
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
@@ -563,8 +567,8 @@ export function SSOSettings() {
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
<p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
</div>
</AlertDescription>
</Alert>
@@ -724,4 +728,4 @@ export function SSOSettings() {
</Card>
</div>
);
}
}
+2 -1
View File
@@ -16,6 +16,7 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
// Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string {
@@ -110,7 +111,7 @@ export function Dashboard() {
useEffectForToasts(() => {
if (!user?.id) return;
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
const eventSource = new EventSource(`${withBase("/api/events")}?userId=${user.id}`);
eventSource.addEventListener("rate-limit", (event) => {
try {
+3 -2
View File
@@ -3,6 +3,7 @@ import type { MirrorJob } from "@/lib/db/schema";
import { formatDate, getStatusColor } from "@/lib/utils";
import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react";
import { withBase } from "@/lib/base-path";
interface RecentActivityProps {
activities: MirrorJob[];
@@ -14,7 +15,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Activity</CardTitle>
<Button variant="outline" asChild>
<a href="/activity">View All</a>
<a href={withBase("/activity")}>View All</a>
</Button>
</CardHeader>
<CardContent>
@@ -27,7 +28,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<a href="/activity">
<a href={withBase("/activity")}>
<Activity className="h-3.5 w-3.5 mr-1.5" />
View History
</a>
+5 -13
View File
@@ -4,7 +4,9 @@ import { GitFork } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import { getStatusColor } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
interface RepositoryListProps {
repositories: Repository[];
@@ -15,11 +17,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 +35,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 (
@@ -51,7 +43,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Repositories</CardTitle>
<Button variant="outline" asChild>
<a href="/repositories">View All</a>
<a href={withBase("/repositories")}>View All</a>
</Button>
</CardHeader>
<CardContent>
@@ -63,7 +55,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
Configure your GitHub connection to start mirroring repositories.
</p>
<Button asChild>
<a href="/config">Configure GitHub</a>
<a href={withBase("/config")}>Configure GitHub</a>
</Button>
</div>
) : (
+4 -3
View File
@@ -14,6 +14,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { withBase } from "@/lib/base-path";
interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
@@ -101,14 +102,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
<button
onClick={() => {
if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/');
window.history.pushState({}, '', withBase('/'));
onNavigate?.('dashboard');
}
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img
src="/logo.png"
src={withBase('/logo.png')}
alt="Gitea Mirror Logo"
className="h-5 w-6"
/>
@@ -163,7 +164,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
</DropdownMenu>
) : (
<Button variant="outline" size="sm" asChild>
<a href="/login">Login</a>
<a href={withBase('/login')}>Login</a>
</Button>
)}
</div>
+3 -2
View File
@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner";
import { useAuth } from "@/hooks/useAuth";
import { useRepoSync } from "@/hooks/useSyncRepo";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { stripBasePath, withBase } from "@/lib/base-path";
// Navigation context to signal when navigation happens
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
@@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
const path = window.location.pathname;
const path = stripBasePath(window.location.pathname);
const pageMap: Record<string, AppProps['page']> = {
'/': 'dashboard',
'/repositories': 'repositories',
@@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
if (!authLoading && !user) {
// Use window.location for client-side redirect
if (typeof window !== 'undefined') {
window.location.href = '/login';
window.location.href = withBase('/login');
}
return null;
}
+6 -5
View File
@@ -9,6 +9,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { stripBasePath, withBase } from "@/lib/base-path";
interface SidebarProps {
className?: string;
@@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
useEffect(() => {
// Hydration happens here
const path = window.location.pathname;
const path = stripBasePath(window.location.pathname);
setCurrentPath(path);
}, []);
// Listen for URL changes (browser back/forward)
useEffect(() => {
const handlePopState = () => {
setCurrentPath(window.location.pathname);
setCurrentPath(stripBasePath(window.location.pathname));
};
window.addEventListener('popstate', handlePopState);
@@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
if (currentPath === href) return;
// Update URL without page reload
window.history.pushState({}, '', href);
window.history.pushState({}, '', withBase(href));
setCurrentPath(href);
// Map href to page name for the parent component
@@ -163,7 +164,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
href={withBase("/docs")}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
@@ -177,7 +178,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<a
href="/docs"
href={withBase("/docs")}
target="_blank"
rel="noopener noreferrer"
className={cn(
@@ -9,8 +9,10 @@ import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
import {
DropdownMenu,
DropdownMenuContent,
@@ -67,11 +69,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,17 +81,12 @@ 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) => {
// Call API to update organization destination
const response = await fetch(`/api/organizations/${orgId}`, {
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
@@ -198,7 +190,7 @@ export function OrganizationList({
<div className="flex items-center gap-2 min-w-0">
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer truncate"
>
{org.name}
@@ -273,7 +265,7 @@ export function OrganizationList({
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="text-xl font-semibold hover:underline cursor-pointer"
>
{org.name}
+2 -1
View File
@@ -50,6 +50,7 @@ import AddRepositoryDialog from "./AddRepositoryDialog";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
const REPOSITORY_SORT_OPTIONS = [
{ value: "imported-desc", label: "Recently Imported" },
@@ -1518,7 +1519,7 @@ export default function Repository() {
<Button
variant="default"
onClick={() => {
window.history.pushState({}, '', '/config');
window.history.pushState({}, '', withBase('/config'));
// We need to trigger a page change event for the navigation system
window.dispatchEvent(new PopStateEvent('popstate'));
}}
@@ -14,6 +14,7 @@ import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
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";
@@ -27,6 +28,7 @@ import {
import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { withBase } from "@/lib/base-path";
import {
DropdownMenu,
DropdownMenuContent,
@@ -101,7 +103,7 @@ export default function RepositoryTable({
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
// Call API to update repository destination
const response = await fetch(`/api/repositories/${repoId}`, {
const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
@@ -124,10 +126,6 @@ export default function RepositoryTable({
// Helper function to construct Gitea repository URL
const getGiteaRepoUrl = (repository: Repository): string | null => {
if (!giteaConfig?.url) {
return null;
}
// Only provide Gitea links for repositories that have been or are being mirrored
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived'];
if (!validStatuses.includes(repository.status)) {
@@ -144,12 +142,7 @@ 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 = [
+20 -1
View File
@@ -1,7 +1,26 @@
---
import { BASE_PATH_WINDOW_KEY } from '@/lib/base-path';
const normalizeBasePath = (value) => {
if (!value || !value.trim()) {
return '/';
}
let normalized = value.trim();
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, '');
return normalized || '/';
};
const runtimeBasePath = normalizeBasePath(process.env.BASE_URL);
---
<script is:inline>
<script is:inline define:vars={{ BASE_PATH_WINDOW_KEY, runtimeBasePath }}>
window[BASE_PATH_WINDOW_KEY] = runtimeBasePath;
const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
+4 -3
View File
@@ -8,6 +8,7 @@ import {
} from "react";
import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user";
import { withBase } from "@/lib/base-path";
interface AuthContextType {
user: ExtendedUser | null;
@@ -61,9 +62,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Redirect user based on error
if (err?.message === "No users found") {
window.location.href = "/signup";
window.location.href = withBase("/signup");
} else {
window.location.href = "/login";
window.location.href = withBase("/login");
}
console.error("Auth check failed", err);
} finally {
@@ -111,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
await authApi.logout();
setUser(null);
window.location.href = "/login";
window.location.href = withBase("/login");
} catch (err) {
console.error("Logout error:", err);
} finally {
+5 -4
View File
@@ -8,6 +8,7 @@ import {
} from "react";
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
import type { Session, AuthUser } from "@/lib/auth-client";
import { withBase } from "@/lib/base-path";
interface AuthContextType {
user: AuthUser | null;
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const result = await authClient.signIn.email({
email,
password,
callbackURL: "/",
callbackURL: withBase("/"),
});
if (result.error) {
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
email,
password,
name: username, // Better Auth uses 'name' field for display name
callbackURL: "/",
callbackURL: withBase("/"),
});
if (result.error) {
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = "/login";
window.location.href = withBase("/login");
},
},
});
@@ -140,4 +141,4 @@ export function useAuth() {
}
// Export the Better Auth session hook for direct use when needed
export { useBetterAuthSession };
export { useBetterAuthSession };
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react";
import type { MirrorJob } from "@/lib/db/schema";
import { withBase } from "@/lib/base-path";
interface UseSSEOptions {
userId?: string;
@@ -41,7 +42,7 @@ export const useSSE = ({
}
// Create new EventSource connection
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
const eventSource = new EventSource(`${withBase("/api/sse")}?userId=${userId}`);
eventSourceRef.current = eventSource;
const handleMessage = (event: MessageEvent) => {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { useAuth } from "./useAuth";
import { withBase } from "@/lib/base-path";
interface UseRepoSyncOptions {
userId?: string;
@@ -51,7 +52,7 @@ export function useRepoSync({
const sync = async () => {
try {
const response = await fetch("/api/job/schedule-sync-repo", {
const response = await fetch(withBase("/api/job/schedule-sync-repo"), {
method: "POST",
headers: {
"Content-Type": "application/json",
+2 -1
View File
@@ -2,6 +2,7 @@
import '../styles/global.css';
import '../styles/docs.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Accept title as a prop with a default value
const { title = 'Gitea Mirror' } = Astro.props;
@@ -11,7 +12,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<title>{title}</title>
<ThemeScript />
</head>
+7 -1
View File
@@ -1,5 +1,7 @@
import { withBase } from "@/lib/base-path";
// Base API URL
const API_BASE = "/api";
const API_BASE = withBase("/api");
// Helper function for API requests
async function apiRequest<T>(
@@ -78,6 +80,10 @@ export const githubApi = {
method: "POST",
body: JSON.stringify({ token }),
}),
getStarredLists: () =>
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
method: "GET",
}),
};
// Gitea API
+9 -4
View File
@@ -3,6 +3,12 @@ import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
import { withBase } from "@/lib/base-path";
function normalizeAuthBaseUrl(url: string): string {
const validatedUrl = new URL(url.trim());
return validatedUrl.origin;
}
export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
@@ -18,9 +24,8 @@ export const authClient = createAuthClient({
// Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') {
try {
// Validate URL format and remove trailing slash
const validatedUrl = new URL(url.trim());
return validatedUrl.origin; // Use origin to ensure clean URL without path
// Validate URL format and preserve optional base path
return normalizeAuthBaseUrl(url);
} catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
}
@@ -34,7 +39,7 @@ export const authClient = createAuthClient({
// Default for SSR - always return a valid URL
return 'http://localhost:4321';
})(),
basePath: '/api/auth', // Explicitly set the base path
basePath: withBase('/api/auth'), // Explicitly set the base path
plugins: [
oidcClient(),
ssoClient(),
+119
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");
});
});
+76 -46
View File
@@ -5,6 +5,73 @@ import { sso } from "@better-auth/sso";
import { db, users } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
import { withBase } from "./base-path";
/**
* Resolves the list of trusted origins for Better Auth CSRF validation.
* Exported for testing. Called per-request with the incoming Request,
* or at startup with no request (static origins only).
*/
export async function resolveTrustedOrigins(request?: Request): Promise<string[]> {
const origins: string[] = [
"http://localhost:4321",
"http://localhost:8080", // Keycloak
];
// Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL;
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
try {
const validatedUrl = new URL(primaryUrl.trim());
origins.push(validatedUrl.origin);
} catch {
// Skip if invalid
}
}
// Add additional trusted origins from environment
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
}
// Auto-detect origin from the incoming request's Host header when running
// behind a reverse proxy. Helps with Better Auth's per-request CSRF check.
if (request?.headers) {
// Take first value only — headers can be comma-separated in chained proxy setups
const rawHost = request.headers.get("x-forwarded-host") || request.headers.get("host");
const host = rawHost?.split(",")[0].trim();
if (host) {
const rawProto = request.headers.get("x-forwarded-proto") || "http";
const proto = rawProto.split(",")[0].trim().toLowerCase();
if (proto === "http" || proto === "https") {
try {
const detected = new URL(`${proto}://${host}`);
origins.push(detected.origin);
} catch {
// Malformed header, ignore
}
}
}
}
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
if (!request) {
console.info("Trusted origins (static):", uniqueOrigins);
}
return uniqueOrigins;
}
export const auth = betterAuth({
// Database configuration
@@ -31,7 +98,7 @@ export const auth = betterAuth({
try {
// Validate URL format and ensure it's a proper origin
const validatedUrl = new URL(url.trim());
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
const cleanUrl = validatedUrl.origin;
console.info('Using BETTER_AUTH_URL:', cleanUrl);
return cleanUrl;
} catch (e) {
@@ -41,50 +108,13 @@ export const auth = betterAuth({
return defaultUrl;
}
})(),
basePath: "/api/auth", // Specify the base path for auth endpoints
basePath: withBase("/api/auth"), // Specify the base path for auth endpoints
// Trusted origins - this is how we support multiple access URLs
trustedOrigins: (() => {
const origins: string[] = [
"http://localhost:4321",
"http://localhost:8080", // Keycloak
];
// Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL;
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
try {
const validatedUrl = new URL(primaryUrl.trim());
origins.push(validatedUrl.origin);
} catch {
// Skip if invalid
}
}
// Add additional trusted origins from environment
// This is where users can specify multiple access URLs
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
// Validate each additional origin
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
}
// Remove duplicates and empty strings, then return
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
console.info('Trusted origins:', uniqueOrigins);
return uniqueOrigins;
})(),
// Trusted origins - this is how we support multiple access URLs.
// Uses the function form so that the origin can be auto-detected from
// the incoming request's Host / X-Forwarded-* headers, which makes the
// app work behind a reverse proxy without manual env var configuration.
trustedOrigins: (request?: Request) => resolveTrustedOrigins(request),
// Authentication methods
emailAndPassword: {
@@ -121,8 +151,8 @@ export const auth = betterAuth({
plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({
loginPage: "/login",
consentPage: "/oauth/consent",
loginPage: withBase("/login"),
consentPage: withBase("/oauth/consent"),
// Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true,
// Note: trustedClients would be configured here if Better Auth supports it
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test } from "bun:test";
const originalBaseUrl = process.env.BASE_URL;
const originalWindow = (globalThis as { window?: unknown }).window;
async function loadModule(baseUrl?: string, runtimeWindowBasePath?: string) {
if (baseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = baseUrl;
}
if (runtimeWindowBasePath === undefined) {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
const restoredWindow = (globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window;
if (typeof restoredWindow === "object" && restoredWindow !== null) {
delete restoredWindow.__GITEA_MIRROR_BASE_PATH__;
}
}
} else {
(globalThis as { window?: { __GITEA_MIRROR_BASE_PATH__?: string } }).window = {
__GITEA_MIRROR_BASE_PATH__: runtimeWindowBasePath,
};
}
return import(`./base-path.ts?case=${encodeURIComponent(baseUrl ?? "default")}-${Date.now()}-${Math.random()}`);
}
afterEach(() => {
if (originalBaseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = originalBaseUrl;
}
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = originalWindow;
}
});
describe("base-path helpers", () => {
test("defaults to root paths", async () => {
const mod = await loadModule(undefined);
expect(mod.BASE_PATH).toBe("/");
expect(mod.withBase("/api/health")).toBe("/api/health");
expect(mod.withBase("repositories")).toBe("/repositories");
expect(mod.stripBasePath("/config")).toBe("/config");
});
test("normalizes prefixed base paths", async () => {
const mod = await loadModule("mirror/");
expect(mod.BASE_PATH).toBe("/mirror");
expect(mod.withBase("/api/health")).toBe("/mirror/api/health");
expect(mod.withBase("repositories")).toBe("/mirror/repositories");
expect(mod.stripBasePath("/mirror/config")).toBe("/config");
expect(mod.stripBasePath("/mirror")).toBe("/");
});
test("keeps absolute URLs unchanged", async () => {
const mod = await loadModule("/mirror");
expect(mod.withBase("https://example.com/path")).toBe("https://example.com/path");
});
test("uses browser runtime base path when process env is unset", async () => {
const mod = await loadModule(undefined, "/runtime");
expect(mod.BASE_PATH).toBe("/runtime");
expect(mod.withBase("/api/health")).toBe("/runtime/api/health");
expect(mod.stripBasePath("/runtime/config")).toBe("/config");
});
test("prefers process env base path over browser runtime value", async () => {
const mod = await loadModule("/env", "/runtime");
expect(mod.BASE_PATH).toBe("/env");
expect(mod.withBase("/api/health")).toBe("/env/api/health");
});
});
+83
View File
@@ -0,0 +1,83 @@
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
const BASE_PATH_WINDOW_KEY = "__GITEA_MIRROR_BASE_PATH__";
function normalizeBasePath(basePath: string | null | undefined): string {
if (!basePath) {
return "/";
}
let normalized = basePath.trim();
if (!normalized) {
return "/";
}
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/+$/, "");
return normalized || "/";
}
function resolveRuntimeBasePath(): string {
if (typeof process !== "undefined" && typeof process.env?.BASE_URL === "string") {
return normalizeBasePath(process.env.BASE_URL);
}
if (typeof window !== "undefined") {
const runtimeBasePath = (window as Window & { [BASE_PATH_WINDOW_KEY]?: string })[BASE_PATH_WINDOW_KEY];
if (typeof runtimeBasePath === "string") {
return normalizeBasePath(runtimeBasePath);
}
}
return "/";
}
export function getBasePath(): string {
return resolveRuntimeBasePath();
}
export const BASE_PATH = getBasePath();
export { BASE_PATH_WINDOW_KEY };
export function withBase(path: string): string {
const basePath = getBasePath();
if (!path) {
return basePath === "/" ? "/" : `${basePath}/`;
}
if (URL_SCHEME_REGEX.test(path) || path.startsWith("//")) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (basePath === "/") {
return normalizedPath;
}
return `${basePath}${normalizedPath}`;
}
export function stripBasePath(pathname: string): string {
const basePath = getBasePath();
if (!pathname) {
return "/";
}
if (basePath === "/") {
return pathname;
}
if (pathname === basePath || pathname === `${basePath}/`) {
return "/";
}
if (pathname.startsWith(`${basePath}/`)) {
return pathname.slice(basePath.length) || "/";
}
return pathname;
}
+34 -1
View File
@@ -26,6 +26,7 @@ export const githubConfigSchema = z.object({
includeOrganizations: z.array(z.string()).default([]),
starredReposOrg: z.string().optional(),
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"),
starredLists: z.array(z.string()).default([]),
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
defaultOrg: z.string().optional(),
starredCodeOnly: z.boolean().default(false),
@@ -75,7 +76,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 +123,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(),
@@ -336,6 +363,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())`),
@@ -417,6 +449,7 @@ export const repositories = sqliteTable("repositories", {
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", {
+21 -3
View File
@@ -4,7 +4,7 @@
*/
import { db, configs, users } from '@/lib/db';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { encrypt } from '@/lib/utils/encryption';
@@ -25,6 +25,7 @@ interface EnvConfig {
autoMirrorStarred?: boolean;
starredReposOrg?: string;
starredReposMode?: 'dedicated-org' | 'preserve-owner';
starredLists?: string[];
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
};
gitea: {
@@ -99,6 +100,9 @@ function parseEnvConfig(): EnvConfig {
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
: undefined;
const starredLists = process.env.MIRROR_STARRED_LISTS
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
: undefined;
return {
github: {
@@ -117,6 +121,7 @@ function parseEnvConfig(): EnvConfig {
autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true',
starredReposOrg: process.env.STARRED_REPOS_ORG,
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
starredLists,
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
},
gitea: {
@@ -219,10 +224,12 @@ export async function initializeConfigFromEnv(): Promise<void> {
console.log('[ENV Config Loader] Found environment configuration, initializing...');
// Get the first user (admin user)
// Get the first user (admin user) — deterministic order so we always pick the
// same row across restarts even if multiple users exist.
const firstUser = await db
.select()
.from(users)
.orderBy(sql`${users.createdAt} ASC`)
.limit(1);
if (firstUser.length === 0) {
@@ -232,11 +239,14 @@ export async function initializeConfigFromEnv(): Promise<void> {
const userId = firstUser[0].id;
// Check if config already exists for this user
// Check if config already exists for this user — prefer the active config and
// fall back to most-recently-updated so we never write env values into a stale
// inactive stub while the populated active row sits untouched (see issue #271).
const existingConfig = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
// Determine mirror strategy based on environment variables or use explicit value
@@ -267,6 +277,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false,
starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [],
};
// Build Gitea config
@@ -302,6 +313,13 @@ export async function initializeConfigFromEnv(): Promise<void> {
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
mirrorLabels: envConfig.mirror.mirrorLabels ?? existingConfig?.[0]?.giteaConfig?.mirrorLabels ?? false,
mirrorMilestones: envConfig.mirror.mirrorMilestones ?? existingConfig?.[0]?.giteaConfig?.mirrorMilestones ?? false,
// Backup options — preserve existing values so UI-configured settings survive restart
backupStrategy: existingConfig?.[0]?.giteaConfig?.backupStrategy ?? 'on-force-push',
backupBeforeSync: existingConfig?.[0]?.giteaConfig?.backupBeforeSync ?? true,
backupRetentionCount: existingConfig?.[0]?.giteaConfig?.backupRetentionCount ?? 5,
backupRetentionDays: existingConfig?.[0]?.giteaConfig?.backupRetentionDays ?? 30,
backupDirectory: existingConfig?.[0]?.giteaConfig?.backupDirectory || undefined,
blockSyncOnBackupFailure: existingConfig?.[0]?.giteaConfig?.blockSyncOnBackupFailure ?? true,
};
// Build schedule config with support for interval as string or number
+62 -4
View File
@@ -555,6 +555,63 @@ describe("Enhanced Gitea Operations", () => {
expect(releaseCall.octokit).toBeDefined();
});
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("ceph"));
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: true,
},
};
const repository: Repository = {
id: "repo789",
name: "test-repo",
fullName: "ceph/test-repo",
owner: "ceph",
cloneUrl: "https://github.com/ceph/test-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
mirroredLocation: "starred/test-repo",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain("/api/v1/repos/starred/test-repo/mirror-sync");
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
});
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true;
@@ -575,7 +632,7 @@ describe("Enhanced Gitea Operations", () => {
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
backupBeforeSync: true,
backupStrategy: "always",
blockSyncOnBackupFailure: true,
},
};
@@ -732,7 +789,7 @@ describe("Enhanced Gitea Operations", () => {
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
});
test("continues incremental issue and PR syncing when metadata was previously synced", async () => {
test("skips issues and PRs when metadata shows they were already synced", async () => {
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
@@ -791,9 +848,10 @@ describe("Enhanced Gitea Operations", () => {
}
);
// All metadata components were previously synced, so none should be called again
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1);
expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1);
expect(mockMirrorGitRepoIssuesToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoPullRequestsToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled();
});
+143 -31
View File
@@ -8,7 +8,8 @@
import type { Config } from "@/types/config";
import type { Repository } from "./db/schema";
import { Octokit } from "@octokit/rest";
import type { Octokit } from "@octokit/rest";
import { createGitHubClient } from "./github";
import { createMirrorJob } from "./helpers";
import { decryptConfigTokens } from "./utils/config-encryption";
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
@@ -52,6 +53,41 @@ interface GiteaRepoInfo {
private: boolean;
}
interface SyncTargetCandidate {
owner: string;
repoName: string;
}
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
if (!location) return null;
const trimmed = location.trim();
if (!trimmed) return null;
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return null;
const owner = trimmed.slice(0, slashIndex).trim();
const repoName = trimmed.slice(slashIndex + 1).trim();
if (!owner || !repoName) return null;
return { owner, repoName };
}
function dedupeSyncTargets(targets: SyncTargetCandidate[]): SyncTargetCandidate[] {
const seen = new Set<string>();
const deduped: SyncTargetCandidate[] = [];
for (const target of targets) {
const key = `${target.owner}/${target.repoName}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(target);
}
return deduped;
}
/**
* Check if a repository exists in Gitea and return its details
*/
@@ -285,19 +321,78 @@ export async function syncGiteaRepoEnhanced({
})
.where(eq(repositories.id, repository.id!));
// Get the expected owner
// Resolve sync target in a backward-compatible order:
// 1) recorded mirroredLocation (actual historical mirror location)
// 2) owner derived from current strategy/config
const dependencies = deps ?? (await import("./gitea"));
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
const candidateTargets = dedupeSyncTargets([
...(recordedTarget ? [recordedTarget] : []),
{ owner: expectedOwner, repoName: repository.name },
]);
// Check if repo exists and get its info
const repoInfo = await getGiteaRepoInfo({
config,
owner: repoOwner,
repoName: repository.name,
});
let repoOwner = expectedOwner;
let repoName = repository.name;
let repoInfo: GiteaRepoInfo | null = null;
let firstNonMirrorTarget: SyncTargetCandidate | null = null;
for (const target of candidateTargets) {
const candidateInfo = await getGiteaRepoInfo({
config,
owner: target.owner,
repoName: target.repoName,
});
if (!candidateInfo) {
continue;
}
if (!candidateInfo.mirror) {
if (!firstNonMirrorTarget) {
firstNonMirrorTarget = target;
}
continue;
}
repoOwner = target.owner;
repoName = target.repoName;
repoInfo = candidateInfo;
break;
}
if (!repoInfo) {
throw new Error(`Repository ${repository.name} not found in Gitea at ${repoOwner}/${repository.name}`);
if (firstNonMirrorTarget) {
console.warn(
`[Sync] Repository ${repository.name} exists at ${firstNonMirrorTarget.owner}/${firstNonMirrorTarget.repoName} but is not configured as a mirror`
);
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: "Repository exists in Gitea but is not configured as a mirror. Manual intervention required.",
})
.where(eq(repositories.id, repository.id!));
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Cannot sync ${repository.name}: Not a mirror repository`,
details: `Repository ${repository.name} exists in Gitea but is not configured as a mirror. You may need to delete and recreate it as a mirror, or manually configure it as a mirror in Gitea.`,
status: "failed",
});
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
}
throw new Error(
`Repository ${repository.name} not found in Gitea. Tried locations: ${candidateTargets
.map((t) => `${t.owner}/${t.repoName}`)
.join(", ")}`
);
}
// Check if it's a mirror repository
@@ -337,12 +432,12 @@ export async function syncGiteaRepoEnhanced({
try {
const decryptedGithubToken = decryptedConfig.githubConfig?.token;
if (decryptedGithubToken) {
const fpOctokit = new Octokit({ auth: decryptedGithubToken });
const fpOctokit = createGitHubClient(decryptedGithubToken);
const detectionResult = await detectForcePush({
giteaUrl: config.giteaConfig.url,
giteaToken: decryptedConfig.giteaConfig.token,
giteaOwner: repoOwner,
giteaRepo: repository.name,
giteaRepo: repoName,
octokit: fpOctokit,
githubOwner: repository.owner,
githubRepo: repository.name,
@@ -407,13 +502,13 @@ export async function syncGiteaRepoEnhanced({
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
const cloneUrl =
repoInfo.clone_url ||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`;
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repoName}.git`;
try {
const backupResult = await createPreSyncBundleBackup({
config,
owner: repoOwner,
repoName: repository.name,
repoName,
cloneUrl,
force: true, // Strategy already decided to backup; skip legacy gate
});
@@ -462,24 +557,27 @@ export async function syncGiteaRepoEnhanced({
}
// Update mirror interval if needed
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
// updating mirror credentials (mirror_username/mirror_password). Repos that
// were originally migrated without credentials must be deleted and re-mirrored.
if (config.giteaConfig?.mirrorInterval) {
try {
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`;
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
await httpPatch(updateUrl, {
mirror_interval: config.giteaConfig.mirrorInterval,
}, {
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
});
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
console.log(`[Sync] Successfully updated mirror interval for ${repoOwner}/${repoName}`);
} catch (updateError) {
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError);
console.warn(`[Sync] Failed to update mirror interval for ${repoOwner}/${repoName}:`, updateError);
// Continue with sync even if interval update fails
}
}
// Perform the sync
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
try {
const response = await httpPost(apiUrl, undefined, {
@@ -499,9 +597,7 @@ export async function syncGiteaRepoEnhanced({
if (!decryptedConfig.githubConfig?.token) {
return null;
}
metadataOctokit = new Octokit({
auth: decryptedConfig.githubConfig.token,
});
metadataOctokit = createGitHubClient(decryptedConfig.githubConfig.token);
return metadataOctokit;
};
@@ -509,10 +605,12 @@ export async function syncGiteaRepoEnhanced({
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues &&
!skipMetadataForStarred;
!skipMetadataForStarred &&
!metadataState.components.issues;
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests &&
!skipMetadataForStarred;
!skipMetadataForStarred &&
!metadataState.components.pullRequests;
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
@@ -536,7 +634,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.releases = true;
metadataUpdated = true;
@@ -568,7 +666,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.issues = true;
metadataState.components.labels = true;
@@ -586,6 +684,13 @@ export async function syncGiteaRepoEnhanced({
);
}
}
} else if (
config.giteaConfig?.mirrorIssues &&
metadataState.components.issues
) {
console.log(
`[Sync] Issues already mirrored for ${repository.name}; skipping to avoid duplicates`
);
}
if (shouldMirrorPullRequests) {
@@ -601,7 +706,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.pullRequests = true;
metadataUpdated = true;
@@ -616,6 +721,13 @@ export async function syncGiteaRepoEnhanced({
);
}
}
} else if (
config.giteaConfig?.mirrorPullRequests &&
metadataState.components.pullRequests
) {
console.log(
`[Sync] Pull requests already mirrored for ${repository.name}; skipping`
);
}
if (shouldMirrorLabels) {
@@ -631,7 +743,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.labels = true;
metadataUpdated = true;
@@ -670,7 +782,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
@@ -708,7 +820,7 @@ export async function syncGiteaRepoEnhanced({
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${repoOwner}/${repository.name}`,
mirroredLocation: `${repoOwner}/${repoName}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
: repository.metadata ?? null,
@@ -720,7 +832,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 ${repoOwner}/${repoName}.`,
status: "synced",
});
+45
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
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;
}
+230 -45
View File
@@ -10,9 +10,10 @@ 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 { buildGithubSourceAuthPayload } from "./utils/mirror-source-auth";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
@@ -586,6 +587,7 @@ export const mirrorGithubRepoToGitea = async ({
orgName: repoOwner,
baseName: repository.name,
githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy,
});
@@ -813,18 +815,32 @@ export const mirrorGithubRepoToGitea = async ({
service: "git",
};
// Add authentication for private repositories
if (repository.isPrivate) {
if (!config.githubConfig.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
// Use separate auth fields (required for Forgejo 12+ compatibility)
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token;
// Always send authentication credentials so Gitea/Forgejo stores them
// for subsequent mirror fetches. This prevents "terminal prompts disabled"
// errors on public repos and raises GitHub API rate limits.
{
const githubOwner =
(
config.githubConfig as typeof config.githubConfig & {
owner?: string;
}
).owner || "";
Object.assign(
migratePayload,
buildGithubSourceAuthPayload({
token: decryptedConfig.githubConfig.token,
githubOwner,
githubUsername: config.githubConfig.username,
repositoryOwner: repository.owner,
})
);
}
// 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,
@@ -833,6 +849,8 @@ export const mirrorGithubRepoToGitea = async ({
}
);
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({
config,
octokit,
@@ -1075,14 +1093,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
@@ -1133,29 +1158,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;
@@ -1187,11 +1286,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}`);
@@ -1254,6 +1349,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
orgName,
baseName: repository.name,
githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy,
});
@@ -1407,20 +1503,33 @@ export async function mirrorGitHubRepoToGiteaOrg({
lfs: config.giteaConfig?.lfs || false,
private: repository.isPrivate,
description: repository.description?.trim() || "",
service: "git",
};
// Add authentication for private repositories
if (repository.isPrivate) {
if (!config.githubConfig?.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
// Use separate auth fields (required for Forgejo 12+ compatibility)
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token;
// Always send authentication credentials so Gitea/Forgejo stores them
// for subsequent mirror fetches. This prevents "terminal prompts disabled"
// errors on public repos and raises GitHub API rate limits.
{
const githubOwner =
(
config.githubConfig as typeof config.githubConfig & {
owner?: string;
}
)?.owner || "";
Object.assign(
migratePayload,
buildGithubSourceAuthPayload({
token: decryptedConfig.githubConfig?.token,
githubOwner,
githubUsername: config.githubConfig?.username,
repositoryOwner: repository.owner,
})
);
}
let migrateSucceeded = false;
const migrateRes = await httpPost(
apiUrl,
migratePayload,
@@ -1429,6 +1538,8 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
);
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({
config,
octokit,
@@ -1676,14 +1787,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
@@ -2600,7 +2720,7 @@ export async function mirrorGitHubReleasesToGitea({
if (existingNote !== releaseNote || existingRelease.name !== (release.name || release.tag_name)) {
console.log(`[Releases] Updating existing release ${release.tag_name} with new changelog/title`);
await httpPut(
await httpPatch(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
{
tag_name: release.tag_name,
@@ -2714,6 +2834,71 @@ export async function mirrorGitHubReleasesToGitea({
}
console.log(`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date)`);
// Enforce release retention limit by removing the oldest excess releases from Gitea
try {
// Paginate to fetch ALL Gitea releases (API max is 100 per page)
const allGiteaReleases: Array<{ id: number; tag_name: string; created_at: string }> = [];
let cleanupPage = 1;
while (true) {
const pageResponse = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases?per_page=100&page=${cleanupPage}`,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
).catch(() => null);
if (!pageResponse?.data || !Array.isArray(pageResponse.data) || pageResponse.data.length === 0) {
break;
}
allGiteaReleases.push(...pageResponse.data);
if (pageResponse.data.length < 100) {
break;
}
cleanupPage++;
}
if (allGiteaReleases.length > releaseLimit) {
const excessCount = allGiteaReleases.length - releaseLimit;
// Sort by created_at ascending (oldest first) so we delete the oldest excess
const sorted = [...allGiteaReleases].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
const toDelete = sorted.slice(0, excessCount);
console.log(
`[Releases] Enforcing retention limit (${releaseLimit}): ${allGiteaReleases.length} releases found, removing ${toDelete.length} oldest excess release(s)`
);
for (const excess of toDelete) {
try {
await httpDelete(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${excess.id}`,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
console.log(`[Releases] Deleted excess release: ${excess.tag_name}`);
} catch (deleteError) {
console.error(
`[Releases] Failed to delete excess release ${excess.tag_name}: ${
deleteError instanceof Error ? deleteError.message : String(deleteError)
}`
);
}
}
}
} catch (cleanupError) {
console.warn(
`[Releases] Release retention cleanup failed: ${
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
}`
);
}
}
export async function mirrorGitRepoPullRequestsToGitea({
+319
View File
@@ -0,0 +1,319 @@
import { describe, expect, test, mock } from "bun:test";
import {
getGithubStarredListNames,
getGithubStarredRepositories,
} from "@/lib/github";
function makeRestStarredRepo(overrides: Record<string, unknown> = {}) {
return {
name: "demo",
full_name: "acme/demo",
html_url: "https://github.com/acme/demo",
clone_url: "https://github.com/acme/demo.git",
owner: {
login: "acme",
type: "Organization",
},
private: false,
fork: false,
has_issues: true,
archived: false,
size: 123,
language: "TypeScript",
description: "Demo",
default_branch: "main",
visibility: "public",
disabled: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
...overrides,
};
}
function makeGraphqlListRepo(
nameWithOwner: string,
overrides: Record<string, unknown> = {},
) {
const [owner, name] = nameWithOwner.split("/");
return {
__typename: "Repository" as const,
name,
nameWithOwner,
url: `https://github.com/${nameWithOwner}`,
sshUrl: `git@github.com:${nameWithOwner}.git`,
isPrivate: false,
isFork: false,
isArchived: false,
isDisabled: false,
hasIssuesEnabled: true,
diskUsage: 456,
description: `${name} repo`,
defaultBranchRef: { name: "main" },
visibility: "PUBLIC" as const,
updatedAt: "2024-01-02T00:00:00Z",
createdAt: "2024-01-01T00:00:00Z",
owner: {
__typename: "Organization" as const,
login: owner,
},
primaryLanguage: { name: "TypeScript" },
...overrides,
};
}
describe("GitHub starred lists support", () => {
test("falls back to REST starred endpoint when no lists are configured", async () => {
const paginate = mock(async () => [makeRestStarredRepo()]);
const graphql = mock(async () => {
throw new Error("GraphQL should not be used in REST fallback path");
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: { githubConfig: { starredLists: [] } } as any,
});
expect(repos).toHaveLength(1);
expect(repos[0].fullName).toBe("acme/demo");
expect(repos[0].isStarred).toBe(true);
expect(paginate).toHaveBeenCalledTimes(1);
expect(graphql).toHaveBeenCalledTimes(0);
});
test("filters starred repositories by configured list names and de-duplicates", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [
null,
{ id: "list-1", name: "HomeLab" },
{ id: "list-2", name: "DotTools" },
{ id: "list-3", name: "Ideas" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-1") {
return {
node: {
items: {
nodes: [
null,
makeGraphqlListRepo("acme/repo-a"),
makeGraphqlListRepo("acme/repo-b"),
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [
makeGraphqlListRepo("acme/repo-b"),
makeGraphqlListRepo("acme/repo-c"),
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["homelab", "dottools"],
},
} as any,
});
expect(repos).toHaveLength(3);
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
"acme/repo-a",
"acme/repo-b",
"acme/repo-c",
]);
expect(paginate).toHaveBeenCalledTimes(0);
});
test("matches configured list names even when separators differ", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [
{ id: "list-1", name: "UI Frontend" },
{ id: "list-2", name: "Email | Self - Hosted" },
{ id: "list-3", name: "PaaS | Hosting | Deploy" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-1") {
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/ui-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-2") {
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/email-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/paas-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["ui-frontend", "email-self-hosted", "paas-hosting-deploy"],
},
} as any,
});
expect(repos).toHaveLength(3);
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
"acme/email-app",
"acme/paas-app",
"acme/ui-app",
]);
expect(paginate).toHaveBeenCalledTimes(0);
});
test("throws when configured star list names do not match any GitHub list", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [{ id: "list-1", name: "HomeLab" }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
await expect(
getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["MissingList"],
},
} as any,
}),
).rejects.toThrow("Configured GitHub star lists not found");
expect(paginate).toHaveBeenCalledTimes(0);
});
test("returns all available starred list names with pagination", async () => {
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables?.after) {
return {
viewer: {
lists: {
nodes: [
null,
{ id: "a", name: "HomeLab" },
{ id: "b", name: "DotTools" },
],
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
},
},
};
}
return {
viewer: {
lists: {
nodes: [
{ id: "c", name: "Ideas" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = { graphql } as any;
const lists = await getGithubStarredListNames({ octokit });
expect(lists).toEqual(["HomeLab", "DotTools", "Ideas"]);
expect(graphql).toHaveBeenCalledTimes(2);
});
});
+282
View File
@@ -300,6 +300,239 @@ export async function getGithubRepositories({
}
}
function getStarredListMatchKey(rawValue: string): string {
const normalized = rawValue.normalize("NFKC").trim().toLowerCase();
const tokens = normalized.match(/[\p{L}\p{N}]+/gu);
return tokens ? tokens.join("") : "";
}
function normalizeStarredListNames(rawLists: unknown): string[] {
if (!Array.isArray(rawLists)) return [];
const deduped = new Map<string, string>();
for (const value of rawLists) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (!trimmed) continue;
const matchKey = getStarredListMatchKey(trimmed);
if (!matchKey || deduped.has(matchKey)) continue;
deduped.set(matchKey, trimmed);
}
return [...deduped.values()];
}
function toHttpsCloneUrl(repoUrl: string): string {
return repoUrl.endsWith(".git") ? repoUrl : `${repoUrl}.git`;
}
interface GitHubStarListNode {
id: string;
name: string;
}
interface GitHubRepositoryListItem {
__typename: "Repository";
name: string;
nameWithOwner: string;
url: string;
sshUrl: string;
isPrivate: boolean;
isFork: boolean;
isArchived: boolean;
isDisabled: boolean;
hasIssuesEnabled: boolean;
diskUsage: number;
description: string | null;
defaultBranchRef: { name: string } | null;
visibility: "PUBLIC" | "PRIVATE" | "INTERNAL";
updatedAt: string;
createdAt: string;
owner: {
__typename: "Organization" | "User" | string;
login: string;
};
primaryLanguage: {
name: string;
} | null;
}
async function getGithubStarLists(octokit: Octokit): Promise<GitHubStarListNode[]> {
const allLists: GitHubStarListNode[] = [];
let cursor: string | null = null;
do {
const result = await octokit.graphql<{
viewer: {
lists: {
nodes: Array<GitHubStarListNode | null> | null;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
};
}>(
`
query($after: String) {
viewer {
lists(first: 50, after: $after) {
nodes {
id
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{ after: cursor },
);
const lists = (result.viewer.lists.nodes ?? []).filter(
(list): list is GitHubStarListNode =>
!!list &&
typeof list.id === "string" &&
typeof list.name === "string",
);
allLists.push(...lists);
if (!result.viewer.lists.pageInfo.hasNextPage) break;
cursor = result.viewer.lists.pageInfo.endCursor;
} while (cursor);
return allLists;
}
async function getGithubRepositoriesForStarList(
octokit: Octokit,
listId: string,
): Promise<GitHubRepositoryListItem[]> {
const repositories: GitHubRepositoryListItem[] = [];
let cursor: string | null = null;
do {
const result = await octokit.graphql<{
node: {
items: {
nodes: Array<GitHubRepositoryListItem | null> | null;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
} | null;
}>(
`
query($listId: ID!, $after: String) {
node(id: $listId) {
... on UserList {
items(first: 100, after: $after) {
nodes {
__typename
... on Repository {
name
nameWithOwner
url
sshUrl
isPrivate
isFork
isArchived
isDisabled
hasIssuesEnabled
diskUsage
description
defaultBranchRef {
name
}
visibility
updatedAt
createdAt
owner {
__typename
login
}
primaryLanguage {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`,
{ listId, after: cursor },
);
const listNode = result.node;
if (!listNode) break;
const nodes = listNode.items.nodes ?? [];
for (const node of nodes) {
if (node?.__typename === "Repository") {
repositories.push(node);
}
}
if (!listNode.items.pageInfo.hasNextPage) break;
cursor = listNode.items.pageInfo.endCursor;
} while (cursor);
return repositories;
}
function mapGraphqlRepoToGitRepo(repo: GitHubRepositoryListItem): GitRepo {
const visibility = (repo.visibility ?? "PUBLIC").toLowerCase() as GitRepo["visibility"];
const createdAt = repo.createdAt ? new Date(repo.createdAt) : new Date();
const updatedAt = repo.updatedAt ? new Date(repo.updatedAt) : new Date();
return {
name: repo.name,
fullName: repo.nameWithOwner,
url: repo.url,
cloneUrl: toHttpsCloneUrl(repo.url),
owner: repo.owner.login,
organization: repo.owner.__typename === "Organization" ? repo.owner.login : undefined,
mirroredLocation: "",
destinationOrg: null,
isPrivate: repo.isPrivate,
isForked: repo.isFork,
forkedFrom: undefined,
hasIssues: repo.hasIssuesEnabled,
isStarred: true,
isArchived: repo.isArchived,
size: repo.diskUsage ?? 0,
hasLFS: false,
hasSubmodules: false,
language: repo.primaryLanguage?.name ?? null,
description: repo.description,
defaultBranch: repo.defaultBranchRef?.name || "main",
visibility,
status: "imported",
isDisabled: repo.isDisabled,
lastMirrored: undefined,
errorMessage: undefined,
importedAt: new Date(),
createdAt,
updatedAt,
};
}
export async function getGithubStarredRepositories({
octokit,
config,
@@ -308,6 +541,46 @@ export async function getGithubStarredRepositories({
config: Partial<Config>;
}): Promise<GitRepo[]> {
try {
const configuredLists = normalizeStarredListNames(
config.githubConfig?.starredLists,
);
if (configuredLists.length > 0) {
const allLists = await getGithubStarLists(octokit);
const configuredMatchKeySet = new Set(
configuredLists.map((list) => getStarredListMatchKey(list)),
);
const matchedLists = allLists.filter((list) =>
configuredMatchKeySet.has(getStarredListMatchKey(list.name)),
);
if (matchedLists.length === 0) {
const availableListNames = normalizeStarredListNames(
allLists.map((list) => list.name),
);
const preview = availableListNames.slice(0, 20).join(", ");
const availableSuffix = preview
? `. Available lists: ${preview}${availableListNames.length > 20 ? ", ..." : ""}`
: "";
throw new Error(
`Configured GitHub star lists not found: ${configuredLists.join(", ")}${availableSuffix}`,
);
}
const deduped = new Map<string, GitRepo>();
for (const list of matchedLists) {
const repos = await getGithubRepositoriesForStarList(octokit, list.id);
for (const repo of repos) {
const key = repo.nameWithOwner.toLowerCase();
if (deduped.has(key)) continue;
deduped.set(key, mapGraphqlRepoToGitRepo(repo));
}
}
return [...deduped.values()];
}
const starredRepos = await octokit.paginate(
octokit.activity.listReposStarredByAuthenticatedUser,
{
@@ -362,6 +635,15 @@ export async function getGithubStarredRepositories({
}
}
export async function getGithubStarredListNames({
octokit,
}: {
octokit: Octokit;
}): Promise<string[]> {
const lists = await getGithubStarLists(octokit);
return normalizeStarredListNames(lists.map((list) => list.name));
}
/**
* Get user github organizations
*/
+13 -1
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);
+221
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");
});
});
+191
View File
@@ -0,0 +1,191 @@
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, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
function sanitizeTestNotificationError(error: unknown): string {
if (!(error instanceof Error)) {
return "Failed to send test notification";
}
const safeErrorPatterns = [
/topic is required/i,
/url and token are required/i,
/unknown provider/i,
/bad request/i,
/unauthorized/i,
/forbidden/i,
/not found/i,
/timeout/i,
/network error/i,
/invalid/i,
];
if (safeErrorPatterns.some((pattern) => pattern.test(error.message))) {
return error.message;
}
return "Failed to send test notification";
}
/**
* 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) {
return { success: false, error: sanitizeTestNotificationError(error) };
}
}
/**
* 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 — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const configResults = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.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);
}
}
+98
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");
});
});
+15
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()}`);
}
+95
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
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()}`);
}
+7 -3
View File
@@ -5,7 +5,7 @@
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
import { db, repositories, organizations, mirrorJobs, configs } from './db';
import { eq, and, lt, inArray } from 'drizzle-orm';
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
import { createGitHubClient } from './github';
import { processWithResilience } from './utils/concurrency';
@@ -216,11 +216,13 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
console.log(`Recovering mirror job ${job.id} with ${remainingItemIds.length} remaining items`);
try {
// Get the config for this user with better error handling
// Get the config for this user — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const userConfigs = await db
.select()
.from(configs)
.where(eq(configs.userId, job.userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (userConfigs.length === 0) {
@@ -347,11 +349,13 @@ async function recoverSyncJob(job: any, remainingItemIds: string[]) {
console.log(`Recovering sync job ${job.id} with ${remainingItemIds.length} remaining items`);
try {
// Get the config for this user with better error handling
// Get the config for this user — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const userConfigs = await db
.select()
.from(configs)
.where(eq(configs.userId, job.userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (userConfigs.length === 0) {
+2 -2
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)", () => {
+44 -7
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 });
+89 -97
View File
@@ -8,34 +8,72 @@ import { db, configs, repositories } from '@/lib/db';
import { eq, and, or } from 'drizzle-orm';
import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea';
import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption';
import { parseInterval, formatDuration } from '@/lib/utils/duration-parser';
import { formatDuration } from '@/lib/utils/duration-parser';
import type { Repository } from '@/lib/db/schema';
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
import { createMirrorJob } from '@/lib/helpers';
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
let schedulerInterval: NodeJS.Timeout | null = null;
let isSchedulerRunning = false;
let hasPerformedAutoStart = false; // Track if we've already done auto-start
/**
* Parse schedule interval with enhanced support for duration strings, cron, and numbers
* Supports formats like: "8h", "30m", "24h", "0 0/2 * * *", or plain numbers (seconds)
*/
function parseScheduleInterval(interval: string | number): number {
function resolveScheduleSettings(config: any): { source: string | number; timezone: string } {
const scheduleConfig = config.scheduleConfig || {};
const source = scheduleConfig.interval ||
config.giteaConfig?.mirrorInterval ||
'1h';
const timezone = normalizeTimezone(scheduleConfig.timezone || 'UTC');
return { source, timezone };
}
function calculateNextRun(config: any, currentTime: Date): Date {
const { source, timezone } = resolveScheduleSettings(config);
try {
const milliseconds = parseInterval(interval);
console.log(`[Scheduler] Parsed interval "${interval}" as ${formatDuration(milliseconds)}`);
return milliseconds;
return getNextScheduledRun(source, currentTime, timezone);
} catch (error) {
console.error(`[Scheduler] Failed to parse interval "${interval}": ${error instanceof Error ? error.message : 'Unknown error'}`);
const defaultInterval = 60 * 60 * 1000; // 1 hour
console.log(`[Scheduler] Using default interval: ${formatDuration(defaultInterval)}`);
return defaultInterval;
console.error(
`[Scheduler] Failed to calculate next run from source "${String(source)}" (timezone=${timezone}): ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
const fallbackMs = 60 * 60 * 1000; // 1 hour
return new Date(currentTime.getTime() + fallbackMs);
}
}
function logNextRun(userId: string, source: string | number, timezone: string, currentTime: Date, nextRun: Date): void {
const deltaMs = Math.max(0, nextRun.getTime() - currentTime.getTime());
const scheduleKind = isCronExpression(source) ? 'cron' : 'interval';
console.log(
`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} ` +
`(in ${formatDuration(deltaMs)}) using ${scheduleKind} "${String(source)}" [timezone=${timezone}]`
);
}
async function persistScheduleRunState(config: any, currentTime: Date, forceEnabled = false): Promise<Date> {
const scheduleConfig = config.scheduleConfig || {};
const { source, timezone } = resolveScheduleSettings(config);
const nextRun = calculateNextRun(config, currentTime);
await db.update(configs).set({
scheduleConfig: {
...scheduleConfig,
...(forceEnabled ? { enabled: true } : {}),
lastRun: currentTime,
nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
logNextRun(config.userId, source, timezone, currentTime, nextRun);
return nextRun;
}
/**
* Run scheduled mirror sync for a single user configuration
*/
@@ -53,43 +91,22 @@ async function runScheduledSync(config: any): Promise<void> {
// Update lastRun timestamp
const currentTime = new Date();
const scheduleConfig = config.scheduleConfig || {};
// Priority order: scheduleConfig.interval > giteaConfig.mirrorInterval > default
const intervalSource = scheduleConfig.interval ||
config.giteaConfig?.mirrorInterval ||
'1h'; // Default to 1 hour instead of 3600 seconds
console.log(`[Scheduler] Using interval source for user ${userId}: ${intervalSource}`);
const interval = parseScheduleInterval(intervalSource);
// Note: The interval timing is calculated from the LAST RUN time, not from container startup
// This means if GITEA_MIRROR_INTERVAL=8h, the next sync will be 8 hours from the last completed sync
const nextRun = new Date(currentTime.getTime() + interval);
console.log(`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} (in ${formatDuration(interval)})`);
await db.update(configs).set({
scheduleConfig: {
...scheduleConfig,
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
const { source, timezone } = resolveScheduleSettings(config);
console.log(`[Scheduler] Using schedule source for user ${userId}: ${String(source)} (timezone=${timezone})`);
await persistScheduleRunState(config, currentTime);
// Auto-discovery: Check for new GitHub repositories
if (scheduleConfig.autoImport !== false) {
console.log(`[Scheduler] Checking for new GitHub repositories for user ${userId}...`);
try {
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
const { getGithubRepositories, getGithubStarredRepositories, createGitHubClient } = await import('@/lib/github');
const { v4: uuidv4 } = await import('uuid');
const { getDecryptedGitHubToken } = await import('@/lib/utils/config-encryption');
// Create GitHub client
// Create GitHub client (honors GH_API_URL for GHES / GHEC data residency)
const decryptedToken = getDecryptedGitHubToken(config);
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: decryptedToken });
const octokit = createGitHubClient(decryptedToken, userId, config.githubConfig?.owner);
// Fetch GitHub data
const [basicAndForkedRepos, starredRepos] = await Promise.all([
getGithubRepositories({ octokit, config }),
@@ -99,7 +116,7 @@ async function runScheduledSync(config: any): Promise<void> {
]);
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
const mirrorableGithubRepos = allGithubRepos.filter(isMirrorableGitHubRepo);
// Check for new repositories
const existingRepos = await db
.select({ normalizedFullName: repositories.normalizedFullName })
@@ -220,10 +237,10 @@ async function runScheduledSync(config: any): Promise<void> {
if (reposNeedingMirror.length > 0) {
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need initial mirroring`);
// Prepare Octokit client
// Prepare Octokit client (honors GH_API_URL for GHES / GHEC data residency)
const decryptedToken = getDecryptedGitHubToken(config);
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: decryptedToken });
const { createGitHubClient } = await import('@/lib/github');
const octokit = createGitHubClient(decryptedToken, userId, config.githubConfig?.owner);
// Process repositories in batches
const batchSize = scheduleConfig.batchSize || 10;
@@ -464,13 +481,12 @@ async function performInitialAutoStart(): Promise<void> {
try {
// Step 1: Import repositories from GitHub
console.log(`[Scheduler] Step 1: Importing repositories from GitHub for user ${config.userId}...`);
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
const { getGithubRepositories, getGithubStarredRepositories, createGitHubClient } = await import('@/lib/github');
const { v4: uuidv4 } = await import('uuid');
// Create GitHub client
// Create GitHub client (honors GH_API_URL for GHES / GHEC data residency)
const decryptedToken = getDecryptedGitHubToken(config);
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: decryptedToken });
const octokit = createGitHubClient(decryptedToken, config.userId, config.githubConfig?.owner);
// Fetch GitHub data
const [basicAndForkedRepos, starredRepos] = await Promise.all([
@@ -553,22 +569,7 @@ async function performInitialAutoStart(): Promise<void> {
// Still update the schedule config to indicate scheduling is active
const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval ||
config.giteaConfig?.mirrorInterval ||
'8h';
const interval = parseScheduleInterval(intervalSource);
const nextRun = new Date(currentTime.getTime() + interval);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true,
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
const nextRun = await persistScheduleRunState(config, currentTime, true);
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
continue;
}
@@ -580,21 +581,7 @@ async function performInitialAutoStart(): Promise<void> {
// Still update schedule config timestamps
const currentTime2 = new Date();
const intervalSource2 = config.scheduleConfig?.interval ||
config.giteaConfig?.mirrorInterval ||
'8h';
const interval2 = parseScheduleInterval(intervalSource2);
const nextRun2 = new Date(currentTime2.getTime() + interval2);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true,
lastRun: currentTime2,
nextRun: nextRun2,
},
updatedAt: currentTime2,
}).where(eq(configs.id, config.id));
const nextRun2 = await persistScheduleRunState(config, currentTime2, true);
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`);
continue;
@@ -681,21 +668,7 @@ async function performInitialAutoStart(): Promise<void> {
// Update the schedule config to indicate we've run
const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval ||
config.giteaConfig?.mirrorInterval ||
'8h';
const interval = parseScheduleInterval(intervalSource);
const nextRun = new Date(currentTime.getTime() + interval);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true, // Ensure scheduling is enabled
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
const nextRun = await persistScheduleRunState(config, currentTime, true);
console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
@@ -772,6 +745,25 @@ async function schedulerLoop(): Promise<void> {
for (const config of validConfigs) {
const scheduleConfig = config.scheduleConfig || {};
const { source, timezone } = resolveScheduleSettings(config);
// For clock-based schedules, initialize nextRun instead of running immediately.
if (!scheduleConfig.nextRun && isCronExpression(source)) {
const initializedNextRun = calculateNextRun(config, currentTime);
await db.update(configs).set({
scheduleConfig: {
...scheduleConfig,
nextRun: initializedNextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
console.log(
`[Scheduler] Initialized next run for user ${config.userId}: ${initializedNextRun.toISOString()} ` +
`from cron "${source}" [timezone=${timezone}]`
);
continue;
}
// Check if it's time to run based on nextRun
if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) {
+2 -1
View File
@@ -2,8 +2,9 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { httpRequest, HttpError } from "@/lib/http-client";
import type { RepoStatus } from "@/types/Repository";
import { withBase } from "@/lib/base-path";
export const API_BASE = "/api";
export const API_BASE = withBase("/api");
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
+23 -8
View File
@@ -1,7 +1,8 @@
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { encrypt } from "@/lib/utils/encryption";
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
export interface DefaultConfigOptions {
userId: string;
@@ -13,7 +14,7 @@ export interface DefaultConfigOptions {
giteaToken?: string;
giteaUsername?: string;
scheduleEnabled?: boolean;
scheduleInterval?: number;
scheduleInterval?: number | string;
cleanupEnabled?: boolean;
cleanupRetentionDays?: number;
};
@@ -24,11 +25,13 @@ export interface DefaultConfigOptions {
* Environment variables can override these defaults
*/
export async function createDefaultConfig({ userId, envOverrides = {} }: DefaultConfigOptions) {
// Check if config already exists
// Check if config already exists — prefer active and most-recently-updated
// to avoid returning a stale inactive stub when multiple rows exist (see issue #271).
const existingConfig = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (existingConfig.length > 0) {
@@ -47,8 +50,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
// Schedule config from env - default to ENABLED
const scheduleEnabled = envOverrides.scheduleEnabled ??
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
const scheduleInterval = envOverrides.scheduleInterval ??
(process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily
const scheduleInterval = envOverrides.scheduleInterval ??
(process.env.SCHEDULE_INTERVAL || 86400); // Default: daily
const scheduleTimezone = normalizeTimezone(process.env.SCHEDULE_TIMEZONE || "UTC");
let scheduleNextRun: Date | null = null;
if (scheduleEnabled) {
try {
scheduleNextRun = getNextScheduledRun(scheduleInterval, new Date(), scheduleTimezone);
} catch {
scheduleNextRun = new Date(Date.now() + 86400 * 1000);
}
}
// Cleanup config from env - default to ENABLED
const cleanupEnabled = envOverrides.cleanupEnabled ??
@@ -75,6 +87,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
includeOrganizations: [],
starredReposOrg: "starred",
starredReposMode: "dedicated-org",
starredLists: [],
mirrorStrategy: "preserve",
defaultOrg: "github-mirrors",
},
@@ -95,7 +108,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,
},
@@ -103,11 +117,12 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
exclude: [],
scheduleConfig: {
enabled: scheduleEnabled,
interval: scheduleInterval,
interval: String(scheduleInterval),
timezone: scheduleTimezone,
concurrent: false,
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
lastRun: null,
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null,
nextRun: scheduleNextRun,
},
cleanupConfig: {
enabled: cleanupEnabled,
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test";
import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper";
import { scheduleConfigSchema } from "@/lib/db/schema";
test("mapUiScheduleToDb - builds cron from start time + frequency", () => {
const existing = scheduleConfigSchema.parse({});
const mapped = mapUiScheduleToDb(
{
enabled: true,
scheduleMode: "clock",
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "Asia/Kolkata",
},
existing
);
expect(mapped.enabled).toBe(true);
expect(mapped.interval).toBe("0 22 * * *");
expect(mapped.timezone).toBe("Asia/Kolkata");
});
test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
const mapped = mapDbScheduleToUi(
scheduleConfigSchema.parse({
enabled: true,
interval: "15 22,6,14 * * *",
timezone: "Asia/Kolkata",
})
);
expect(mapped.scheduleMode).toBe("clock");
expect(mapped.clockFrequencyHours).toBe(8);
expect(mapped.startTime).toBe("22:15");
expect(mapped.timezone).toBe("Asia/Kolkata");
});
+69 -9
View File
@@ -12,6 +12,7 @@ import type {
import { z } from "zod";
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
import { parseInterval } from "@/lib/utils/duration-parser";
import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils";
// Use the actual database schema types
type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
@@ -19,6 +20,17 @@ type DbGiteaConfig = z.infer<typeof giteaConfigSchema>;
type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>;
type DbCleanupConfig = z.infer<typeof cleanupConfigSchema>;
function normalizeStarredLists(lists: string[] | undefined): string[] {
if (!Array.isArray(lists)) return [];
const deduped = new Set<string>();
for (const list of lists) {
const trimmed = list.trim();
if (!trimmed) continue;
deduped.add(trimmed);
}
return [...deduped];
}
/**
* Maps UI config structure to database schema structure
*/
@@ -49,6 +61,7 @@ export function mapUiToDbConfig(
// Starred repos organization
starredReposOrg: giteaConfig.starredReposOrg,
starredReposMode: giteaConfig.starredReposMode || "dedicated-org",
starredLists: normalizeStarredLists(githubConfig.starredLists),
// Mirror strategy
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
@@ -101,9 +114,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,
};
@@ -129,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): {
token: dbConfig.githubConfig?.token || "",
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred
starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists),
};
// Map from database Gitea config to UI fields
@@ -146,9 +161,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,
};
@@ -193,15 +211,42 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
? { ...(existing as unknown as DbScheduleConfig) }
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
// Store interval as seconds string to avoid lossy cron conversion
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0
? String(uiSchedule.interval)
: (typeof base.interval === 'string' ? base.interval : String(86400));
const baseInterval = typeof base.interval === "string"
? base.interval
: String(base.interval ?? 86400);
const timezone = normalizeTimezone(
typeof uiSchedule.timezone === "string"
? uiSchedule.timezone
: base.timezone || "UTC"
);
let intervalExpression = baseInterval;
if (uiSchedule.scheduleMode === "clock") {
const cronExpression = buildClockCronExpression(
uiSchedule.startTime || "22:00",
Number(uiSchedule.clockFrequencyHours || 24)
);
if (cronExpression) {
intervalExpression = cronExpression;
}
} else if (typeof uiSchedule.intervalExpression === "string" && uiSchedule.intervalExpression.trim().length > 0) {
intervalExpression = uiSchedule.intervalExpression.trim();
} else if (typeof uiSchedule.interval === "number" && Number.isFinite(uiSchedule.interval) && uiSchedule.interval > 0) {
intervalExpression = String(Math.floor(uiSchedule.interval));
} else if (typeof uiSchedule.interval === "string" && uiSchedule.interval.trim().length > 0) {
intervalExpression = uiSchedule.interval.trim();
}
const scheduleChanged = baseInterval !== intervalExpression || normalizeTimezone(base.timezone || "UTC") !== timezone;
return {
...base,
enabled: !!uiSchedule.enabled,
interval: intervalSeconds,
interval: intervalExpression,
timezone,
nextRun: scheduleChanged ? undefined : base.nextRun,
} as DbScheduleConfig;
}
@@ -214,11 +259,21 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
return {
enabled: false,
interval: 86400, // Default to daily (24 hours)
intervalExpression: "86400",
scheduleMode: "interval",
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "UTC",
lastRun: null,
nextRun: null,
};
}
const intervalExpression = typeof dbSchedule.interval === "string"
? dbSchedule.interval
: String(dbSchedule.interval ?? 86400);
const parsedClockSchedule = parseClockCronExpression(intervalExpression);
// Parse interval supporting numbers (seconds), duration strings, and cron
let intervalSeconds = 86400; // Default to daily (24 hours)
try {
@@ -236,6 +291,11 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
return {
enabled: dbSchedule.enabled || false,
interval: intervalSeconds,
intervalExpression,
scheduleMode: parsedClockSchedule ? "clock" : "interval",
clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24,
startTime: parsedClockSchedule?.startTime ?? "22:00",
timezone: normalizeTimezone(dbSchedule.timezone || "UTC"),
lastRun: dbSchedule.lastRun || null,
nextRun: dbSchedule.nextRun || null,
};
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { buildGithubSourceAuthPayload } from "./mirror-source-auth";
describe("buildGithubSourceAuthPayload", () => {
test("uses configured owner when available", () => {
const auth = buildGithubSourceAuthPayload({
token: "ghp_test_token",
githubOwner: "ConfiguredOwner",
githubUsername: "fallback-user",
repositoryOwner: "repo-owner",
});
expect(auth).toEqual({
auth_username: "ConfiguredOwner",
auth_password: "ghp_test_token",
auth_token: "ghp_test_token",
});
});
test("falls back to configured username then repository owner", () => {
const authFromUsername = buildGithubSourceAuthPayload({
token: "token1",
githubUsername: "configured-user",
repositoryOwner: "repo-owner",
});
expect(authFromUsername.auth_username).toBe("configured-user");
const authFromRepoOwner = buildGithubSourceAuthPayload({
token: "token2",
repositoryOwner: "repo-owner",
});
expect(authFromRepoOwner.auth_username).toBe("repo-owner");
});
test("uses x-access-token as last-resort username", () => {
const auth = buildGithubSourceAuthPayload({
token: "ghp_test_token",
});
expect(auth.auth_username).toBe("x-access-token");
});
test("trims token whitespace", () => {
const auth = buildGithubSourceAuthPayload({
token: " ghp_trimmed ",
githubUsername: "user",
});
expect(auth.auth_password).toBe("ghp_trimmed");
expect(auth.auth_token).toBe("ghp_trimmed");
});
test("returns empty object when token is missing", () => {
const result = buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
});
expect(result).toEqual({});
});
});
+49
View File
@@ -0,0 +1,49 @@
interface BuildGithubSourceAuthPayloadParams {
token?: string | null;
githubOwner?: string | null;
githubUsername?: string | null;
repositoryOwner?: string | null;
}
export interface GithubSourceAuthPayload {
auth_username: string;
auth_password: string;
auth_token: string;
}
export type GithubSourceAuthPayloadOrEmpty = GithubSourceAuthPayload | Record<string, never>;
const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token";
function normalize(value?: string | null): string {
return typeof value === "string" ? value.trim() : "";
}
/**
* Build source credentials for GitHub repository mirroring.
* GitHub expects username + token-as-password over HTTPS (not the GitLab-style "oauth2" username).
* Returns an empty object when no token is available, allowing callers to use it unconditionally.
*/
export function buildGithubSourceAuthPayload({
token,
githubOwner,
githubUsername,
repositoryOwner,
}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayloadOrEmpty {
const normalizedToken = normalize(token);
if (!normalizedToken) {
return {};
}
const authUsername =
normalize(githubOwner) ||
normalize(githubUsername) ||
normalize(repositoryOwner) ||
DEFAULT_GITHUB_AUTH_USERNAME;
return {
auth_username: authUsername,
auth_password: normalizedToken,
auth_token: normalizedToken,
};
}
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "bun:test";
import {
buildClockCronExpression,
getNextCronOccurrence,
getNextScheduledRun,
isCronExpression,
normalizeTimezone,
parseClockCronExpression,
} from "./schedule-utils";
test("isCronExpression - detects 5-part cron expressions", () => {
expect(isCronExpression("0 22 * * *")).toBe(true);
expect(isCronExpression("8h")).toBe(false);
expect(isCronExpression(3600)).toBe(false);
});
test("buildClockCronExpression - creates daily and hourly expressions", () => {
expect(buildClockCronExpression("22:00", 24)).toBe("0 22 * * *");
expect(buildClockCronExpression("22:15", 8)).toBe("15 22,6,14 * * *");
expect(buildClockCronExpression("10:30", 1)).toBe("30 * * * *");
expect(buildClockCronExpression("10:30", 7)).toBeNull();
});
test("parseClockCronExpression - parses generated expressions", () => {
expect(parseClockCronExpression("0 22 * * *")).toEqual({
startTime: "22:00",
frequencyHours: 24,
});
expect(parseClockCronExpression("15 22,6,14 * * *")).toEqual({
startTime: "22:15",
frequencyHours: 8,
});
expect(parseClockCronExpression("30 * * * *")).toEqual({
startTime: "00:30",
frequencyHours: 1,
});
expect(parseClockCronExpression("0 3 * * 1-5")).toBeNull();
});
test("getNextCronOccurrence - computes next run in UTC", () => {
const from = new Date("2026-03-18T15:20:00.000Z");
const next = getNextCronOccurrence("0 22 * * *", from, "UTC");
expect(next.toISOString()).toBe("2026-03-18T22:00:00.000Z");
});
test("getNextCronOccurrence - respects timezone", () => {
const from = new Date("2026-03-18T15:20:00.000Z");
// 22:00 IST equals 16:30 UTC
const next = getNextCronOccurrence("0 22 * * *", from, "Asia/Kolkata");
expect(next.toISOString()).toBe("2026-03-18T16:30:00.000Z");
});
test("getNextScheduledRun - handles interval and cron schedules", () => {
const from = new Date("2026-03-18T00:00:00.000Z");
const intervalNext = getNextScheduledRun("8h", from, "UTC");
expect(intervalNext.toISOString()).toBe("2026-03-18T08:00:00.000Z");
const cronNext = getNextScheduledRun("0 */6 * * *", from, "UTC");
expect(cronNext.toISOString()).toBe("2026-03-18T06:00:00.000Z");
});
test("normalizeTimezone - falls back to UTC for invalid values", () => {
expect(normalizeTimezone("Invalid/Zone")).toBe("UTC");
expect(normalizeTimezone("Asia/Kolkata")).toBe("Asia/Kolkata");
});
+420
View File
@@ -0,0 +1,420 @@
import { parseInterval } from "@/lib/utils/duration-parser";
const WEEKDAY_INDEX: Record<string, number> = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
const MONTH_INDEX: Record<string, number> = {
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
};
interface ParsedCronField {
wildcard: boolean;
values: Set<number>;
}
interface ZonedDateParts {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
}
interface ParsedCronExpression {
minute: ParsedCronField;
hour: ParsedCronField;
dayOfMonth: ParsedCronField;
month: ParsedCronField;
dayOfWeek: ParsedCronField;
}
const zonedPartsFormatterCache = new Map<string, Intl.DateTimeFormat>();
const zonedWeekdayFormatterCache = new Map<string, Intl.DateTimeFormat>();
function pad2(value: number): string {
return value.toString().padStart(2, "0");
}
export function isCronExpression(value: unknown): value is string {
return typeof value === "string" && value.trim().split(/\s+/).length === 5;
}
export function normalizeTimezone(timezone?: string): string {
const candidate = timezone?.trim() || "UTC";
try {
// Validate timezone eagerly.
new Intl.DateTimeFormat("en-US", { timeZone: candidate });
return candidate;
} catch {
return "UTC";
}
}
function getZonedPartsFormatter(timezone: string): Intl.DateTimeFormat {
const cacheKey = normalizeTimezone(timezone);
const cached = zonedPartsFormatterCache.get(cacheKey);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: cacheKey,
hour12: false,
hourCycle: "h23",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
zonedPartsFormatterCache.set(cacheKey, formatter);
return formatter;
}
function getZonedWeekdayFormatter(timezone: string): Intl.DateTimeFormat {
const cacheKey = normalizeTimezone(timezone);
const cached = zonedWeekdayFormatterCache.get(cacheKey);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: cacheKey,
weekday: "short",
});
zonedWeekdayFormatterCache.set(cacheKey, formatter);
return formatter;
}
function getZonedDateParts(date: Date, timezone: string): ZonedDateParts {
const safeTimezone = normalizeTimezone(timezone);
const parts = getZonedPartsFormatter(safeTimezone).formatToParts(date);
const month = Number(parts.find((part) => part.type === "month")?.value);
const dayOfMonth = Number(parts.find((part) => part.type === "day")?.value);
const hour = Number(parts.find((part) => part.type === "hour")?.value);
const minute = Number(parts.find((part) => part.type === "minute")?.value);
const weekdayLabel = getZonedWeekdayFormatter(safeTimezone)
.format(date)
.toLowerCase()
.slice(0, 3);
const dayOfWeek = WEEKDAY_INDEX[weekdayLabel];
if (
Number.isNaN(month) ||
Number.isNaN(dayOfMonth) ||
Number.isNaN(hour) ||
Number.isNaN(minute) ||
typeof dayOfWeek !== "number"
) {
throw new Error("Unable to extract timezone-aware date parts");
}
return {
month,
dayOfMonth,
hour,
minute,
dayOfWeek,
};
}
function parseCronAtom(
atom: string,
min: number,
max: number,
aliases?: Record<string, number>,
allowSevenAsSunday = false
): number {
const normalized = atom.trim().toLowerCase();
if (normalized.length === 0) {
throw new Error("Empty cron atom");
}
const aliasValue = aliases?.[normalized];
const parsed = aliasValue ?? Number(normalized);
if (!Number.isInteger(parsed)) {
throw new Error(`Invalid cron value: "${atom}"`);
}
const normalizedDowValue = allowSevenAsSunday && parsed === 7 ? 0 : parsed;
if (normalizedDowValue < min || normalizedDowValue > max) {
throw new Error(
`Cron value "${atom}" out of range (${min}-${max})`
);
}
return normalizedDowValue;
}
function addRangeValues(
target: Set<number>,
start: number,
end: number,
step: number,
min: number,
max: number
): void {
if (step <= 0) {
throw new Error(`Invalid cron step: ${step}`);
}
if (start < min || end > max || start > end) {
throw new Error(`Invalid cron range: ${start}-${end}`);
}
for (let value = start; value <= end; value += step) {
target.add(value);
}
}
function parseCronField(
field: string,
min: number,
max: number,
aliases?: Record<string, number>,
allowSevenAsSunday = false
): ParsedCronField {
const raw = field.trim();
if (raw === "*") {
const values = new Set<number>();
for (let i = min; i <= max; i += 1) values.add(i);
return { wildcard: true, values };
}
const values = new Set<number>();
const segments = raw.split(",");
for (const segment of segments) {
const trimmedSegment = segment.trim();
if (!trimmedSegment) {
throw new Error(`Invalid cron field "${field}"`);
}
const [basePart, stepPart] = trimmedSegment.split("/");
const step = stepPart ? Number(stepPart) : 1;
if (!Number.isInteger(step) || step <= 0) {
throw new Error(`Invalid cron step "${stepPart}"`);
}
if (basePart === "*") {
addRangeValues(values, min, max, step, min, max);
continue;
}
if (basePart.includes("-")) {
const [startRaw, endRaw] = basePart.split("-");
const start = parseCronAtom(
startRaw,
min,
max,
aliases,
allowSevenAsSunday
);
const end = parseCronAtom(
endRaw,
min,
max,
aliases,
allowSevenAsSunday
);
addRangeValues(values, start, end, step, min, max);
continue;
}
const value = parseCronAtom(
basePart,
min,
max,
aliases,
allowSevenAsSunday
);
values.add(value);
}
return { wildcard: false, values };
}
function parseCronExpression(expression: string): ParsedCronExpression {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(
'Cron expression must have 5 parts: "minute hour day month weekday"'
);
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
return {
minute: parseCronField(minute, 0, 59),
hour: parseCronField(hour, 0, 23),
dayOfMonth: parseCronField(dayOfMonth, 1, 31),
month: parseCronField(month, 1, 12, MONTH_INDEX),
dayOfWeek: parseCronField(dayOfWeek, 0, 6, WEEKDAY_INDEX, true),
};
}
function matchesCron(
cron: ParsedCronExpression,
parts: ZonedDateParts
): boolean {
if (!cron.minute.values.has(parts.minute)) return false;
if (!cron.hour.values.has(parts.hour)) return false;
if (!cron.month.values.has(parts.month)) return false;
const dayOfMonthWildcard = cron.dayOfMonth.wildcard;
const dayOfWeekWildcard = cron.dayOfWeek.wildcard;
const dayOfMonthMatches = cron.dayOfMonth.values.has(parts.dayOfMonth);
const dayOfWeekMatches = cron.dayOfWeek.values.has(parts.dayOfWeek);
if (dayOfMonthWildcard && dayOfWeekWildcard) return true;
if (dayOfMonthWildcard) return dayOfWeekMatches;
if (dayOfWeekWildcard) return dayOfMonthMatches;
return dayOfMonthMatches || dayOfWeekMatches;
}
export function getNextCronOccurrence(
expression: string,
fromDate: Date,
timezone = "UTC",
maxLookaheadMinutes = 2 * 365 * 24 * 60
): Date {
const cron = parseCronExpression(expression);
const safeTimezone = normalizeTimezone(timezone);
const base = new Date(fromDate);
base.setSeconds(0, 0);
const firstCandidateMs = base.getTime() + 60_000;
for (let offset = 0; offset <= maxLookaheadMinutes; offset += 1) {
const candidate = new Date(firstCandidateMs + offset * 60_000);
const candidateParts = getZonedDateParts(candidate, safeTimezone);
if (matchesCron(cron, candidateParts)) {
return candidate;
}
}
throw new Error(
`Could not find next cron occurrence for "${expression}" within ${maxLookaheadMinutes} minutes`
);
}
export function getNextScheduledRun(
schedule: string | number,
fromDate: Date,
timezone = "UTC"
): Date {
if (isCronExpression(schedule)) {
return getNextCronOccurrence(schedule, fromDate, timezone);
}
const intervalMs = parseInterval(schedule);
return new Date(fromDate.getTime() + intervalMs);
}
export function buildClockCronExpression(
startTime: string,
frequencyHours: number
): string | null {
const parsed = startTime.match(/^([01]\d|2[0-3]):([0-5]\d)$/);
if (!parsed) return null;
if (!Number.isInteger(frequencyHours) || frequencyHours <= 0) {
return null;
}
const hour = Number(parsed[1]);
const minute = Number(parsed[2]);
if (frequencyHours === 24) {
return `${minute} ${hour} * * *`;
}
if (frequencyHours === 1) {
return `${minute} * * * *`;
}
if (24 % frequencyHours !== 0) {
return null;
}
const hourCount = 24 / frequencyHours;
const hours: number[] = [];
for (let i = 0; i < hourCount; i += 1) {
hours.push((hour + i * frequencyHours) % 24);
}
return `${minute} ${hours.join(",")} * * *`;
}
export function parseClockCronExpression(
expression: string
): { startTime: string; frequencyHours: number } | null {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = parts;
if (dayRaw !== "*" || monthRaw !== "*" || weekdayRaw !== "*") {
return null;
}
const minute = Number(minuteRaw);
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
return null;
}
if (hourRaw === "*") {
return {
startTime: `00:${pad2(minute)}`,
frequencyHours: 1,
};
}
const hourTokens = hourRaw.split(",");
if (hourTokens.length === 0) return null;
const hours = hourTokens.map((token) => Number(token));
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
return null;
}
if (hours.length === 1) {
return {
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
frequencyHours: 24,
};
}
// Verify evenly spaced circular sequence to infer "every N hours".
const deltas: number[] = [];
for (let i = 0; i < hours.length; i += 1) {
const current = hours[i];
const next = i === hours.length - 1 ? hours[0] : hours[i + 1];
const delta = (next - current + 24) % 24;
deltas.push(delta);
}
const expectedDelta = deltas[0];
const uniform = deltas.every((delta) => delta === expectedDelta && delta > 0);
if (!uniform || expectedDelta <= 0 || 24 % expectedDelta !== 0) {
return null;
}
return {
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
frequencyHours: expectedDelta,
};
}
+34 -1
View File
@@ -9,6 +9,13 @@ import { auth } from './lib/auth';
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
import { initializeConfigFromEnv } from './lib/env-config-loader';
import { db, users } from './lib/db';
import { getBasePath } from './lib/base-path';
const ASTRO_INTERNAL_ASSET_PATH_PATTERN = /(["'])\/(_astro\/|_server-islands\/|_image\b)/g;
function prefixAstroInternalAssetPaths(html: string, basePath: string): string {
return html.replace(ASTRO_INTERNAL_ASSET_PATH_PATTERN, `$1${basePath}/$2`);
}
// Flag to track if recovery has been initialized
let recoveryInitialized = false;
@@ -21,6 +28,8 @@ let envConfigInitialized = false;
let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
export const onRequest = defineMiddleware(async (context, next) => {
const basePath = getBasePath();
// First, try Better Auth session (cookie-based)
try {
const session = await auth.api.getSession({
@@ -217,5 +226,29 @@ export const onRequest = defineMiddleware(async (context, next) => {
}
// Continue with the request
return next();
const response = await next();
if (basePath === "/") {
return response;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
return response;
}
const body = await response.text();
const rewrittenBody = prefixAstroInternalAssetPaths(body, basePath);
if (rewrittenBody === body) {
return response;
}
const headers = new Headers(response.headers);
headers.delete("content-length");
return new Response(rewrittenBody, {
status: response.status,
statusText: response.statusText,
headers,
});
});
+3 -2
View File
@@ -2,6 +2,7 @@
import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { NotFound } from '@/components/NotFound';
import { withBase } from '@/lib/base-path';
const generator = Astro.generator;
---
@@ -10,7 +11,7 @@ const generator = Astro.generator;
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={generator} />
<title>Page Not Found - Gitea Mirror</title>
<ThemeScript />
@@ -34,4 +35,4 @@ const generator = Astro.generator;
transform: translateY(-10px);
}
}
</style>
</style>
+2 -2
View File
@@ -3,6 +3,7 @@ import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import { db, mirrorJobs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Fetch activity data from the database
let activityData = [];
@@ -13,7 +14,6 @@ try {
activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing
if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return [];
}
@@ -54,7 +54,7 @@ const handleRefresh = () => {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<meta name="generator" content={Astro.generator} />
<title>Activity Log - Gitea Mirror</title>
<ThemeScript />
+8 -3
View File
@@ -1,5 +1,6 @@
import { auth } from "@/lib/auth";
import type { APIRoute } from "astro";
import { stripBasePath, withBase } from "@/lib/base-path";
export const ALL: APIRoute = async (ctx) => {
// If you want to use rate limiting, make sure to set the 'x-forwarded-for' header
@@ -9,7 +10,11 @@ export const ALL: APIRoute = async (ctx) => {
}
try {
return await auth.handler(ctx.request);
const requestUrl = new URL(ctx.request.url);
requestUrl.pathname = withBase(stripBasePath(requestUrl.pathname));
const authRequest = new Request(requestUrl, ctx.request);
return await auth.handler(authRequest);
} catch (error) {
console.error("Auth handler error:", error);
@@ -18,7 +23,7 @@ export const ALL: APIRoute = async (ctx) => {
if (url.pathname.includes('/sso/callback')) {
// Redirect to error page for SSO errors
return Response.redirect(
`${ctx.url.origin}/auth-error?error=sso_callback_failed&error_description=${encodeURIComponent(
`${ctx.url.origin}${withBase('/auth-error')}?error=sso_callback_failed&error_description=${encodeURIComponent(
error instanceof Error ? error.message : "SSO authentication failed"
)}`,
302
@@ -34,4 +39,4 @@ export const ALL: APIRoute = async (ctx) => {
headers: { "Content-Type": "application/json" }
});
}
};
};
+4 -1
View File
@@ -6,6 +6,7 @@ import { db, ssoProviders } from "@/lib/db";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { normalizeOidcProviderConfig, OidcConfigError } from "@/lib/sso/oidc-config";
import { withBase } from "@/lib/base-path";
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth
export async function POST(context: APIContext) {
@@ -87,7 +88,9 @@ export async function POST(context: APIContext) {
registrationBody.samlConfig = {
entryPoint,
cert,
callbackUrl: callbackUrl || `${context.url.origin}/api/auth/sso/saml2/callback/${providerId}`,
callbackUrl:
callbackUrl ||
`${context.url.origin}${withBase(`/api/auth/sso/saml2/callback/${providerId}`)}`,
audience: audience || context.url.origin,
wantAssertionsSigned,
signatureAlgorithm,
+51
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");
});
});
+100 -17
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { db, configs, users } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import {
mapUiToDbConfig,
@@ -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,24 @@ 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 {
@@ -56,11 +83,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
}
// Fetch existing config
// Fetch existing config — prefer the active config; fall back to most-recently-updated
// so a stale inactive stub never wins over a populated active row (see issue #271).
const existingConfigResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const existingConfig = existingConfigResult[0];
@@ -115,17 +144,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(
@@ -163,7 +216,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",
@@ -176,7 +229,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({
@@ -200,11 +257,14 @@ export const GET: APIRoute = async ({ request, locals }) => {
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Fetch the configuration for the user
// Fetch the configuration for the user — prefer the active config; fall back to
// most-recently-updated so a stale inactive stub never wins over a populated
// active row (see issue #271).
const config = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (config.length === 0) {
@@ -258,13 +318,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,
@@ -278,6 +359,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
lastRun: dbConfig.cleanupConfig.lastRun,
nextRun: dbConfig.cleanupConfig.nextRun,
},
notificationConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -288,7 +370,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,
@@ -302,6 +384,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" },
+6 -1
View File
@@ -38,7 +38,12 @@ export const GET: APIRoute = async ({ request, locals }) => {
.where(eq(mirrorJobs.userId, userId))
.orderBy(sql`${mirrorJobs.timestamp} DESC`)
.limit(10),
db.select().from(configs).where(eq(configs.userId, userId)).limit(1),
db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1),
db
.select({ value: count() })
.from(repositories)
+56
View File
@@ -0,0 +1,56 @@
import type { APIRoute } from "astro";
import { db, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import {
createGitHubClient,
getGithubStarredListNames,
} from "@/lib/github";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (!config) {
return jsonResponse({
data: { success: false, message: "No configuration found for this user" },
status: 404,
});
}
if (!config.githubConfig?.token) {
return jsonResponse({
data: { success: false, message: "GitHub token is missing in config" },
status: 400,
});
}
const token = getDecryptedGitHubToken(config);
const githubUsername = config.githubConfig?.owner || undefined;
const octokit = createGitHubClient(token, userId, githubUsername);
const lists = await getGithubStarredListNames({ octokit });
return jsonResponse({
data: {
success: true,
lists,
},
status: 200,
});
} catch (error) {
return createSecureErrorResponse(error, "starred lists fetch", 500);
}
};
+46 -44
View File
@@ -1,39 +1,51 @@
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
import { POST } from "./test-connection";
import { Octokit } from "@octokit/rest";
// Mock the Octokit class
mock.module("@octokit/rest", () => {
// createGitHubClient returns this stub. Tests mutate `getAuthenticatedImpl`
// to steer the behavior without re-calling mock.module (which is fragile
// once the route module has already captured a live binding).
let getAuthenticatedImpl: () => Promise<any> = () =>
Promise.resolve({
data: {
login: "testuser",
name: "Test User",
avatar_url: "https://example.com/avatar.png",
},
});
mock.module("@/lib/github", () => {
return {
Octokit: mock(function() {
return {
users: {
getAuthenticated: mock(() => Promise.resolve({
data: {
login: "testuser",
name: "Test User",
avatar_url: "https://example.com/avatar.png"
}
}))
}
};
})
createGitHubClient: mock(() => ({
users: {
getAuthenticated: mock(() => getAuthenticatedImpl()),
},
})),
};
});
import { POST } from "./test-connection";
describe("GitHub Test Connection API", () => {
// Mock console.error to prevent test output noise
let originalConsoleError: typeof console.error;
beforeEach(() => {
originalConsoleError = console.error;
console.error = mock(() => {});
// Reset to the success stub before each test so tests are independent
getAuthenticatedImpl = () =>
Promise.resolve({
data: {
login: "testuser",
name: "Test User",
avatar_url: "https://example.com/avatar.png",
},
});
});
afterEach(() => {
console.error = originalConsoleError;
});
test("returns 400 if token is missing", async () => {
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
@@ -42,16 +54,16 @@ describe("GitHub Test Connection API", () => {
},
body: JSON.stringify({})
});
const response = await POST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe("GitHub token is required");
});
test("returns 200 with user data on successful connection", async () => {
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
@@ -62,11 +74,11 @@ describe("GitHub Test Connection API", () => {
token: "valid-token"
})
});
const response = await POST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.message).toBe("Successfully connected to GitHub as testuser");
@@ -76,7 +88,7 @@ describe("GitHub Test Connection API", () => {
avatar_url: "https://example.com/avatar.png"
});
});
test("returns 400 if username doesn't match authenticated user", async () => {
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
@@ -88,29 +100,19 @@ describe("GitHub Test Connection API", () => {
username: "differentuser"
})
});
const response = await POST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe("Token belongs to testuser, not differentuser");
});
test("handles authentication errors", async () => {
// Mock Octokit to throw an error
mock.module("@octokit/rest", () => {
return {
Octokit: mock(function() {
return {
users: {
getAuthenticated: mock(() => Promise.reject(new Error("Bad credentials")))
}
};
})
};
});
// Swap the stub to throw an auth error for this test only
getAuthenticatedImpl = () => Promise.reject(new Error("Bad credentials"));
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
+5 -5
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { createGitHubClient } from "@/lib/github";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
@@ -22,10 +22,10 @@ export const POST: APIRoute = async ({ request }) => {
);
}
// Create an Octokit instance with the provided token
const octokit = new Octokit({
auth: token,
});
// Create an Octokit instance with the provided token.
// Uses createGitHubClient so GH_API_URL / GITHUB_API_URL routes the call
// to the correct endpoint for GHES / GHEC with data residency.
const octokit = createGitHubClient(token);
// Test the connection by fetching the authenticated user
const { data } = await octokit.users.getAuthenticated();
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepoEnhanced } from "@/lib/gitea-enhanced";
import { createSecureErrorResponse } from "@/lib/utils";
@@ -38,11 +38,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
import { db, configs, organizations } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { createGitHubClient } from "@/lib/github";
import { mirrorGitHubOrgToGitea } from "@/lib/gitea";
import { repoStatusEnum } from "@/types/Repository";
@@ -41,11 +41,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+45 -38
View File
@@ -3,6 +3,46 @@ import type { MirrorRepoRequest } from "@/types/mirror";
import { POST } from "./mirror-repo";
// Mock the database module
const mockConfigRow = [{
id: "config-id",
userId: "user-id",
githubConfig: {
token: "github-token",
preserveOrgStructure: false,
mirrorIssues: false
},
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token",
username: "giteauser"
}
}];
const mockRepoRows = [
{
id: "repo-id-1",
name: "test-repo-1",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
},
{
id: "repo-id-2",
name: "test-repo-2",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
}
];
const mockDb = {
select: mock(() => ({
from: mock((table: any) => ({
@@ -10,47 +50,14 @@ const mockDb = {
// Return config for configs table
if (table === mockConfigs) {
return {
limit: mock(() => Promise.resolve([{
id: "config-id",
userId: "user-id",
githubConfig: {
token: "github-token",
preserveOrgStructure: false,
mirrorIssues: false
},
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token",
username: "giteauser"
}
}]))
orderBy: mock(() => ({
limit: mock(() => Promise.resolve(mockConfigRow))
})),
limit: mock(() => Promise.resolve(mockConfigRow))
};
}
// Return repositories for repositories table
return Promise.resolve([
{
id: "repo-id-1",
name: "test-repo-1",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
},
{
id: "repo-id-2",
name: "test-repo-2",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
}
]);
return Promise.resolve(mockRepoRows);
})
}))
}))
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import {
mirrorGithubRepoToGitea,
@@ -43,11 +43,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -1
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db, configs, repositories } from "@/lib/db";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata";
@@ -35,10 +35,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more