Compare commits

...

8 Commits

Author SHA1 Message Date
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
66 changed files with 727 additions and 173 deletions
+8
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
@@ -31,6 +33,12 @@ BETTER_AUTH_URL=http://localhost:4321
# 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
+7 -4
View File
@@ -1,6 +1,6 @@
# 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 -y upgrade && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
@@ -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
@@ -31,7 +32,7 @@ FROM debian:trixie-slim AS git-lfs-builder
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
wget ca-certificates git make \
&& rm -rf /var/lib/apt/lists/*
ARG GO_VERSION=1.25.8
ARG 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,7 +49,7 @@ 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 -y upgrade && apt-get install -y --no-install-recommends \
git wget sqlite3 openssl ca-certificates \
@@ -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"]
+16 -1
View File
@@ -300,7 +300,22 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
### 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)
+12 -9
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.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=="],
@@ -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=="],
+7 -1
View File
@@ -22,6 +22,7 @@ services:
# 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
@@ -29,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
@@ -36,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
+11 -1
View File
@@ -30,6 +30,7 @@ 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),
@@ -37,6 +38,11 @@ services:
# 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
@@ -81,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
+22 -2
View File
@@ -33,10 +33,11 @@ Essential application settings required for running Gitea Mirror.
| `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | No |
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
@@ -302,6 +303,7 @@ services:
environment:
# Core Configuration
- NODE_ENV=production
- BASE_URL=/
- DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here
# Primary access URL:
@@ -370,6 +372,24 @@ This setup allows you to:
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
### Path Prefix Deployments
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
```bash
# BASE_URL handles the path prefix — auth URLs stay as origin only
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
```
Notes:
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` must be **origin only** (scheme + host). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
- `BETTER_AUTH_TRUSTED_ORIGINS` must also contain origins only (no path).
- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
+11 -6
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.14.2",
"version": "3.15.2",
"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.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})`);
});
+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"
/>
+10 -7
View File
@@ -24,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;
@@ -35,6 +36,8 @@ type ConfigState = {
notificationConfig: NotificationConfig;
};
const CONFIG_API_PATH = withBase('/api/config');
export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({
githubConfig: {
@@ -198,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),
@@ -264,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),
@@ -329,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),
@@ -378,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),
@@ -418,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),
@@ -453,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),
@@ -498,7 +501,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),
@@ -14,6 +14,7 @@ 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;
@@ -31,7 +32,7 @@ export function NotificationSettings({
const handleTestNotification = async () => {
setIsTesting(true);
try {
const resp = await fetch("/api/notifications/test", {
const resp = await fetch(withBase("/api/notifications/test"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationConfig }),
+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>
+3 -2
View File
@@ -6,6 +6,7 @@ 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[];
@@ -42,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>
@@ -54,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(
@@ -12,6 +12,7 @@ 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,
@@ -85,7 +86,7 @@ export function OrganizationList({
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",
@@ -189,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}
@@ -264,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'));
}}
@@ -28,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,
@@ -102,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",
+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>
+3 -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>(
+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(),
+5 -4
View File
@@ -5,6 +5,7 @@ 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.
@@ -97,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) {
@@ -107,7 +108,7 @@ 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.
// Uses the function form so that the origin can be auto-detected from
@@ -150,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;
}
+7
View File
@@ -308,6 +308,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
+4 -3
View File
@@ -789,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: {
@@ -848,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();
});
+21 -2
View File
@@ -556,6 +556,9 @@ 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 ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
@@ -603,10 +606,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 &&
@@ -680,6 +685,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) {
@@ -710,6 +722,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) {
+75 -5
View File
@@ -815,8 +815,10 @@ export const mirrorGithubRepoToGitea = async ({
service: "git",
};
// Add authentication for private repositories
if (repository.isPrivate) {
// 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 & {
@@ -1501,10 +1503,13 @@ 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) {
// 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 & {
@@ -2715,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,
@@ -2829,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({
+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));
+7 -7
View File
@@ -52,12 +52,12 @@ describe("buildGithubSourceAuthPayload", () => {
expect(auth.auth_token).toBe("ghp_trimmed");
});
test("throws when token is missing", () => {
expect(() =>
buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
})
).toThrow("GitHub token is required to mirror private repositories.");
test("returns empty object when token is missing", () => {
const result = buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
});
expect(result).toEqual({});
});
});
+6 -3
View File
@@ -11,6 +11,8 @@ export interface GithubSourceAuthPayload {
auth_token: string;
}
export type GithubSourceAuthPayloadOrEmpty = GithubSourceAuthPayload | Record<string, never>;
const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token";
function normalize(value?: string | null): string {
@@ -18,18 +20,19 @@ function normalize(value?: string | null): string {
}
/**
* Build source credentials for private GitHub repository mirroring.
* 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): GithubSourceAuthPayload {
}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayloadOrEmpty {
const normalizedToken = normalize(token);
if (!normalizedToken) {
throw new Error("GitHub token is required to mirror private repositories.");
return {};
}
const authUsername =
+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 -1
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 = [];
@@ -53,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,
+4 -3
View File
@@ -1,6 +1,7 @@
---
import Layout from '@/layouts/main.astro';
import { Button } from '@/components/ui/button';
import { withBase } from '@/lib/base-path';
const error = Astro.url.searchParams.get('error');
const errorDescription = Astro.url.searchParams.get('error_description');
@@ -30,13 +31,13 @@ const errorDescription = Astro.url.searchParams.get('error_description');
<div class="mt-6 flex gap-2">
<Button
variant="outline"
onClick={() => window.location.href = '/login'}
onClick={() => window.location.href = withBase('/login')}
>
Back to Login
</Button>
<Button
variant="outline"
onClick={() => window.location.href = '/'}
onClick={() => window.location.href = withBase('/')}
>
Go Home
</Button>
@@ -44,4 +45,4 @@ const errorDescription = Astro.url.searchParams.get('error_description');
</div>
</div>
</div>
</Layout>
</Layout>
+2 -1
View File
@@ -7,13 +7,14 @@ import { db, configs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { Button } from '@/components/ui/button';
import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config';
import { withBase } from '@/lib/base-path';
---
<html lang="en">
<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>Configuration - Gitea Mirror</title>
<ThemeScript />
+21 -8
View File
@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Advanced Topics - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
@@ -51,8 +52,9 @@ import MainLayout from '../../layouts/main.astro';
{ var: 'NODE_ENV', desc: 'Application environment', default: 'production' },
{ var: 'PORT', desc: 'Server port', default: '4321' },
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
{ var: 'BASE_URL', desc: 'Application base path ("/" or e.g. "/mirror")', default: '/' },
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
{ var: 'BETTER_AUTH_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' },
{ var: 'BETTER_AUTH_URL', desc: 'Authentication origin (scheme + host, no path). Any path is stripped automatically.', default: 'http://localhost:4321' },
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
].map((item, i) => (
@@ -225,10 +227,20 @@ import MainLayout from '../../layouts/main.astro';
BETTER_AUTH_URL=https://gitea-mirror.example.com
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
</div>
<div class="bg-muted/30 rounded p-3 mt-3">
<pre class="text-sm"><code>{`# If deployed under a path prefix (example: /mirror):
# BASE_URL handles the path — 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/*`}</code></pre>
</div>
<ul class="mt-3 space-y-1 text-sm">
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth base URL for callbacks and redirects</li>
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li>
<li><code class="bg-red-500/10 px-1 rounded">BASE_URL</code> — Application base path for path-prefix deployments (e.g. <code>/mirror</code>). This is handled separately from the auth URLs.</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth origin, scheme + host only (e.g. <code>https://git.example.com</code>). Do <strong>not</strong> include the base path — it is applied automatically from <code>BASE_URL</code>.</li>
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) auth origin. Same rule: origin only, no path.</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
</ul>
</div>
@@ -243,9 +255,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- BETTER_AUTH_URL=https://gitea-mirror.example.com
- PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
- 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
# ... other settings ...`}</code></pre>
</div>
@@ -509,4 +522,4 @@ ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
</section>
</article>
</main>
</MainLayout>
</MainLayout>
+2 -1
View File
@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Architecture - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
+2 -1
View File
@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Authentication & SSO - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
+2 -1
View File
@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="CA Certificates - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
+4 -2
View File
@@ -1,8 +1,10 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
const envVars = [
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' },
{ name: 'BASE_URL', desc: 'Application base path', default: '/', example: '/mirror' },
{ name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' },
{ name: 'JWT_SECRET', desc: 'Secret key for JWT auth', default: 'Auto-generated', example: 'your-secure-string' },
{ name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' },
@@ -35,7 +37,7 @@ const giteaOptions = [
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
@@ -509,4 +511,4 @@ curl http://your-server:port/api/health`}</code></pre>
</section>
</article>
</main>
</MainLayout>
</MainLayout>
+3 -2
View File
@@ -1,6 +1,7 @@
---
import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu';
import { withBase } from '@/lib/base-path';
// Define our documentation pages directly
const docs = [
@@ -69,7 +70,7 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
return (
<a
href={doc.href}
href={withBase(doc.href)}
class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none"
tabindex="0"
>
@@ -85,4 +86,4 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
})}
</div>
</main>
</MainLayout>
</MainLayout>
+5 -4
View File
@@ -1,12 +1,13 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Quick Start Guide - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
@@ -418,11 +419,11 @@ bun run start</code></pre>
<div class="space-y-3">
<div class="flex gap-3">
<span class="text-primary">📖</span>
<span>Check out the <a href="/docs/configuration" class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span>
<span>Check out the <a href={withBase('/docs/configuration')} class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span>
</div>
<div class="flex gap-3">
<span class="text-primary">🏗️</span>
<span>Review the <a href="/docs/architecture" class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span>
<span>Review the <a href={withBase('/docs/architecture')} class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span>
</div>
<div class="flex gap-3">
<span class="text-primary">📊</span>
@@ -434,4 +435,4 @@ bun run start</code></pre>
</section>
</article>
</main>
</MainLayout>
</MainLayout>
+3 -2
View File
@@ -4,6 +4,7 @@ import App from '@/components/layout/MainLayout';
import { db, repositories, mirrorJobs, users } from '@/lib/db';
import { sql } from 'drizzle-orm';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(users);
@@ -11,7 +12,7 @@ const userCount = userCountResult[0]?.count || 0;
// Redirect to signup if no users exist
if (userCount === 0) {
return Astro.redirect('/signup');
return Astro.redirect(withBase('/signup'));
}
// Fetch data from the database
@@ -59,7 +60,7 @@ try {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/x-icon" href={withBase('/favicon.ico')} />
<meta name="generator" content={Astro.generator} />
<title>Dashboard - Gitea Mirror</title>
<ThemeScript />
+3 -2
View File
@@ -4,6 +4,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
import { LoginPage } from '@/components/auth/LoginPage';
import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database
const userCountResult = await db
@@ -13,7 +14,7 @@ const userCount = userCountResult[0].count;
// Redirect to signup if no users exist
if (userCount === 0) {
return Astro.redirect('/signup');
return Astro.redirect(withBase('/signup'));
}
const generator = Astro.generator;
@@ -23,7 +24,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>Login - Gitea Mirror</title>
<ThemeScript />
+4 -3
View File
@@ -3,11 +3,12 @@ import '@/styles/global.css';
import ConsentPage from '@/components/oauth/ConsentPage';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import Providers from '@/components/layout/Providers';
import { withBase } from '@/lib/base-path';
// Check if user is authenticated
const sessionCookie = Astro.cookies.get('better-auth-session');
if (!sessionCookie) {
return Astro.redirect('/login');
return Astro.redirect(withBase('/login'));
}
---
@@ -15,7 +16,7 @@ if (!sessionCookie) {
<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>Authorize Application - Gitea Mirror</title>
<ThemeScript />
@@ -25,4 +26,4 @@ if (!sessionCookie) {
<ConsentPage client:load />
</Providers>
</body>
</html>
</html>
+2 -1
View File
@@ -2,6 +2,7 @@
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
---
@@ -9,7 +10,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
<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>Organizations - Gitea Mirror</title>
<ThemeScript />
+2 -1
View File
@@ -2,13 +2,14 @@
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
---
<html lang="en">
<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>Repositories - Gitea Mirror</title>
<ThemeScript />
+3 -2
View File
@@ -4,6 +4,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
import { SignupPage } from '@/components/auth/SignupPage';
import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database
const userCountResult = await db
@@ -13,7 +14,7 @@ const userCount = userCountResult[0]?.count;
// Redirect to login if users already exist
if (userCount !== null && Number(userCount) > 0) {
return Astro.redirect('/login');
return Astro.redirect(withBase('/login'));
}
const generator = Astro.generator;
@@ -23,7 +24,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>Setup Admin Account - Gitea Mirror</title>
<ThemeScript />
+18 -18
View File
@@ -25,7 +25,7 @@ importers:
version: 1.12.69
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))
version: 4.2.1(vite@7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
@@ -1053,8 +1053,8 @@ packages:
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
@@ -2010,8 +2010,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
vite@7.3.2:
resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -2146,12 +2146,12 @@ snapshots:
'@astrojs/internal-helpers': 0.8.0
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': 5.2.0(vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))
'@vitejs/plugin-react': 5.2.0(vite@7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))
devalue: 5.6.4
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
ultrahtml: 1.6.0
vite: 7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
vite: 7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -2748,12 +2748,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
'@tailwindcss/oxide-win32-x64-msvc': 4.2.1
'@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))':
'@tailwindcss/vite@4.2.1(vite@7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1
tailwindcss: 4.2.1
vite: 7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
vite: 7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
'@types/babel__core@7.20.5':
dependencies:
@@ -2823,7 +2823,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))':
'@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -2831,7 +2831,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
vite: 7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
transitivePeerDependencies:
- supports-color
@@ -2906,8 +2906,8 @@ snapshots:
unist-util-visit: 5.1.0
unstorage: 1.17.4
vfile: 6.0.3
vite: 7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
vitefu: 1.1.2(vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))
vite: 7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
vitefu: 1.1.2(vite@7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1))
xxhash-wasm: 1.1.0
yargs-parser: 22.0.0
zod: 4.3.6
@@ -3044,7 +3044,7 @@ snapshots:
dependencies:
character-entities: 2.0.2
defu@6.1.4: {}
defu@6.1.7: {}
dequal@2.0.3: {}
@@ -3208,7 +3208,7 @@ snapshots:
dependencies:
cookie-es: 1.2.2
crossws: 0.3.5
defu: 6.1.4
defu: 6.1.7
destr: 2.0.5
iron-webcrypto: 1.2.1
node-mock-http: 1.0.4
@@ -4416,7 +4416,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1):
vite@7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.4)
@@ -4430,9 +4430,9 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.31.1
vitefu@1.1.2(vite@7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)):
vitefu@1.1.2(vite@7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)):
optionalDependencies:
vite: 7.3.1(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
vite: 7.3.2(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.31.1)
web-namespaces@2.0.1: {}