Compare commits

..

20 Commits

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


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

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

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


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

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

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

* fix: respect BASE_URL in SAML callback fallback

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


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

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

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


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

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

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

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


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

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

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

* docs: add starred lists UI screenshot

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


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

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

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

* Updated UI

* docs: add updated issue 240 UI screenshot

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

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

View File

@@ -9,6 +9,8 @@
NODE_ENV=production NODE_ENV=production
HOST=0.0.0.0 HOST=0.0.0.0
PORT=4321 PORT=4321
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
BASE_URL=/
# Database Configuration # Database Configuration
# For self-hosted, SQLite is used by default # 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 # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=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 # BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls # 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 # BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
@@ -63,6 +71,7 @@ DOCKER_TAG=latest
# INCLUDE_ARCHIVED=false # INCLUDE_ARCHIVED=false
# SKIP_FORKS=false # SKIP_FORKS=false
# MIRROR_STARRED=false # MIRROR_STARRED=false
# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos
# STARRED_REPOS_ORG=starred # Organization name for starred repos # STARRED_REPOS_ORG=starred # Organization name for starred repos
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner # STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner

View File

@@ -9,6 +9,8 @@ on:
- 'flake.nix' - 'flake.nix'
- 'flake.lock' - 'flake.lock'
- 'bun.nix' - 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml' - '.github/workflows/nix-build.yml'
pull_request: pull_request:
branches: [main] branches: [main]
@@ -16,6 +18,8 @@ on:
- 'flake.nix' - 'flake.nix'
- 'flake.lock' - 'flake.lock'
- 'bun.nix' - 'bun.nix'
- 'bun.lock'
- 'package.json'
- '.github/workflows/nix-build.yml' - '.github/workflows/nix-build.yml'
permissions: permissions:
@@ -39,6 +43,9 @@ jobs:
- name: Setup Nix Cache - name: Setup Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@main uses: DeterminateSystems/magic-nix-cache-action@main
- name: Regenerate bun.nix from bun.lock
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
- name: Check flake - name: Check flake
run: nix flake check --accept-flake-config run: nix flake check --accept-flake-config

View File

@@ -2,7 +2,7 @@
FROM oven/bun:1.3.10-debian AS base FROM oven/bun:1.3.10-debian AS base
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \ python3 make g++ gcc wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -16,6 +16,7 @@ COPY . .
RUN bun run build RUN bun run build
RUN mkdir -p dist/scripts && \ RUN mkdir -p dist/scripts && \
for script in scripts/*.ts; do \ 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"); \ bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
done done
@@ -28,7 +29,7 @@ RUN bun install --production --omit=peer --frozen-lockfile
# ---------------------------- # ----------------------------
# Build git-lfs from source with patched Go to resolve Go stdlib CVEs # Build git-lfs from source with patched Go to resolve Go stdlib CVEs
FROM debian:trixie-slim AS git-lfs-builder FROM debian:trixie-slim AS git-lfs-builder
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
wget ca-certificates git make \ wget ca-certificates git make \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
ARG GO_VERSION=1.25.8 ARG GO_VERSION=1.25.8
@@ -50,7 +51,7 @@ RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lf
# ---------------------------- # ----------------------------
FROM oven/bun:1.3.10-debian AS runner FROM oven/bun:1.3.10-debian AS runner
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
git wget sqlite3 openssl ca-certificates \ git wget sqlite3 openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
@@ -59,6 +60,7 @@ COPY --from=pruner /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh 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 COPY --from=builder /app/drizzle ./drizzle
# Remove build-only packages that are not needed at runtime # 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 HOST=0.0.0.0
ENV PORT=4321 ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db ENV DATABASE_URL=file:data/gitea-mirror.db
ENV BASE_URL=/
# Create directories and setup permissions # Create directories and setup permissions
RUN mkdir -p /app/certs && \ RUN mkdir -p /app/certs && \
@@ -90,6 +93,6 @@ VOLUME /app/data
EXPOSE 4321 EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1 CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
ENTRYPOINT ["./docker-entrypoint.sh"] ENTRYPOINT ["./docker-entrypoint.sh"]

View File

@@ -300,7 +300,22 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
### Reverse Proxy Configuration ### 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) ### Mirror Token Rotation (GitHub Token Changed)

View File

@@ -83,7 +83,7 @@
"overrides": { "overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0", "@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"devalue": "^5.6.4", "devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5", "fast-xml-parser": "^5.5.6",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"rollup": ">=4.59.0", "rollup": ">=4.59.0",
"svgo": "^4.0.1", "svgo": "^4.0.1",
@@ -957,9 +957,9 @@
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-builder": ["fast-xml-builder@1.1.3", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg=="], "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"fast-xml-parser": ["fast-xml-parser@5.5.5", "", { "dependencies": { "fast-xml-builder": "^1.1.3", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg=="], "fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],

804
design/giteamirror.pen Normal file
View File

@@ -0,0 +1,804 @@
{
"version": "2.9",
"children": [
{
"type": "frame",
"id": "eIiDx",
"x": 0,
"y": 0,
"name": "Scheduling Settings - Redesign",
"width": 1080,
"fill": "#09090B",
"cornerRadius": 16,
"gap": 24,
"padding": 32,
"children": [
{
"type": "frame",
"id": "7r0Wv",
"name": "Automatic Syncing Card",
"clip": true,
"width": "fill_container",
"fill": "#18181B",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#27272A"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "gyCPG",
"name": "Header",
"width": "fill_container",
"gap": 12,
"padding": [
20,
24
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "OunzZ",
"name": "headerIcon",
"width": 20,
"height": 20,
"iconFontName": "refresh-cw",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "fMdlX",
"name": "headerTitle",
"fill": "#FAFAFA",
"content": "Automatic Syncing",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
},
{
"type": "rectangle",
"id": "4cX02",
"name": "divider1",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "Kiezh",
"name": "Toggle Section",
"width": "fill_container",
"gap": 14,
"padding": [
20,
24
],
"children": [
{
"type": "frame",
"id": "QCPzN",
"name": "Checkbox",
"width": 20,
"height": 20,
"fill": "#6366F1",
"cornerRadius": 4,
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "4FTax",
"x": 3,
"y": 3,
"name": "checkIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#FFFFFF"
}
]
},
{
"type": "frame",
"id": "FTzs6",
"name": "toggleText",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "1nJKC",
"name": "toggleLabel",
"fill": "#FAFAFA",
"content": "Enable automatic repository syncing",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "r1O5t",
"name": "toggleDesc",
"fill": "#71717A",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Periodically sync GitHub changes to Gitea",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "rectangle",
"id": "nvQ6R",
"name": "divider2",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "FOoBn",
"name": "Schedule Builder",
"width": "fill_container",
"layout": "vertical",
"gap": 20,
"padding": 24,
"children": [
{
"type": "frame",
"id": "IqHEu",
"name": "schedHeader",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "RnVoM",
"name": "schedTitle",
"fill": "#A1A1AA",
"content": "SCHEDULE",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "600",
"letterSpacing": 1
},
{
"type": "frame",
"id": "aVtIZ",
"name": "tzBadge",
"fill": "#27272A",
"cornerRadius": 20,
"gap": 6,
"padding": [
4,
10
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "iXpYV",
"name": "tzIcon",
"width": 12,
"height": 12,
"iconFontName": "globe",
"iconFontFamily": "lucide",
"fill": "#71717A"
},
{
"type": "text",
"id": "WjPMl",
"name": "tzText",
"fill": "#A1A1AA",
"content": "UTC",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "500"
}
]
}
]
},
{
"type": "frame",
"id": "P02fk",
"name": "formRow",
"width": "fill_container",
"gap": 12,
"children": [
{
"type": "frame",
"id": "kcYK5",
"name": "Frequency",
"width": "fill_container",
"layout": "vertical",
"gap": 6,
"children": [
{
"type": "text",
"id": "vMvsN",
"name": "label2",
"fill": "#A1A1AA",
"content": "Frequency",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "frame",
"id": "3prth",
"name": "select2",
"width": "fill_container",
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "ANY36",
"name": "sel2Text",
"fill": "#FAFAFA",
"content": "Daily",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "GUWfd",
"name": "sel2Icon",
"width": 16,
"height": 16,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
},
{
"type": "frame",
"id": "xphp0",
"name": "Start Time",
"width": "fill_container",
"layout": "vertical",
"gap": 6,
"children": [
{
"type": "text",
"id": "l6VkR",
"name": "label3",
"fill": "#A1A1AA",
"content": "Start Time",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "frame",
"id": "lWBDi",
"name": "timeInput",
"width": "fill_container",
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "fbuMi",
"name": "timeText",
"fill": "#FAFAFA",
"content": "10:00 PM",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "5xKW7",
"name": "timeIcon",
"width": 16,
"height": 16,
"iconFontName": "clock-4",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
}
]
}
]
}
]
},
{
"type": "rectangle",
"id": "BtYt7",
"name": "divider3",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "520Kb",
"name": "Status Bar",
"width": "fill_container",
"padding": [
16,
24
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "J8JzX",
"name": "lastSync",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "MS5VM",
"name": "lastIcon",
"width": 14,
"height": 14,
"iconFontName": "history",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "8KJHY",
"name": "lastLabel",
"fill": "#52525B",
"content": "Last sync",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "Fz116",
"name": "lastValue",
"fill": "#A1A1AA",
"content": "Never",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "ZbRFN",
"name": "nextSync",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "wIKSk",
"name": "nextIcon",
"width": 14,
"height": 14,
"iconFontName": "calendar",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "ejqSP",
"name": "nextLabel",
"fill": "#52525B",
"content": "Next sync",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "M4oJ7",
"name": "nextValue",
"fill": "#6366F1",
"content": "Calculating...",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "7PK7H",
"name": "Database Maintenance Card",
"clip": true,
"width": "fill_container",
"height": "fill_container",
"fill": "#18181B",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#27272A"
},
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "FAaon",
"name": "Header",
"width": "fill_container",
"gap": 12,
"padding": [
20,
24
],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "64CaE",
"name": "rHeaderIcon",
"width": 20,
"height": 20,
"iconFontName": "database",
"iconFontFamily": "lucide",
"fill": "#A1A1AA"
},
{
"type": "text",
"id": "rvZlC",
"name": "rHeaderTitle",
"fill": "#FAFAFA",
"content": "Database Maintenance",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
}
]
},
{
"type": "rectangle",
"id": "nsM0M",
"name": "rDivider1",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "8zhPi",
"name": "Toggle Section",
"width": "fill_container",
"gap": 14,
"padding": [
20,
24
],
"children": [
{
"type": "frame",
"id": "eQbZk",
"name": "Checkbox",
"width": 20,
"height": 20,
"fill": "#6366F1",
"cornerRadius": 4,
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "t6PbY",
"x": 3,
"y": 3,
"name": "rCheckIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#FFFFFF"
}
]
},
{
"type": "frame",
"id": "lpBPI",
"name": "rToggleText",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "Kuy1S",
"name": "rToggleLabel",
"fill": "#FAFAFA",
"content": "Enable automatic database cleanup",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "text",
"id": "OviVY",
"name": "rToggleDesc",
"fill": "#71717A",
"textGrowth": "fixed-width",
"width": "fill_container",
"content": "Remove old activity logs to optimize storage",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "rectangle",
"id": "1og3D",
"name": "rDivider2",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "J7576",
"name": "Retention Section",
"width": "fill_container",
"layout": "vertical",
"gap": 16,
"padding": 24,
"children": [
{
"type": "frame",
"id": "JZA6R",
"name": "retLabelRow",
"gap": 6,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "Diiak",
"name": "retLabel",
"fill": "#FAFAFA",
"content": "Data retention period",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
},
{
"type": "icon_font",
"id": "1qqCe",
"name": "retInfoIcon",
"width": 14,
"height": 14,
"iconFontName": "info",
"iconFontFamily": "lucide",
"fill": "#52525B"
}
]
},
{
"type": "frame",
"id": "kfUjs",
"name": "retRow",
"width": "fill_container",
"gap": 16,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "9bhls",
"name": "retSelect",
"width": 180,
"height": 40,
"fill": "#27272A",
"cornerRadius": 8,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "#3F3F46"
},
"padding": [
0,
12
],
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "3NOod",
"name": "retSelText",
"fill": "#FAFAFA",
"content": "1 month",
"fontFamily": "Inter",
"fontSize": 13
},
{
"type": "icon_font",
"id": "8QBA8",
"name": "retSelIcon",
"width": 16,
"height": 16,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "#71717A"
}
]
},
{
"type": "text",
"id": "GA6ye",
"name": "retHelper",
"fill": "#52525B",
"content": "Cleanup runs every 2 days",
"fontFamily": "Inter",
"fontSize": 12
}
]
}
]
},
{
"type": "rectangle",
"id": "WfXVB",
"name": "rDivider3",
"fill": "#27272A",
"width": "fill_container",
"height": 1
},
{
"type": "frame",
"id": "WpXnI",
"name": "Cleanup Status",
"width": "fill_container",
"layout": "vertical",
"gap": 12,
"padding": [
16,
24
],
"children": [
{
"type": "frame",
"id": "fbpm5",
"name": "lastCleanup",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "DdLix",
"name": "lastCleanupLeft",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "FN2cj",
"name": "lastCleanIcon",
"width": 14,
"height": 14,
"iconFontName": "history",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "JjmMa",
"name": "lastCleanLabel",
"fill": "#52525B",
"content": "Last cleanup",
"fontFamily": "Inter",
"fontSize": 12
}
]
},
{
"type": "text",
"id": "l1Kph",
"name": "lastCleanValue",
"fill": "#A1A1AA",
"content": "Never",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "AWHY8",
"name": "nextCleanup",
"width": "fill_container",
"justifyContent": "space_between",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "sj0qN",
"name": "nextCleanupLeft",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "V6RTK",
"name": "nextCleanIcon",
"width": 14,
"height": 14,
"iconFontName": "calendar",
"iconFontFamily": "lucide",
"fill": "#52525B"
},
{
"type": "text",
"id": "wf0b4",
"name": "nextCleanLabel",
"fill": "#52525B",
"content": "Next cleanup",
"fontFamily": "Inter",
"fontSize": 12
}
]
},
{
"type": "text",
"id": "YWZGH",
"name": "nextCleanValue",
"fill": "#6366F1",
"content": "March 20, 2026 at 12:19 AM",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -22,6 +22,7 @@ services:
# BETTER_AUTH_URL=https://gitea-mirror.example.com # BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL.
# === CORE SETTINGS === # === CORE SETTINGS ===
# These are technically required but have working defaults # These are technically required but have working defaults
@@ -29,6 +30,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- BASE_URL=${BASE_URL:-/}
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321} - PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
# Optional concurrency controls (defaults match in-app defaults) # Optional concurrency controls (defaults match in-app defaults)
# If you want perfect ordering of issues and PRs, set these at 1 # 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} - MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5

View File

@@ -66,6 +66,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=dev-secret-key - BETTER_AUTH_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config # GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username} - GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
@@ -89,7 +90,11 @@ services:
# Optional: Skip TLS verification (insecure, use only for testing) # Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false} # - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
healthcheck: 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@@ -30,6 +30,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321} - 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), # 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 # BETTER_AUTH_URL=https://gitea-mirror.example.com
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com # BETTER_AUTH_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} - PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided # 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_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-} - HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5

View File

@@ -229,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP
# Start the application # Start the application
echo "Starting Gitea Mirror..." 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=$! APP_PID=$!
# Wait for the application to finish # Wait for the application to finish

View File

@@ -33,10 +33,11 @@ Essential application settings required for running Gitea Mirror.
| `NODE_ENV` | Application environment | `production` | No | | `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No | | `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | 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 | | `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_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 | | `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 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 | | `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 | | `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 | | `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
@@ -61,6 +62,7 @@ Settings for connecting to and configuring GitHub repository sources.
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` | | `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` | | `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` | | `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string | | `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` | | `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
@@ -301,6 +303,7 @@ services:
environment: environment:
# Core Configuration # Core Configuration
- NODE_ENV=production - NODE_ENV=production
- BASE_URL=/
- DATABASE_URL=file:data/gitea-mirror.db - DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here - BETTER_AUTH_SECRET=your-secure-secret-here
# Primary access URL: # Primary access URL:
@@ -369,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. **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 ### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes: The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -31,7 +31,7 @@
# Build the application # Build the application
gitea-mirror = pkgs.stdenv.mkDerivation { gitea-mirror = pkgs.stdenv.mkDerivation {
pname = "gitea-mirror"; pname = "gitea-mirror";
version = "3.9.6"; version = "3.14.1";
src = ./.; src = ./.;

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "3.13.3", "version": "3.15.0",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },
@@ -31,8 +31,8 @@
"test-shutdown": "bun scripts/test-graceful-shutdown.ts", "test-shutdown": "bun scripts/test-graceful-shutdown.ts",
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup", "test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
"preview": "bunx --bun astro preview", "preview": "bunx --bun astro preview",
"start": "bun dist/server/entry.mjs", "start": "bun scripts/runtime-server.ts",
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs", "start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts",
"test": "bun test", "test": "bun test",
"test:migrations": "bun scripts/validate-migrations.ts", "test:migrations": "bun scripts/validate-migrations.ts",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",
@@ -46,7 +46,7 @@
"overrides": { "overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0", "@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"devalue": "^5.6.4", "devalue": "^5.6.4",
"fast-xml-parser": "^5.5.5", "fast-xml-parser": "^5.5.6",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"svgo": "^4.0.1", "svgo": "^4.0.1",
"rollup": ">=4.59.0" "rollup": ">=4.59.0"

76
scripts/runtime-server.ts Normal file
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})`);
});

View File

@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react"; import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
import { withBase } from "@/lib/base-path";
export function NotFound() { export function NotFound() {
return ( return (
@@ -21,7 +22,7 @@ export function NotFound() {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button asChild className="w-full"> <Button asChild className="w-full">
<a href="/"> <a href={withBase("/")}>
<Home className="mr-2 h-4 w-4" /> <Home className="mr-2 h-4 w-4" />
Go to Dashboard Go to Dashboard
</a> </a>
@@ -45,21 +46,21 @@ export function NotFound() {
{/* Quick Links */} {/* Quick Links */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<a <a
href="/repositories" href={withBase("/repositories")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors" 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" /> <GitBranch className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Repositories</span> <span className="text-xs">Repositories</span>
</a> </a>
<a <a
href="/config" href={withBase("/config")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors" 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" /> <Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Config</span> <span className="text-xs">Config</span>
</a> </a>
<a <a
href="/docs" href={withBase("/docs")}
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors" 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" /> <BookOpen className="h-5 w-5 text-muted-foreground" />
@@ -77,4 +78,4 @@ export function NotFound() {
</Card> </Card>
</div> </div>
); );
} }

View File

@@ -36,6 +36,7 @@ import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh'; import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus'; import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout'; import { useNavigation } from '@/components/layout/MainLayout';
import { withBase } from '@/lib/base-path';
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -321,7 +322,7 @@ export function ActivityLog() {
setIsInitialLoading(true); setIsInitialLoading(true);
setShowCleanupDialog(false); setShowCleanupDialog(false);
const response = await fetch('/api/activities/cleanup', { const response = await fetch(withBase('/api/activities/cleanup'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id }), body: JSON.stringify({ userId: user.id }),

View File

@@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator';
import { toast, Toaster } from 'sonner'; import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils'; import { showErrorToast } from '@/lib/utils';
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react'; import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function LoginForm() { export function LoginForm() {
@@ -47,7 +48,7 @@ export function LoginForm() {
toast.success('Login successful!'); toast.success('Login successful!');
// Small delay before redirecting to see the success message // Small delay before redirecting to see the success message
setTimeout(() => { setTimeout(() => {
window.location.href = '/'; window.location.href = withBase('/');
}, 1000); }, 1000);
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
@@ -64,12 +65,15 @@ export function LoginForm() {
return; 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({ await authClient.signIn.sso({
email: ssoEmail || undefined, email: ssoEmail || undefined,
domain: domain, domain: domain,
providerId: providerId, providerId: providerId,
callbackURL: `${baseURL}/`, callbackURL,
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin. scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
}); });
} catch (error) { } catch (error) {
@@ -85,7 +89,7 @@ export function LoginForm() {
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<img <img
src="/logo.png" src={withBase('/logo.png')}
alt="Gitea Mirror Logo" alt="Gitea Mirror Logo"
className="h-8 w-10" className="h-8 w-10"
/> />

View File

@@ -7,6 +7,7 @@ import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils'; import { showErrorToast } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from 'lucide-react';
import { withBase } from '@/lib/base-path';
export function SignupForm() { export function SignupForm() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -42,7 +43,7 @@ export function SignupForm() {
toast.success('Account created successfully! Redirecting to dashboard...'); toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message // Small delay before redirecting to see the success message
setTimeout(() => { setTimeout(() => {
window.location.href = '/'; window.location.href = withBase('/');
}, 1500); }, 1500);
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
@@ -57,7 +58,7 @@ export function SignupForm() {
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<img <img
src="/logo.png" src={withBase('/logo.png')}
alt="Gitea Mirror Logo" alt="Gitea Mirror Logo"
className="h-8 w-10" className="h-8 w-10"
/> />

View File

@@ -1,7 +1,8 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,6 +20,7 @@ import {
Zap, Zap,
Info, Info,
Archive, Archive,
Globe,
} from "lucide-react"; } from "lucide-react";
import { import {
Tooltip, Tooltip,
@@ -28,6 +30,10 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config"; import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import {
buildClockCronExpression,
getNextCronOccurrence,
} from "@/lib/utils/schedule-utils";
interface AutomationSettingsProps { interface AutomationSettingsProps {
scheduleConfig: ScheduleConfig; scheduleConfig: ScheduleConfig;
@@ -38,15 +44,13 @@ interface AutomationSettingsProps {
isAutoSavingCleanup?: boolean; isAutoSavingCleanup?: boolean;
} }
const scheduleIntervals = [ const clockFrequencies = [
{ label: "Every hour", value: 3600 }, { label: "Every hour", value: 1 },
{ label: "Every 2 hours", value: 7200 }, { label: "Every 2 hours", value: 2 },
{ label: "Every 4 hours", value: 14400 }, { label: "Every 4 hours", value: 4 },
{ label: "Every 8 hours", value: 28800 }, { label: "Every 8 hours", value: 8 },
{ label: "Every 12 hours", value: 43200 }, { label: "Every 12 hours", value: 12 },
{ label: "Daily", value: 86400 }, { label: "Daily", value: 24 },
{ label: "Every 2 days", value: 172800 },
{ label: "Weekly", value: 604800 },
]; ];
const retentionPeriods = [ const retentionPeriods = [
@@ -85,6 +89,27 @@ export function AutomationSettings({
isAutoSavingSchedule, isAutoSavingSchedule,
isAutoSavingCleanup, isAutoSavingCleanup,
}: AutomationSettingsProps) { }: AutomationSettingsProps) {
const browserTimezone =
typeof Intl !== "undefined"
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
: "UTC";
// Use saved timezone, but treat "UTC" as unset for users who never chose it
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
const nextScheduledRun = useMemo(() => {
if (!scheduleConfig.enabled) return null;
const startTime = scheduleConfig.startTime || "22:00";
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
if (!cronExpression) return null;
try {
return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone);
} catch {
return null;
}
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
// Update nextRun for cleanup when settings change // Update nextRun for cleanup when settings change
useEffect(() => { useEffect(() => {
if (cleanupConfig.enabled && !cleanupConfig.nextRun) { if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
@@ -125,7 +150,7 @@ export function AutomationSettings({
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Automatic Syncing Section */} {/* Automatic Syncing Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50"> <div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2"> <h3 className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-primary" /> <RefreshCw className="h-4 w-4 text-primary" />
@@ -136,14 +161,21 @@ export function AutomationSettings({
)} )}
</div> </div>
<div className="space-y-4"> <div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="enable-auto-mirror" id="enable-auto-mirror"
checked={scheduleConfig.enabled} checked={scheduleConfig.enabled}
className="mt-1.25" className="mt-1.25"
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onScheduleChange({ ...scheduleConfig, enabled: !!checked }) onScheduleChange({
...scheduleConfig,
enabled: !!checked,
timezone: checked ? browserTimezone : scheduleConfig.timezone,
startTime: scheduleConfig.startTime || "22:00",
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
scheduleMode: "clock",
})
} }
/> />
<div className="space-y-0.5 flex-1"> <div className="space-y-0.5 flex-1">
@@ -154,79 +186,123 @@ export function AutomationSettings({
Enable automatic repository syncing Enable automatic repository syncing
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Periodically check GitHub for changes and mirror them to Gitea Periodically sync GitHub changes to Gitea
</p> </p>
</div> </div>
</div> </div>
{scheduleConfig.enabled && ( {scheduleConfig.enabled && (
<div className="ml-6 space-y-3"> <div className="space-y-3">
<div> <div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="mirror-interval" className="text-sm"> <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Sync frequency Schedule
</Label> </p>
<Select <span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
value={scheduleConfig.interval.toString()} <Globe className="h-3 w-3" />
onValueChange={(value) => {effectiveTimezone}
onScheduleChange({ </span>
...scheduleConfig, </div>
interval: parseInt(value, 10),
}) <div className="grid gap-3 sm:grid-cols-2">
} <div className="space-y-1.5">
> <Label
<SelectTrigger id="mirror-interval" className="mt-1.5"> htmlFor="clock-frequency"
<SelectValue /> className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
</SelectTrigger> >
<SelectContent> Frequency
{scheduleIntervals.map((option) => ( </Label>
<SelectItem <Select
key={option.value} value={String(scheduleConfig.clockFrequencyHours || 24)}
value={option.value.toString()} onValueChange={(value) =>
> onScheduleChange({
{option.label} ...scheduleConfig,
</SelectItem> scheduleMode: "clock",
))} clockFrequencyHours: parseInt(value, 10),
</SelectContent> startTime: scheduleConfig.startTime || "22:00",
</Select> timezone: effectiveTimezone,
})
}
>
<SelectTrigger id="clock-frequency" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{clockFrequencies.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label
htmlFor="clock-start-time"
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Start time
</Label>
<div className="relative">
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
<Clock className="size-4" />
</div>
<Input
id="clock-start-time"
type="time"
value={scheduleConfig.startTime || "22:00"}
onChange={(event) =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
startTime: event.target.value,
clockFrequencyHours:
scheduleConfig.clockFrequencyHours || 24,
timezone: effectiveTimezone,
})
}
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div> </div>
</div> </div>
)} )}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50"> <div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between text-xs"> <span className="flex items-center gap-1.5">
<span className="flex items-center gap-1.5"> <Clock className="h-3.5 w-3.5" />
<Clock className="h-3.5 w-3.5" /> Last sync{" "}
Last sync <span className="font-medium">
</span>
<span className="font-medium text-muted-foreground">
{scheduleConfig.lastRun {scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun) ? formatDate(scheduleConfig.lastRun)
: "Never"} : "Never"}
</span> </span>
</div> </span>
{scheduleConfig.enabled ? ( {scheduleConfig.enabled ? (
scheduleConfig.nextRun && ( <span className="flex items-center gap-1.5">
<div className="flex items-center justify-between text-xs"> <Calendar className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5"> Next sync{" "}
<Calendar className="h-3.5 w-3.5" /> <span className="font-medium text-primary">
Next sync {scheduleConfig.nextRun
</span> ? formatDate(scheduleConfig.nextRun)
<span className="font-medium"> : nextScheduledRun
{formatDate(scheduleConfig.nextRun)} ? formatDate(nextScheduledRun)
</span> : "Calculating..."}
</div> </span>
) </span>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <span>Enable syncing to schedule updates</span>
Enable automatic syncing to schedule periodic repository updates
</div>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Database Cleanup Section */} {/* Database Cleanup Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50"> <div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2"> <h3 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-primary" /> <Database className="h-4 w-4 text-primary" />
@@ -237,7 +313,7 @@ export function AutomationSettings({
)} )}
</div> </div>
<div className="space-y-4"> <div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="enable-auto-cleanup" id="enable-auto-cleanup"
@@ -255,13 +331,13 @@ export function AutomationSettings({
Enable automatic database cleanup Enable automatic database cleanup
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Remove old activity logs and events to optimize storage Remove old activity logs to optimize storage
</p> </p>
</div> </div>
</div> </div>
{cleanupConfig.enabled && ( {cleanupConfig.enabled && (
<div className="ml-6 space-y-5"> <div className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2"> <Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
Data retention period Data retention period
@@ -312,7 +388,7 @@ export function AutomationSettings({
</div> </div>
)} )}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50"> <div className="mt-auto space-y-2 pt-3 border-t border-border/50">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />

View File

@@ -24,6 +24,7 @@ import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { invalidateConfigCache } from '@/hooks/useConfigStatus'; import { invalidateConfigCache } from '@/hooks/useConfigStatus';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { withBase } from '@/lib/base-path';
type ConfigState = { type ConfigState = {
githubConfig: GitHubConfig; githubConfig: GitHubConfig;
@@ -35,6 +36,8 @@ type ConfigState = {
notificationConfig: NotificationConfig; notificationConfig: NotificationConfig;
}; };
const CONFIG_API_PATH = withBase('/api/config');
export function ConfigTabs() { export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({ const [config, setConfig] = useState<ConfigState>({
githubConfig: { githubConfig: {
@@ -42,6 +45,7 @@ export function ConfigTabs() {
token: '', token: '',
privateRepositories: false, privateRepositories: false,
mirrorStarred: false, mirrorStarred: false,
starredLists: [],
}, },
giteaConfig: { giteaConfig: {
url: '', url: '',
@@ -197,7 +201,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -263,7 +267,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -328,7 +332,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -377,7 +381,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -417,7 +421,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -452,7 +456,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),
@@ -497,7 +501,7 @@ export function ConfigTabs() {
}; };
try { try {
const response = await fetch('/api/config', { const response = await fetch(CONFIG_API_PATH, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload), body: JSON.stringify(reqPayload),

View File

@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -17,6 +18,7 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { import {
Info, Info,
Check,
GitBranch, GitBranch,
Star, Star,
Lock, Lock,
@@ -31,7 +33,9 @@ import {
ChevronDown, ChevronDown,
Funnel, Funnel,
HardDrive, HardDrive,
FileCode2 FileCode2,
Plus,
X
} from "lucide-react"; } from "lucide-react";
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config"; import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
import { import {
@@ -41,7 +45,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { githubApi } from "@/lib/api";
interface GitHubMirrorSettingsProps { interface GitHubMirrorSettingsProps {
githubConfig: GitHubConfig; githubConfig: GitHubConfig;
@@ -60,8 +73,42 @@ export function GitHubMirrorSettings({
onMirrorOptionsChange, onMirrorOptionsChange,
onAdvancedOptionsChange, onAdvancedOptionsChange,
}: GitHubMirrorSettingsProps) { }: GitHubMirrorSettingsProps) {
const [starListsOpen, setStarListsOpen] = React.useState(false);
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => { const [starListSearch, setStarListSearch] = React.useState("");
const [customStarListName, setCustomStarListName] = React.useState("");
const [availableStarLists, setAvailableStarLists] = React.useState<string[]>([]);
const [loadingStarLists, setLoadingStarLists] = React.useState(false);
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false);
const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => {
if (!Array.isArray(lists)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
for (const list of lists) {
const trimmed = list.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(trimmed);
}
return normalized;
}, []);
const selectedStarLists = React.useMemo(
() => normalizeStarListNames(githubConfig.starredLists),
[githubConfig.starredLists, normalizeStarListNames],
);
const allKnownStarLists = React.useMemo(
() => normalizeStarListNames([...availableStarLists, ...selectedStarLists]),
[availableStarLists, selectedStarLists, normalizeStarListNames],
);
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string | string[]) => {
onGitHubConfigChange({ ...githubConfig, [field]: value }); onGitHubConfigChange({ ...githubConfig, [field]: value });
}; };
@@ -83,6 +130,59 @@ export function GitHubMirrorSettings({
onAdvancedOptionsChange({ ...advancedOptions, [field]: value }); onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
}; };
const setSelectedStarLists = React.useCallback((lists: string[]) => {
onGitHubConfigChange({
...githubConfig,
starredLists: normalizeStarListNames(lists),
});
}, [githubConfig, normalizeStarListNames, onGitHubConfigChange]);
const loadStarLists = React.useCallback(async () => {
if (
loadingStarLists ||
loadedStarLists ||
attemptedStarListLoad ||
!githubConfig.mirrorStarred
) return;
setAttemptedStarListLoad(true);
setLoadingStarLists(true);
try {
const response = await githubApi.getStarredLists();
setAvailableStarLists(normalizeStarListNames(response.lists));
setLoadedStarLists(true);
} catch {
// Keep UX usable with manual custom input even if list fetch fails.
// Allow retry on next popover open.
setLoadedStarLists(false);
} finally {
setLoadingStarLists(false);
}
}, [
attemptedStarListLoad,
githubConfig.mirrorStarred,
loadedStarLists,
loadingStarLists,
normalizeStarListNames,
]);
React.useEffect(() => {
if (!starListsOpen || !githubConfig.mirrorStarred) return;
void loadStarLists();
}, [starListsOpen, githubConfig.mirrorStarred, loadStarLists]);
React.useEffect(() => {
if (!githubConfig.mirrorStarred) {
setStarListsOpen(false);
}
}, [githubConfig.mirrorStarred]);
React.useEffect(() => {
if (!starListsOpen) {
setAttemptedStarListLoad(false);
}
}, [starListsOpen]);
// When metadata is disabled, all components should be disabled // When metadata is disabled, all components should be disabled
const isMetadataEnabled = mirrorOptions.mirrorMetadata; const isMetadataEnabled = mirrorOptions.mirrorMetadata;
@@ -98,6 +198,17 @@ export function GitHubMirrorSettings({
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length; const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
const totalStarredOptions = 4; // releases, issues, PRs, wiki const totalStarredOptions = 4; // releases, issues, PRs, wiki
const normalizedStarListSearch = starListSearch.trim();
const canAddSearchAsStarList = normalizedStarListSearch.length > 0
&& !allKnownStarLists.some((list) => list.toLowerCase() === normalizedStarListSearch.toLowerCase());
const addCustomStarList = () => {
const trimmed = customStarListName.trim();
if (!trimmed) return;
setSelectedStarLists([...selectedStarLists, trimmed]);
setCustomStarListName("");
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Repository Selection Section */} {/* Repository Selection Section */}
@@ -312,6 +423,143 @@ export function GitHubMirrorSettings({
</div> </div>
)} )}
{/* Star list selection */}
{githubConfig.mirrorStarred && (
<div className="mt-4 space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Star Lists (optional)
</Label>
<Popover open={starListsOpen} onOpenChange={setStarListsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={starListsOpen}
className="w-full justify-between h-9 text-xs font-normal"
>
<span className="truncate text-left">
{selectedStarLists.length === 0
? "All starred repositories"
: `${selectedStarLists.length} list${selectedStarLists.length === 1 ? "" : "s"} selected`}
</span>
<ChevronDown className="ml-2 h-3 w-3 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[360px] p-0" align="start">
<Command>
<CommandInput
value={starListSearch}
onValueChange={setStarListSearch}
placeholder="Search GitHub star lists..."
/>
<CommandList>
<CommandEmpty>
{loadingStarLists ? "Loading star lists..." : "No matching lists"}
</CommandEmpty>
<CommandGroup>
{allKnownStarLists.map((list) => {
const isSelected = selectedStarLists.some(
(selected) => selected.toLowerCase() === list.toLowerCase(),
);
return (
<CommandItem
key={list}
value={list}
onSelect={() => {
if (isSelected) {
setSelectedStarLists(
selectedStarLists.filter(
(selected) => selected.toLowerCase() !== list.toLowerCase(),
),
);
} else {
setSelectedStarLists([...selectedStarLists, list]);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{list}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
{canAddSearchAsStarList && (
<div className="border-t p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-xs"
onClick={() => {
setSelectedStarLists([...selectedStarLists, normalizedStarListSearch]);
setStarListSearch("");
}}
>
<Plus className="mr-2 h-3.5 w-3.5" />
Add "{normalizedStarListSearch}"
</Button>
</div>
)}
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
Leave empty to mirror all starred repositories. Select one or more lists to limit syncing.
</p>
{selectedStarLists.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedStarLists.map((list) => (
<Badge key={list} variant="secondary" className="gap-1">
<span>{list}</span>
<button
type="button"
onClick={() =>
setSelectedStarLists(
selectedStarLists.filter(
(selected) => selected.toLowerCase() !== list.toLowerCase(),
),
)
}
className="rounded-sm hover:text-foreground/80"
aria-label={`Remove ${list} list`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={customStarListName}
onChange={(event) => setCustomStarListName(event.target.value)}
placeholder="Add custom list name"
className="h-8 text-xs"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={addCustomStarList}
disabled={!customStarListName.trim()}
>
Add
</Button>
</div>
</div>
)}
{/* Duplicate name handling for starred repos */} {/* Duplicate name handling for starred repos */}
{githubConfig.mirrorStarred && ( {githubConfig.mirrorStarred && (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">

View File

@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Bell, Activity, Send } from "lucide-react"; import { Bell, Activity, Send } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { NotificationConfig } from "@/types/config"; import type { NotificationConfig } from "@/types/config";
import { withBase } from "@/lib/base-path";
interface NotificationSettingsProps { interface NotificationSettingsProps {
notificationConfig: NotificationConfig; notificationConfig: NotificationConfig;
@@ -31,7 +32,7 @@ export function NotificationSettings({
const handleTestNotification = async () => { const handleTestNotification = async () => {
setIsTesting(true); setIsTesting(true);
try { try {
const resp = await fetch("/api/notifications/test", { const resp = await fetch(withBase("/api/notifications/test"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationConfig }), body: JSON.stringify({ notificationConfig }),

View File

@@ -14,6 +14,7 @@ import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { MultiSelect } from '@/components/ui/multi-select'; import { MultiSelect } from '@/components/ui/multi-select';
import { withBase } from '@/lib/base-path';
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean { function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
try { try {
@@ -100,6 +101,9 @@ export function SSOSettings() {
digestAlgorithm: 'sha256', digestAlgorithm: 'sha256',
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', 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 { } else {
requestData.entryPoint = providerForm.entryPoint; requestData.entryPoint = providerForm.entryPoint;
requestData.cert = providerForm.cert; requestData.cert = providerForm.cert;
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`; requestData.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
requestData.audience = providerForm.audience || window.location.origin; requestData.audience = providerForm.audience || appOrigin;
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned; requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
requestData.signatureAlgorithm = providerForm.signatureAlgorithm; requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
requestData.digestAlgorithm = providerForm.digestAlgorithm; requestData.digestAlgorithm = providerForm.digestAlgorithm;
@@ -517,7 +521,7 @@ export function SSOSettings() {
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
<div className="space-y-2"> <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']) && ( {isTrustedIssuer(providerForm.issuer, ['google.com']) && (
<p className="text-xs text-muted-foreground"> <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. 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" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
<div className="space-y-1"> <div className="space-y-1">
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p> <p>Callback URL: {buildAbsoluteAppUrl(`/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>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
</div> </div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -724,4 +728,4 @@ export function SSOSettings() {
</Card> </Card>
</div> </div>
); );
} }

View File

@@ -16,6 +16,7 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { usePageVisibility } from "@/hooks/usePageVisibility"; import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout"; import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
// Helper function to format last sync time // Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string { function formatLastSyncTime(date: Date | null): string {
@@ -110,7 +111,7 @@ export function Dashboard() {
useEffectForToasts(() => { useEffectForToasts(() => {
if (!user?.id) return; 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) => { eventSource.addEventListener("rate-limit", (event) => {
try { try {

View File

@@ -3,6 +3,7 @@ import type { MirrorJob } from "@/lib/db/schema";
import { formatDate, getStatusColor } from "@/lib/utils"; import { formatDate, getStatusColor } from "@/lib/utils";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react"; import { Activity, Clock } from "lucide-react";
import { withBase } from "@/lib/base-path";
interface RecentActivityProps { interface RecentActivityProps {
activities: MirrorJob[]; activities: MirrorJob[];
@@ -14,7 +15,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Activity</CardTitle> <CardTitle>Recent Activity</CardTitle>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/activity">View All</a> <a href={withBase("/activity")}>View All</a>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -27,7 +28,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<a href="/activity"> <a href={withBase("/activity")}>
<Activity className="h-3.5 w-3.5 mr-1.5" /> <Activity className="h-3.5 w-3.5 mr-1.5" />
View History View History
</a> </a>

View File

@@ -6,6 +6,7 @@ import type { Repository } from "@/lib/db/schema";
import { getStatusColor } from "@/lib/utils"; import { getStatusColor } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url"; import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
interface RepositoryListProps { interface RepositoryListProps {
repositories: Repository[]; repositories: Repository[];
@@ -42,7 +43,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Repositories</CardTitle> <CardTitle>Repositories</CardTitle>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/repositories">View All</a> <a href={withBase("/repositories")}>View All</a>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -54,7 +55,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
Configure your GitHub connection to start mirroring repositories. Configure your GitHub connection to start mirroring repositories.
</p> </p>
<Button asChild> <Button asChild>
<a href="/config">Configure GitHub</a> <a href={withBase("/config")}>Configure GitHub</a>
</Button> </Button>
</div> </div>
) : ( ) : (

View File

@@ -14,6 +14,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { withBase } from "@/lib/base-path";
interface HeaderProps { interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log"; currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
@@ -101,14 +102,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
<button <button
onClick={() => { onClick={() => {
if (currentPage !== 'dashboard') { if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/'); window.history.pushState({}, '', withBase('/'));
onNavigate?.('dashboard'); onNavigate?.('dashboard');
} }
}} }}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity" className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
> >
<img <img
src="/logo.png" src={withBase('/logo.png')}
alt="Gitea Mirror Logo" alt="Gitea Mirror Logo"
className="h-5 w-6" className="h-5 w-6"
/> />
@@ -163,7 +164,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<a href="/login">Login</a> <a href={withBase('/login')}>Login</a>
</Button> </Button>
)} )}
</div> </div>

View File

@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRepoSync } from "@/hooks/useSyncRepo"; import { useRepoSync } from "@/hooks/useSyncRepo";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { stripBasePath, withBase } from "@/lib/base-path";
// Navigation context to signal when navigation happens // Navigation context to signal when navigation happens
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 }); const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
@@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
// Handle browser back/forward navigation // Handle browser back/forward navigation
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
const path = window.location.pathname; const path = stripBasePath(window.location.pathname);
const pageMap: Record<string, AppProps['page']> = { const pageMap: Record<string, AppProps['page']> = {
'/': 'dashboard', '/': 'dashboard',
'/repositories': 'repositories', '/repositories': 'repositories',
@@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
if (!authLoading && !user) { if (!authLoading && !user) {
// Use window.location for client-side redirect // Use window.location for client-side redirect
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = withBase('/login');
} }
return null; return null;
} }

View File

@@ -9,6 +9,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { stripBasePath, withBase } from "@/lib/base-path";
interface SidebarProps { interface SidebarProps {
className?: string; className?: string;
@@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
useEffect(() => { useEffect(() => {
// Hydration happens here // Hydration happens here
const path = window.location.pathname; const path = stripBasePath(window.location.pathname);
setCurrentPath(path); setCurrentPath(path);
}, []); }, []);
// Listen for URL changes (browser back/forward) // Listen for URL changes (browser back/forward)
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
setCurrentPath(window.location.pathname); setCurrentPath(stripBasePath(window.location.pathname));
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
@@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
if (currentPath === href) return; if (currentPath === href) return;
// Update URL without page reload // Update URL without page reload
window.history.pushState({}, '', href); window.history.pushState({}, '', withBase(href));
setCurrentPath(href); setCurrentPath(href);
// Map href to page name for the parent component // 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. Check out the documentation for help with setup and configuration.
</p> </p>
<a <a
href="/docs" href={withBase("/docs")}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<a <a
href="/docs" href={withBase("/docs")}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(

View File

@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url"; import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor"; import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -85,7 +86,7 @@ export function OrganizationList({
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => { const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
// Call API to update organization destination // Call API to update organization destination
const response = await fetch(`/api/organizations/${orgId}`, { const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -189,7 +190,7 @@ export function OrganizationList({
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<a <a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`} href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer truncate" className="font-medium hover:underline cursor-pointer truncate"
> >
{org.name} {org.name}
@@ -264,7 +265,7 @@ export function OrganizationList({
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
<a <a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`} href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
className="text-xl font-semibold hover:underline cursor-pointer" className="text-xl font-semibold hover:underline cursor-pointer"
> >
{org.name} {org.name}

View File

@@ -50,6 +50,7 @@ import AddRepositoryDialog from "./AddRepositoryDialog";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout"; import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
const REPOSITORY_SORT_OPTIONS = [ const REPOSITORY_SORT_OPTIONS = [
{ value: "imported-desc", label: "Recently Imported" }, { value: "imported-desc", label: "Recently Imported" },
@@ -1518,7 +1519,7 @@ export default function Repository() {
<Button <Button
variant="default" variant="default"
onClick={() => { onClick={() => {
window.history.pushState({}, '', '/config'); window.history.pushState({}, '', withBase('/config'));
// We need to trigger a page change event for the navigation system // We need to trigger a page change event for the navigation system
window.dispatchEvent(new PopStateEvent('popstate')); window.dispatchEvent(new PopStateEvent('popstate'));
}} }}

View File

@@ -28,6 +28,7 @@ import {
import { InlineDestinationEditor } from "./InlineDestinationEditor"; import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { withBase } from "@/lib/base-path";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -102,7 +103,7 @@ export default function RepositoryTable({
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => { const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
// Call API to update repository destination // Call API to update repository destination
const response = await fetch(`/api/repositories/${repoId}`, { const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

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 = () => { const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) { if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme'); return localStorage.getItem('theme');

View File

@@ -8,6 +8,7 @@ import {
} from "react"; } from "react";
import { authApi } from "@/lib/api"; import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user"; import type { ExtendedUser } from "@/types/user";
import { withBase } from "@/lib/base-path";
interface AuthContextType { interface AuthContextType {
user: ExtendedUser | null; user: ExtendedUser | null;
@@ -61,9 +62,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Redirect user based on error // Redirect user based on error
if (err?.message === "No users found") { if (err?.message === "No users found") {
window.location.href = "/signup"; window.location.href = withBase("/signup");
} else { } else {
window.location.href = "/login"; window.location.href = withBase("/login");
} }
console.error("Auth check failed", err); console.error("Auth check failed", err);
} finally { } finally {
@@ -111,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { try {
await authApi.logout(); await authApi.logout();
setUser(null); setUser(null);
window.location.href = "/login"; window.location.href = withBase("/login");
} catch (err) { } catch (err) {
console.error("Logout error:", err); console.error("Logout error:", err);
} finally { } finally {

View File

@@ -8,6 +8,7 @@ import {
} from "react"; } from "react";
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client"; import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
import type { Session, AuthUser } from "@/lib/auth-client"; import type { Session, AuthUser } from "@/lib/auth-client";
import { withBase } from "@/lib/base-path";
interface AuthContextType { interface AuthContextType {
user: AuthUser | null; user: AuthUser | null;
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const result = await authClient.signIn.email({ const result = await authClient.signIn.email({
email, email,
password, password,
callbackURL: "/", callbackURL: withBase("/"),
}); });
if (result.error) { if (result.error) {
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
email, email,
password, password,
name: username, // Better Auth uses 'name' field for display name name: username, // Better Auth uses 'name' field for display name
callbackURL: "/", callbackURL: withBase("/"),
}); });
if (result.error) { if (result.error) {
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await authClient.signOut({ await authClient.signOut({
fetchOptions: { fetchOptions: {
onSuccess: () => { 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 the Better Auth session hook for direct use when needed
export { useBetterAuthSession }; export { useBetterAuthSession };

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import type { MirrorJob } from "@/lib/db/schema"; import type { MirrorJob } from "@/lib/db/schema";
import { withBase } from "@/lib/base-path";
interface UseSSEOptions { interface UseSSEOptions {
userId?: string; userId?: string;
@@ -41,7 +42,7 @@ export const useSSE = ({
} }
// Create new EventSource connection // Create new EventSource connection
const eventSource = new EventSource(`/api/sse?userId=${userId}`); const eventSource = new EventSource(`${withBase("/api/sse")}?userId=${userId}`);
eventSourceRef.current = eventSource; eventSourceRef.current = eventSource;
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useAuth } from "./useAuth"; import { useAuth } from "./useAuth";
import { withBase } from "@/lib/base-path";
interface UseRepoSyncOptions { interface UseRepoSyncOptions {
userId?: string; userId?: string;
@@ -51,7 +52,7 @@ export function useRepoSync({
const sync = async () => { const sync = async () => {
try { try {
const response = await fetch("/api/job/schedule-sync-repo", { const response = await fetch(withBase("/api/job/schedule-sync-repo"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -2,6 +2,7 @@
import '../styles/global.css'; import '../styles/global.css';
import '../styles/docs.css'; import '../styles/docs.css';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Accept title as a prop with a default value // Accept title as a prop with a default value
const { title = 'Gitea Mirror' } = Astro.props; const { title = 'Gitea Mirror' } = Astro.props;
@@ -11,7 +12,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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> <title>{title}</title>
<ThemeScript /> <ThemeScript />
</head> </head>

View File

@@ -1,5 +1,7 @@
import { withBase } from "@/lib/base-path";
// Base API URL // Base API URL
const API_BASE = "/api"; const API_BASE = withBase("/api");
// Helper function for API requests // Helper function for API requests
async function apiRequest<T>( async function apiRequest<T>(
@@ -78,6 +80,10 @@ export const githubApi = {
method: "POST", method: "POST",
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
}), }),
getStarredLists: () =>
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
method: "GET",
}),
}; };
// Gitea API // Gitea API

View File

@@ -3,6 +3,12 @@ import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins"; import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client"; import { ssoClient } from "@better-auth/sso/client";
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth"; 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({ export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin // 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 // Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') { if (url && typeof url === 'string' && url.trim() !== '') {
try { try {
// Validate URL format and remove trailing slash // Validate URL format and preserve optional base path
const validatedUrl = new URL(url.trim()); return normalizeAuthBaseUrl(url);
return validatedUrl.origin; // Use origin to ensure clean URL without path
} catch (e) { } catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`); 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 // Default for SSR - always return a valid URL
return 'http://localhost:4321'; return 'http://localhost:4321';
})(), })(),
basePath: '/api/auth', // Explicitly set the base path basePath: withBase('/api/auth'), // Explicitly set the base path
plugins: [ plugins: [
oidcClient(), oidcClient(),
ssoClient(), ssoClient(),

View File

@@ -5,6 +5,7 @@ import { sso } from "@better-auth/sso";
import { db, users } from "./db"; import { db, users } from "./db";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { withBase } from "./base-path";
/** /**
* Resolves the list of trusted origins for Better Auth CSRF validation. * Resolves the list of trusted origins for Better Auth CSRF validation.
@@ -97,7 +98,7 @@ export const auth = betterAuth({
try { try {
// Validate URL format and ensure it's a proper origin // Validate URL format and ensure it's a proper origin
const validatedUrl = new URL(url.trim()); 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); console.info('Using BETTER_AUTH_URL:', cleanUrl);
return cleanUrl; return cleanUrl;
} catch (e) { } catch (e) {
@@ -107,7 +108,7 @@ export const auth = betterAuth({
return defaultUrl; 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. // Trusted origins - this is how we support multiple access URLs.
// Uses the function form so that the origin can be auto-detected from // Uses the function form so that the origin can be auto-detected from
@@ -150,8 +151,8 @@ export const auth = betterAuth({
plugins: [ plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider // OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({ oidcProvider({
loginPage: "/login", loginPage: withBase("/login"),
consentPage: "/oauth/consent", consentPage: withBase("/oauth/consent"),
// Allow dynamic client registration for flexibility // Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true, allowDynamicClientRegistration: true,
// Note: trustedClients would be configured here if Better Auth supports it // Note: trustedClients would be configured here if Better Auth supports it

86
src/lib/base-path.test.ts Normal file
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
src/lib/base-path.ts Normal file
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;
}

View File

@@ -26,6 +26,7 @@ export const githubConfigSchema = z.object({
includeOrganizations: z.array(z.string()).default([]), includeOrganizations: z.array(z.string()).default([]),
starredReposOrg: z.string().optional(), starredReposOrg: z.string().optional(),
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"), starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"),
starredLists: z.array(z.string()).default([]),
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"), mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
defaultOrg: z.string().optional(), defaultOrg: z.string().optional(),
starredCodeOnly: z.boolean().default(false), starredCodeOnly: z.boolean().default(false),

View File

@@ -25,6 +25,7 @@ interface EnvConfig {
autoMirrorStarred?: boolean; autoMirrorStarred?: boolean;
starredReposOrg?: string; starredReposOrg?: string;
starredReposMode?: 'dedicated-org' | 'preserve-owner'; starredReposMode?: 'dedicated-org' | 'preserve-owner';
starredLists?: string[];
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed'; mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
}; };
gitea: { gitea: {
@@ -99,6 +100,9 @@ function parseEnvConfig(): EnvConfig {
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean) ? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
: undefined; : undefined;
const starredLists = process.env.MIRROR_STARRED_LISTS
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
: undefined;
return { return {
github: { github: {
@@ -117,6 +121,7 @@ function parseEnvConfig(): EnvConfig {
autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true', autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true',
starredReposOrg: process.env.STARRED_REPOS_ORG, starredReposOrg: process.env.STARRED_REPOS_ORG,
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner', starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
starredLists,
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed', mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
}, },
gitea: { gitea: {
@@ -267,6 +272,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors', defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false, starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false, autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false,
starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [],
}; };
// Build Gitea config // Build Gitea config

View File

@@ -555,6 +555,63 @@ describe("Enhanced Gitea Operations", () => {
expect(releaseCall.octokit).toBeDefined(); expect(releaseCall.octokit).toBeDefined();
}); });
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("ceph"));
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: true,
},
};
const repository: Repository = {
id: "repo789",
name: "test-repo",
fullName: "ceph/test-repo",
owner: "ceph",
cloneUrl: "https://github.com/ceph/test-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
mirroredLocation: "starred/test-repo",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain("/api/v1/repos/starred/test-repo/mirror-sync");
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
});
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => { test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true; mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true; mockShouldBlockSyncOnBackupFailure = true;

View File

@@ -52,6 +52,41 @@ interface GiteaRepoInfo {
private: boolean; private: boolean;
} }
interface SyncTargetCandidate {
owner: string;
repoName: string;
}
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
if (!location) return null;
const trimmed = location.trim();
if (!trimmed) return null;
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return null;
const owner = trimmed.slice(0, slashIndex).trim();
const repoName = trimmed.slice(slashIndex + 1).trim();
if (!owner || !repoName) return null;
return { owner, repoName };
}
function dedupeSyncTargets(targets: SyncTargetCandidate[]): SyncTargetCandidate[] {
const seen = new Set<string>();
const deduped: SyncTargetCandidate[] = [];
for (const target of targets) {
const key = `${target.owner}/${target.repoName}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(target);
}
return deduped;
}
/** /**
* Check if a repository exists in Gitea and return its details * Check if a repository exists in Gitea and return its details
*/ */
@@ -285,19 +320,78 @@ export async function syncGiteaRepoEnhanced({
}) })
.where(eq(repositories.id, repository.id!)); .where(eq(repositories.id, repository.id!));
// Get the expected owner // Resolve sync target in a backward-compatible order:
// 1) recorded mirroredLocation (actual historical mirror location)
// 2) owner derived from current strategy/config
const dependencies = deps ?? (await import("./gitea")); const dependencies = deps ?? (await import("./gitea"));
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository }); const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
const candidateTargets = dedupeSyncTargets([
...(recordedTarget ? [recordedTarget] : []),
{ owner: expectedOwner, repoName: repository.name },
]);
// Check if repo exists and get its info let repoOwner = expectedOwner;
const repoInfo = await getGiteaRepoInfo({ let repoName = repository.name;
config, let repoInfo: GiteaRepoInfo | null = null;
owner: repoOwner, let firstNonMirrorTarget: SyncTargetCandidate | null = null;
repoName: repository.name,
}); for (const target of candidateTargets) {
const candidateInfo = await getGiteaRepoInfo({
config,
owner: target.owner,
repoName: target.repoName,
});
if (!candidateInfo) {
continue;
}
if (!candidateInfo.mirror) {
if (!firstNonMirrorTarget) {
firstNonMirrorTarget = target;
}
continue;
}
repoOwner = target.owner;
repoName = target.repoName;
repoInfo = candidateInfo;
break;
}
if (!repoInfo) { if (!repoInfo) {
throw new Error(`Repository ${repository.name} not found in Gitea at ${repoOwner}/${repository.name}`); if (firstNonMirrorTarget) {
console.warn(
`[Sync] Repository ${repository.name} exists at ${firstNonMirrorTarget.owner}/${firstNonMirrorTarget.repoName} but is not configured as a mirror`
);
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: "Repository exists in Gitea but is not configured as a mirror. Manual intervention required.",
})
.where(eq(repositories.id, repository.id!));
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Cannot sync ${repository.name}: Not a mirror repository`,
details: `Repository ${repository.name} exists in Gitea but is not configured as a mirror. You may need to delete and recreate it as a mirror, or manually configure it as a mirror in Gitea.`,
status: "failed",
});
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
}
throw new Error(
`Repository ${repository.name} not found in Gitea. Tried locations: ${candidateTargets
.map((t) => `${t.owner}/${t.repoName}`)
.join(", ")}`
);
} }
// Check if it's a mirror repository // Check if it's a mirror repository
@@ -342,7 +436,7 @@ export async function syncGiteaRepoEnhanced({
giteaUrl: config.giteaConfig.url, giteaUrl: config.giteaConfig.url,
giteaToken: decryptedConfig.giteaConfig.token, giteaToken: decryptedConfig.giteaConfig.token,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepo: repository.name, giteaRepo: repoName,
octokit: fpOctokit, octokit: fpOctokit,
githubOwner: repository.owner, githubOwner: repository.owner,
githubRepo: repository.name, githubRepo: repository.name,
@@ -407,13 +501,13 @@ export async function syncGiteaRepoEnhanced({
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) { if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
const cloneUrl = const cloneUrl =
repoInfo.clone_url || repoInfo.clone_url ||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`; `${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repoName}.git`;
try { try {
const backupResult = await createPreSyncBundleBackup({ const backupResult = await createPreSyncBundleBackup({
config, config,
owner: repoOwner, owner: repoOwner,
repoName: repository.name, repoName,
cloneUrl, cloneUrl,
force: true, // Strategy already decided to backup; skip legacy gate force: true, // Strategy already decided to backup; skip legacy gate
}); });
@@ -464,22 +558,22 @@ export async function syncGiteaRepoEnhanced({
// Update mirror interval if needed // Update mirror interval if needed
if (config.giteaConfig?.mirrorInterval) { if (config.giteaConfig?.mirrorInterval) {
try { try {
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`); console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`; const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
await httpPatch(updateUrl, { await httpPatch(updateUrl, {
mirror_interval: config.giteaConfig.mirrorInterval, mirror_interval: config.giteaConfig.mirrorInterval,
}, { }, {
Authorization: `token ${decryptedConfig.giteaConfig.token}`, Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}); });
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`); console.log(`[Sync] Successfully updated mirror interval for ${repoOwner}/${repoName}`);
} catch (updateError) { } catch (updateError) {
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError); console.warn(`[Sync] Failed to update mirror interval for ${repoOwner}/${repoName}:`, updateError);
// Continue with sync even if interval update fails // Continue with sync even if interval update fails
} }
} }
// Perform the sync // Perform the sync
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`; const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
try { try {
const response = await httpPost(apiUrl, undefined, { const response = await httpPost(apiUrl, undefined, {
@@ -536,7 +630,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.releases = true; metadataState.components.releases = true;
metadataUpdated = true; metadataUpdated = true;
@@ -568,7 +662,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.issues = true; metadataState.components.issues = true;
metadataState.components.labels = true; metadataState.components.labels = true;
@@ -601,7 +695,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.pullRequests = true; metadataState.components.pullRequests = true;
metadataUpdated = true; metadataUpdated = true;
@@ -631,7 +725,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.labels = true; metadataState.components.labels = true;
metadataUpdated = true; metadataUpdated = true;
@@ -670,7 +764,7 @@ export async function syncGiteaRepoEnhanced({
octokit, octokit,
repository, repository,
giteaOwner: repoOwner, giteaOwner: repoOwner,
giteaRepoName: repository.name, giteaRepoName: repoName,
}); });
metadataState.components.milestones = true; metadataState.components.milestones = true;
metadataUpdated = true; metadataUpdated = true;
@@ -708,7 +802,7 @@ export async function syncGiteaRepoEnhanced({
updatedAt: new Date(), updatedAt: new Date(),
lastMirrored: new Date(), lastMirrored: new Date(),
errorMessage: null, errorMessage: null,
mirroredLocation: `${repoOwner}/${repository.name}`, mirroredLocation: `${repoOwner}/${repoName}`,
metadata: metadataUpdated metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState) ? serializeRepositoryMetadataState(metadataState)
: repository.metadata ?? null, : repository.metadata ?? null,
@@ -720,7 +814,7 @@ export async function syncGiteaRepoEnhanced({
repositoryId: repository.id, repositoryId: repository.id,
repositoryName: repository.name, repositoryName: repository.name,
message: `Sync requested for repository: ${repository.name}`, message: `Sync requested for repository: ${repository.name}`,
details: `Mirror sync was requested for ${repository.name}.`, details: `Mirror sync was requested for ${repoOwner}/${repoName}.`,
status: "synced", status: "synced",
}); });

View File

@@ -13,6 +13,7 @@ import { db, organizations, repositories } from "./db";
import { eq, and, ne } from "drizzle-orm"; import { eq, and, ne } from "drizzle-orm";
import { decryptConfigTokens } from "./utils/config-encryption"; import { decryptConfigTokens } from "./utils/config-encryption";
import { formatDateShort } from "./utils"; import { formatDateShort } from "./utils";
import { buildGithubSourceAuthPayload } from "./utils/mirror-source-auth";
import { import {
parseRepositoryMetadataState, parseRepositoryMetadataState,
serializeRepositoryMetadataState, serializeRepositoryMetadataState,
@@ -816,14 +817,22 @@ export const mirrorGithubRepoToGitea = async ({
// Add authentication for private repositories // Add authentication for private repositories
if (repository.isPrivate) { if (repository.isPrivate) {
if (!config.githubConfig.token) { const githubOwner =
throw new Error( (
"GitHub token is required to mirror private repositories." config.githubConfig as typeof config.githubConfig & {
); owner?: string;
} }
// Use separate auth fields (required for Forgejo 12+ compatibility) ).owner || "";
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token; Object.assign(
migratePayload,
buildGithubSourceAuthPayload({
token: decryptedConfig.githubConfig.token,
githubOwner,
githubUsername: config.githubConfig.username,
repositoryOwner: repository.owner,
})
);
} }
// Track whether the Gitea migrate call succeeded so the catch block // Track whether the Gitea migrate call succeeded so the catch block
@@ -1496,14 +1505,22 @@ export async function mirrorGitHubRepoToGiteaOrg({
// Add authentication for private repositories // Add authentication for private repositories
if (repository.isPrivate) { if (repository.isPrivate) {
if (!config.githubConfig?.token) { const githubOwner =
throw new Error( (
"GitHub token is required to mirror private repositories." config.githubConfig as typeof config.githubConfig & {
); owner?: string;
} }
// Use separate auth fields (required for Forgejo 12+ compatibility) )?.owner || "";
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token; Object.assign(
migratePayload,
buildGithubSourceAuthPayload({
token: decryptedConfig.githubConfig?.token,
githubOwner,
githubUsername: config.githubConfig?.username,
repositoryOwner: repository.owner,
})
);
} }
let migrateSucceeded = false; let migrateSucceeded = false;

View File

@@ -0,0 +1,319 @@
import { describe, expect, test, mock } from "bun:test";
import {
getGithubStarredListNames,
getGithubStarredRepositories,
} from "@/lib/github";
function makeRestStarredRepo(overrides: Record<string, unknown> = {}) {
return {
name: "demo",
full_name: "acme/demo",
html_url: "https://github.com/acme/demo",
clone_url: "https://github.com/acme/demo.git",
owner: {
login: "acme",
type: "Organization",
},
private: false,
fork: false,
has_issues: true,
archived: false,
size: 123,
language: "TypeScript",
description: "Demo",
default_branch: "main",
visibility: "public",
disabled: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
...overrides,
};
}
function makeGraphqlListRepo(
nameWithOwner: string,
overrides: Record<string, unknown> = {},
) {
const [owner, name] = nameWithOwner.split("/");
return {
__typename: "Repository" as const,
name,
nameWithOwner,
url: `https://github.com/${nameWithOwner}`,
sshUrl: `git@github.com:${nameWithOwner}.git`,
isPrivate: false,
isFork: false,
isArchived: false,
isDisabled: false,
hasIssuesEnabled: true,
diskUsage: 456,
description: `${name} repo`,
defaultBranchRef: { name: "main" },
visibility: "PUBLIC" as const,
updatedAt: "2024-01-02T00:00:00Z",
createdAt: "2024-01-01T00:00:00Z",
owner: {
__typename: "Organization" as const,
login: owner,
},
primaryLanguage: { name: "TypeScript" },
...overrides,
};
}
describe("GitHub starred lists support", () => {
test("falls back to REST starred endpoint when no lists are configured", async () => {
const paginate = mock(async () => [makeRestStarredRepo()]);
const graphql = mock(async () => {
throw new Error("GraphQL should not be used in REST fallback path");
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: { githubConfig: { starredLists: [] } } as any,
});
expect(repos).toHaveLength(1);
expect(repos[0].fullName).toBe("acme/demo");
expect(repos[0].isStarred).toBe(true);
expect(paginate).toHaveBeenCalledTimes(1);
expect(graphql).toHaveBeenCalledTimes(0);
});
test("filters starred repositories by configured list names and de-duplicates", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [
null,
{ id: "list-1", name: "HomeLab" },
{ id: "list-2", name: "DotTools" },
{ id: "list-3", name: "Ideas" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-1") {
return {
node: {
items: {
nodes: [
null,
makeGraphqlListRepo("acme/repo-a"),
makeGraphqlListRepo("acme/repo-b"),
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [
makeGraphqlListRepo("acme/repo-b"),
makeGraphqlListRepo("acme/repo-c"),
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["homelab", "dottools"],
},
} as any,
});
expect(repos).toHaveLength(3);
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
"acme/repo-a",
"acme/repo-b",
"acme/repo-c",
]);
expect(paginate).toHaveBeenCalledTimes(0);
});
test("matches configured list names even when separators differ", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [
{ id: "list-1", name: "UI Frontend" },
{ id: "list-2", name: "Email | Self - Hosted" },
{ id: "list-3", name: "PaaS | Hosting | Deploy" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-1") {
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/ui-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
if (variables.listId === "list-2") {
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/email-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [makeGraphqlListRepo("acme/paas-app")],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
const repos = await getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["ui-frontend", "email-self-hosted", "paas-hosting-deploy"],
},
} as any,
});
expect(repos).toHaveLength(3);
expect(repos.map((repo) => repo.fullName).sort()).toEqual([
"acme/email-app",
"acme/paas-app",
"acme/ui-app",
]);
expect(paginate).toHaveBeenCalledTimes(0);
});
test("throws when configured star list names do not match any GitHub list", async () => {
const paginate = mock(async () => []);
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables || !("listId" in variables)) {
return {
viewer: {
lists: {
nodes: [{ id: "list-1", name: "HomeLab" }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
}
return {
node: {
items: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = {
paginate,
graphql,
activity: {
listReposStarredByAuthenticatedUser: () => {},
},
} as any;
await expect(
getGithubStarredRepositories({
octokit,
config: {
githubConfig: {
starredLists: ["MissingList"],
},
} as any,
}),
).rejects.toThrow("Configured GitHub star lists not found");
expect(paginate).toHaveBeenCalledTimes(0);
});
test("returns all available starred list names with pagination", async () => {
const graphql = mock(async (_query: string, variables?: Record<string, unknown>) => {
if (!variables?.after) {
return {
viewer: {
lists: {
nodes: [
null,
{ id: "a", name: "HomeLab" },
{ id: "b", name: "DotTools" },
],
pageInfo: { hasNextPage: true, endCursor: "cursor-1" },
},
},
};
}
return {
viewer: {
lists: {
nodes: [
{ id: "c", name: "Ideas" },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
};
});
const octokit = { graphql } as any;
const lists = await getGithubStarredListNames({ octokit });
expect(lists).toEqual(["HomeLab", "DotTools", "Ideas"]);
expect(graphql).toHaveBeenCalledTimes(2);
});
});

View File

@@ -300,6 +300,239 @@ export async function getGithubRepositories({
} }
} }
function getStarredListMatchKey(rawValue: string): string {
const normalized = rawValue.normalize("NFKC").trim().toLowerCase();
const tokens = normalized.match(/[\p{L}\p{N}]+/gu);
return tokens ? tokens.join("") : "";
}
function normalizeStarredListNames(rawLists: unknown): string[] {
if (!Array.isArray(rawLists)) return [];
const deduped = new Map<string, string>();
for (const value of rawLists) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (!trimmed) continue;
const matchKey = getStarredListMatchKey(trimmed);
if (!matchKey || deduped.has(matchKey)) continue;
deduped.set(matchKey, trimmed);
}
return [...deduped.values()];
}
function toHttpsCloneUrl(repoUrl: string): string {
return repoUrl.endsWith(".git") ? repoUrl : `${repoUrl}.git`;
}
interface GitHubStarListNode {
id: string;
name: string;
}
interface GitHubRepositoryListItem {
__typename: "Repository";
name: string;
nameWithOwner: string;
url: string;
sshUrl: string;
isPrivate: boolean;
isFork: boolean;
isArchived: boolean;
isDisabled: boolean;
hasIssuesEnabled: boolean;
diskUsage: number;
description: string | null;
defaultBranchRef: { name: string } | null;
visibility: "PUBLIC" | "PRIVATE" | "INTERNAL";
updatedAt: string;
createdAt: string;
owner: {
__typename: "Organization" | "User" | string;
login: string;
};
primaryLanguage: {
name: string;
} | null;
}
async function getGithubStarLists(octokit: Octokit): Promise<GitHubStarListNode[]> {
const allLists: GitHubStarListNode[] = [];
let cursor: string | null = null;
do {
const result = await octokit.graphql<{
viewer: {
lists: {
nodes: Array<GitHubStarListNode | null> | null;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
};
}>(
`
query($after: String) {
viewer {
lists(first: 50, after: $after) {
nodes {
id
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{ after: cursor },
);
const lists = (result.viewer.lists.nodes ?? []).filter(
(list): list is GitHubStarListNode =>
!!list &&
typeof list.id === "string" &&
typeof list.name === "string",
);
allLists.push(...lists);
if (!result.viewer.lists.pageInfo.hasNextPage) break;
cursor = result.viewer.lists.pageInfo.endCursor;
} while (cursor);
return allLists;
}
async function getGithubRepositoriesForStarList(
octokit: Octokit,
listId: string,
): Promise<GitHubRepositoryListItem[]> {
const repositories: GitHubRepositoryListItem[] = [];
let cursor: string | null = null;
do {
const result = await octokit.graphql<{
node: {
items: {
nodes: Array<GitHubRepositoryListItem | null> | null;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
} | null;
}>(
`
query($listId: ID!, $after: String) {
node(id: $listId) {
... on UserList {
items(first: 100, after: $after) {
nodes {
__typename
... on Repository {
name
nameWithOwner
url
sshUrl
isPrivate
isFork
isArchived
isDisabled
hasIssuesEnabled
diskUsage
description
defaultBranchRef {
name
}
visibility
updatedAt
createdAt
owner {
__typename
login
}
primaryLanguage {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`,
{ listId, after: cursor },
);
const listNode = result.node;
if (!listNode) break;
const nodes = listNode.items.nodes ?? [];
for (const node of nodes) {
if (node?.__typename === "Repository") {
repositories.push(node);
}
}
if (!listNode.items.pageInfo.hasNextPage) break;
cursor = listNode.items.pageInfo.endCursor;
} while (cursor);
return repositories;
}
function mapGraphqlRepoToGitRepo(repo: GitHubRepositoryListItem): GitRepo {
const visibility = (repo.visibility ?? "PUBLIC").toLowerCase() as GitRepo["visibility"];
const createdAt = repo.createdAt ? new Date(repo.createdAt) : new Date();
const updatedAt = repo.updatedAt ? new Date(repo.updatedAt) : new Date();
return {
name: repo.name,
fullName: repo.nameWithOwner,
url: repo.url,
cloneUrl: toHttpsCloneUrl(repo.url),
owner: repo.owner.login,
organization: repo.owner.__typename === "Organization" ? repo.owner.login : undefined,
mirroredLocation: "",
destinationOrg: null,
isPrivate: repo.isPrivate,
isForked: repo.isFork,
forkedFrom: undefined,
hasIssues: repo.hasIssuesEnabled,
isStarred: true,
isArchived: repo.isArchived,
size: repo.diskUsage ?? 0,
hasLFS: false,
hasSubmodules: false,
language: repo.primaryLanguage?.name ?? null,
description: repo.description,
defaultBranch: repo.defaultBranchRef?.name || "main",
visibility,
status: "imported",
isDisabled: repo.isDisabled,
lastMirrored: undefined,
errorMessage: undefined,
importedAt: new Date(),
createdAt,
updatedAt,
};
}
export async function getGithubStarredRepositories({ export async function getGithubStarredRepositories({
octokit, octokit,
config, config,
@@ -308,6 +541,46 @@ export async function getGithubStarredRepositories({
config: Partial<Config>; config: Partial<Config>;
}): Promise<GitRepo[]> { }): Promise<GitRepo[]> {
try { try {
const configuredLists = normalizeStarredListNames(
config.githubConfig?.starredLists,
);
if (configuredLists.length > 0) {
const allLists = await getGithubStarLists(octokit);
const configuredMatchKeySet = new Set(
configuredLists.map((list) => getStarredListMatchKey(list)),
);
const matchedLists = allLists.filter((list) =>
configuredMatchKeySet.has(getStarredListMatchKey(list.name)),
);
if (matchedLists.length === 0) {
const availableListNames = normalizeStarredListNames(
allLists.map((list) => list.name),
);
const preview = availableListNames.slice(0, 20).join(", ");
const availableSuffix = preview
? `. Available lists: ${preview}${availableListNames.length > 20 ? ", ..." : ""}`
: "";
throw new Error(
`Configured GitHub star lists not found: ${configuredLists.join(", ")}${availableSuffix}`,
);
}
const deduped = new Map<string, GitRepo>();
for (const list of matchedLists) {
const repos = await getGithubRepositoriesForStarList(octokit, list.id);
for (const repo of repos) {
const key = repo.nameWithOwner.toLowerCase();
if (deduped.has(key)) continue;
deduped.set(key, mapGraphqlRepoToGitRepo(repo));
}
}
return [...deduped.values()];
}
const starredRepos = await octokit.paginate( const starredRepos = await octokit.paginate(
octokit.activity.listReposStarredByAuthenticatedUser, octokit.activity.listReposStarredByAuthenticatedUser,
{ {
@@ -362,6 +635,15 @@ export async function getGithubStarredRepositories({
} }
} }
export async function getGithubStarredListNames({
octokit,
}: {
octokit: Octokit;
}): Promise<string[]> {
const lists = await getGithubStarLists(octokit);
return normalizeStarredListNames(lists.map((list) => list.name));
}
/** /**
* Get user github organizations * Get user github organizations
*/ */

View File

@@ -6,6 +6,31 @@ import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption"; import { decrypt } from "@/lib/utils/encryption";
function sanitizeTestNotificationError(error: unknown): string {
if (!(error instanceof Error)) {
return "Failed to send test notification";
}
const safeErrorPatterns = [
/topic is required/i,
/url and token are required/i,
/unknown provider/i,
/bad request/i,
/unauthorized/i,
/forbidden/i,
/not found/i,
/timeout/i,
/network error/i,
/invalid/i,
];
if (safeErrorPatterns.some((pattern) => pattern.test(error.message))) {
return error.message;
}
return "Failed to send test notification";
}
/** /**
* Sends a notification using the configured provider. * Sends a notification using the configured provider.
* NEVER throws -- all errors are caught and logged. * NEVER throws -- all errors are caught and logged.
@@ -63,8 +88,7 @@ export async function testNotification(
} }
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); return { success: false, error: sanitizeTestNotificationError(error) };
return { success: false, error: message };
} }
} }

View File

@@ -8,34 +8,72 @@ import { db, configs, repositories } from '@/lib/db';
import { eq, and, or } from 'drizzle-orm'; import { eq, and, or } from 'drizzle-orm';
import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea'; import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea';
import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption'; import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption';
import { parseInterval, formatDuration } from '@/lib/utils/duration-parser'; import { formatDuration } from '@/lib/utils/duration-parser';
import type { Repository } from '@/lib/db/schema'; import type { Repository } from '@/lib/db/schema';
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository'; import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils'; import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility'; import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
import { createMirrorJob } from '@/lib/helpers'; import { createMirrorJob } from '@/lib/helpers';
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
let schedulerInterval: NodeJS.Timeout | null = null; let schedulerInterval: NodeJS.Timeout | null = null;
let isSchedulerRunning = false; let isSchedulerRunning = false;
let hasPerformedAutoStart = false; // Track if we've already done auto-start let hasPerformedAutoStart = false; // Track if we've already done auto-start
/** function resolveScheduleSettings(config: any): { source: string | number; timezone: string } {
* Parse schedule interval with enhanced support for duration strings, cron, and numbers const scheduleConfig = config.scheduleConfig || {};
* Supports formats like: "8h", "30m", "24h", "0 0/2 * * *", or plain numbers (seconds) const source = scheduleConfig.interval ||
*/ config.giteaConfig?.mirrorInterval ||
function parseScheduleInterval(interval: string | number): number { '1h';
const timezone = normalizeTimezone(scheduleConfig.timezone || 'UTC');
return { source, timezone };
}
function calculateNextRun(config: any, currentTime: Date): Date {
const { source, timezone } = resolveScheduleSettings(config);
try { try {
const milliseconds = parseInterval(interval); return getNextScheduledRun(source, currentTime, timezone);
console.log(`[Scheduler] Parsed interval "${interval}" as ${formatDuration(milliseconds)}`);
return milliseconds;
} catch (error) { } catch (error) {
console.error(`[Scheduler] Failed to parse interval "${interval}": ${error instanceof Error ? error.message : 'Unknown error'}`); console.error(
const defaultInterval = 60 * 60 * 1000; // 1 hour `[Scheduler] Failed to calculate next run from source "${String(source)}" (timezone=${timezone}): ${
console.log(`[Scheduler] Using default interval: ${formatDuration(defaultInterval)}`); error instanceof Error ? error.message : 'Unknown error'
return defaultInterval; }`
);
const fallbackMs = 60 * 60 * 1000; // 1 hour
return new Date(currentTime.getTime() + fallbackMs);
} }
} }
function logNextRun(userId: string, source: string | number, timezone: string, currentTime: Date, nextRun: Date): void {
const deltaMs = Math.max(0, nextRun.getTime() - currentTime.getTime());
const scheduleKind = isCronExpression(source) ? 'cron' : 'interval';
console.log(
`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} ` +
`(in ${formatDuration(deltaMs)}) using ${scheduleKind} "${String(source)}" [timezone=${timezone}]`
);
}
async function persistScheduleRunState(config: any, currentTime: Date, forceEnabled = false): Promise<Date> {
const scheduleConfig = config.scheduleConfig || {};
const { source, timezone } = resolveScheduleSettings(config);
const nextRun = calculateNextRun(config, currentTime);
await db.update(configs).set({
scheduleConfig: {
...scheduleConfig,
...(forceEnabled ? { enabled: true } : {}),
lastRun: currentTime,
nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
logNextRun(config.userId, source, timezone, currentTime, nextRun);
return nextRun;
}
/** /**
* Run scheduled mirror sync for a single user configuration * Run scheduled mirror sync for a single user configuration
*/ */
@@ -53,29 +91,9 @@ async function runScheduledSync(config: any): Promise<void> {
// Update lastRun timestamp // Update lastRun timestamp
const currentTime = new Date(); const currentTime = new Date();
const scheduleConfig = config.scheduleConfig || {}; const scheduleConfig = config.scheduleConfig || {};
const { source, timezone } = resolveScheduleSettings(config);
// Priority order: scheduleConfig.interval > giteaConfig.mirrorInterval > default console.log(`[Scheduler] Using schedule source for user ${userId}: ${String(source)} (timezone=${timezone})`);
const intervalSource = scheduleConfig.interval || await persistScheduleRunState(config, currentTime);
config.giteaConfig?.mirrorInterval ||
'1h'; // Default to 1 hour instead of 3600 seconds
console.log(`[Scheduler] Using interval source for user ${userId}: ${intervalSource}`);
const interval = parseScheduleInterval(intervalSource);
// Note: The interval timing is calculated from the LAST RUN time, not from container startup
// This means if GITEA_MIRROR_INTERVAL=8h, the next sync will be 8 hours from the last completed sync
const nextRun = new Date(currentTime.getTime() + interval);
console.log(`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} (in ${formatDuration(interval)})`);
await db.update(configs).set({
scheduleConfig: {
...scheduleConfig,
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
// Auto-discovery: Check for new GitHub repositories // Auto-discovery: Check for new GitHub repositories
if (scheduleConfig.autoImport !== false) { if (scheduleConfig.autoImport !== false) {
@@ -553,22 +571,7 @@ async function performInitialAutoStart(): Promise<void> {
// Still update the schedule config to indicate scheduling is active // Still update the schedule config to indicate scheduling is active
const currentTime = new Date(); const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval || const nextRun = await persistScheduleRunState(config, currentTime, true);
config.giteaConfig?.mirrorInterval ||
'8h';
const interval = parseScheduleInterval(intervalSource);
const nextRun = new Date(currentTime.getTime() + interval);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true,
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`); console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
continue; continue;
} }
@@ -580,21 +583,7 @@ async function performInitialAutoStart(): Promise<void> {
// Still update schedule config timestamps // Still update schedule config timestamps
const currentTime2 = new Date(); const currentTime2 = new Date();
const intervalSource2 = config.scheduleConfig?.interval || const nextRun2 = await persistScheduleRunState(config, currentTime2, true);
config.giteaConfig?.mirrorInterval ||
'8h';
const interval2 = parseScheduleInterval(intervalSource2);
const nextRun2 = new Date(currentTime2.getTime() + interval2);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true,
lastRun: currentTime2,
nextRun: nextRun2,
},
updatedAt: currentTime2,
}).where(eq(configs.id, config.id));
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`); console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`);
continue; continue;
@@ -681,21 +670,7 @@ async function performInitialAutoStart(): Promise<void> {
// Update the schedule config to indicate we've run // Update the schedule config to indicate we've run
const currentTime = new Date(); const currentTime = new Date();
const intervalSource = config.scheduleConfig?.interval || const nextRun = await persistScheduleRunState(config, currentTime, true);
config.giteaConfig?.mirrorInterval ||
'8h';
const interval = parseScheduleInterval(intervalSource);
const nextRun = new Date(currentTime.getTime() + interval);
await db.update(configs).set({
scheduleConfig: {
...config.scheduleConfig,
enabled: true, // Ensure scheduling is enabled
lastRun: currentTime,
nextRun: nextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`); console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
@@ -772,6 +747,25 @@ async function schedulerLoop(): Promise<void> {
for (const config of validConfigs) { for (const config of validConfigs) {
const scheduleConfig = config.scheduleConfig || {}; const scheduleConfig = config.scheduleConfig || {};
const { source, timezone } = resolveScheduleSettings(config);
// For clock-based schedules, initialize nextRun instead of running immediately.
if (!scheduleConfig.nextRun && isCronExpression(source)) {
const initializedNextRun = calculateNextRun(config, currentTime);
await db.update(configs).set({
scheduleConfig: {
...scheduleConfig,
nextRun: initializedNextRun,
},
updatedAt: currentTime,
}).where(eq(configs.id, config.id));
console.log(
`[Scheduler] Initialized next run for user ${config.userId}: ${initializedNextRun.toISOString()} ` +
`from cron "${source}" [timezone=${timezone}]`
);
continue;
}
// Check if it's time to run based on nextRun // Check if it's time to run based on nextRun
if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) { if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) {

View File

@@ -2,8 +2,9 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { httpRequest, HttpError } from "@/lib/http-client"; import { httpRequest, HttpError } from "@/lib/http-client";
import type { RepoStatus } from "@/types/Repository"; 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[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));

View File

@@ -2,6 +2,7 @@ import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { encrypt } from "@/lib/utils/encryption"; import { encrypt } from "@/lib/utils/encryption";
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
export interface DefaultConfigOptions { export interface DefaultConfigOptions {
userId: string; userId: string;
@@ -13,7 +14,7 @@ export interface DefaultConfigOptions {
giteaToken?: string; giteaToken?: string;
giteaUsername?: string; giteaUsername?: string;
scheduleEnabled?: boolean; scheduleEnabled?: boolean;
scheduleInterval?: number; scheduleInterval?: number | string;
cleanupEnabled?: boolean; cleanupEnabled?: boolean;
cleanupRetentionDays?: number; cleanupRetentionDays?: number;
}; };
@@ -47,8 +48,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
// Schedule config from env - default to ENABLED // Schedule config from env - default to ENABLED
const scheduleEnabled = envOverrides.scheduleEnabled ?? const scheduleEnabled = envOverrides.scheduleEnabled ??
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED (process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
const scheduleInterval = envOverrides.scheduleInterval ?? const scheduleInterval = envOverrides.scheduleInterval ??
(process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily (process.env.SCHEDULE_INTERVAL || 86400); // Default: daily
const scheduleTimezone = normalizeTimezone(process.env.SCHEDULE_TIMEZONE || "UTC");
let scheduleNextRun: Date | null = null;
if (scheduleEnabled) {
try {
scheduleNextRun = getNextScheduledRun(scheduleInterval, new Date(), scheduleTimezone);
} catch {
scheduleNextRun = new Date(Date.now() + 86400 * 1000);
}
}
// Cleanup config from env - default to ENABLED // Cleanup config from env - default to ENABLED
const cleanupEnabled = envOverrides.cleanupEnabled ?? const cleanupEnabled = envOverrides.cleanupEnabled ??
@@ -75,6 +85,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
includeOrganizations: [], includeOrganizations: [],
starredReposOrg: "starred", starredReposOrg: "starred",
starredReposMode: "dedicated-org", starredReposMode: "dedicated-org",
starredLists: [],
mirrorStrategy: "preserve", mirrorStrategy: "preserve",
defaultOrg: "github-mirrors", defaultOrg: "github-mirrors",
}, },
@@ -104,11 +115,12 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
exclude: [], exclude: [],
scheduleConfig: { scheduleConfig: {
enabled: scheduleEnabled, enabled: scheduleEnabled,
interval: scheduleInterval, interval: String(scheduleInterval),
timezone: scheduleTimezone,
concurrent: false, concurrent: false,
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
lastRun: null, lastRun: null,
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null, nextRun: scheduleNextRun,
}, },
cleanupConfig: { cleanupConfig: {
enabled: cleanupEnabled, enabled: cleanupEnabled,

View File

@@ -0,0 +1,36 @@
import { expect, test } from "bun:test";
import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper";
import { scheduleConfigSchema } from "@/lib/db/schema";
test("mapUiScheduleToDb - builds cron from start time + frequency", () => {
const existing = scheduleConfigSchema.parse({});
const mapped = mapUiScheduleToDb(
{
enabled: true,
scheduleMode: "clock",
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "Asia/Kolkata",
},
existing
);
expect(mapped.enabled).toBe(true);
expect(mapped.interval).toBe("0 22 * * *");
expect(mapped.timezone).toBe("Asia/Kolkata");
});
test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
const mapped = mapDbScheduleToUi(
scheduleConfigSchema.parse({
enabled: true,
interval: "15 22,6,14 * * *",
timezone: "Asia/Kolkata",
})
);
expect(mapped.scheduleMode).toBe("clock");
expect(mapped.clockFrequencyHours).toBe(8);
expect(mapped.startTime).toBe("22:15");
expect(mapped.timezone).toBe("Asia/Kolkata");
});

View File

@@ -12,6 +12,7 @@ import type {
import { z } from "zod"; import { z } from "zod";
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema"; import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
import { parseInterval } from "@/lib/utils/duration-parser"; import { parseInterval } from "@/lib/utils/duration-parser";
import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils";
// Use the actual database schema types // Use the actual database schema types
type DbGitHubConfig = z.infer<typeof githubConfigSchema>; type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
@@ -19,6 +20,17 @@ type DbGiteaConfig = z.infer<typeof giteaConfigSchema>;
type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>; type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>;
type DbCleanupConfig = z.infer<typeof cleanupConfigSchema>; type DbCleanupConfig = z.infer<typeof cleanupConfigSchema>;
function normalizeStarredLists(lists: string[] | undefined): string[] {
if (!Array.isArray(lists)) return [];
const deduped = new Set<string>();
for (const list of lists) {
const trimmed = list.trim();
if (!trimmed) continue;
deduped.add(trimmed);
}
return [...deduped];
}
/** /**
* Maps UI config structure to database schema structure * Maps UI config structure to database schema structure
*/ */
@@ -49,6 +61,7 @@ export function mapUiToDbConfig(
// Starred repos organization // Starred repos organization
starredReposOrg: giteaConfig.starredReposOrg, starredReposOrg: giteaConfig.starredReposOrg,
starredReposMode: giteaConfig.starredReposMode || "dedicated-org", starredReposMode: giteaConfig.starredReposMode || "dedicated-org",
starredLists: normalizeStarredLists(githubConfig.starredLists),
// Mirror strategy // Mirror strategy
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve", mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
@@ -130,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): {
token: dbConfig.githubConfig?.token || "", token: dbConfig.githubConfig?.token || "",
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred
starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists),
}; };
// Map from database Gitea config to UI fields // Map from database Gitea config to UI fields
@@ -197,15 +211,42 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
? { ...(existing as unknown as DbScheduleConfig) } ? { ...(existing as unknown as DbScheduleConfig) }
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig); : (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
// Store interval as seconds string to avoid lossy cron conversion const baseInterval = typeof base.interval === "string"
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0 ? base.interval
? String(uiSchedule.interval) : String(base.interval ?? 86400);
: (typeof base.interval === 'string' ? base.interval : String(86400));
const timezone = normalizeTimezone(
typeof uiSchedule.timezone === "string"
? uiSchedule.timezone
: base.timezone || "UTC"
);
let intervalExpression = baseInterval;
if (uiSchedule.scheduleMode === "clock") {
const cronExpression = buildClockCronExpression(
uiSchedule.startTime || "22:00",
Number(uiSchedule.clockFrequencyHours || 24)
);
if (cronExpression) {
intervalExpression = cronExpression;
}
} else if (typeof uiSchedule.intervalExpression === "string" && uiSchedule.intervalExpression.trim().length > 0) {
intervalExpression = uiSchedule.intervalExpression.trim();
} else if (typeof uiSchedule.interval === "number" && Number.isFinite(uiSchedule.interval) && uiSchedule.interval > 0) {
intervalExpression = String(Math.floor(uiSchedule.interval));
} else if (typeof uiSchedule.interval === "string" && uiSchedule.interval.trim().length > 0) {
intervalExpression = uiSchedule.interval.trim();
}
const scheduleChanged = baseInterval !== intervalExpression || normalizeTimezone(base.timezone || "UTC") !== timezone;
return { return {
...base, ...base,
enabled: !!uiSchedule.enabled, enabled: !!uiSchedule.enabled,
interval: intervalSeconds, interval: intervalExpression,
timezone,
nextRun: scheduleChanged ? undefined : base.nextRun,
} as DbScheduleConfig; } as DbScheduleConfig;
} }
@@ -218,11 +259,21 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
return { return {
enabled: false, enabled: false,
interval: 86400, // Default to daily (24 hours) interval: 86400, // Default to daily (24 hours)
intervalExpression: "86400",
scheduleMode: "interval",
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "UTC",
lastRun: null, lastRun: null,
nextRun: null, nextRun: null,
}; };
} }
const intervalExpression = typeof dbSchedule.interval === "string"
? dbSchedule.interval
: String(dbSchedule.interval ?? 86400);
const parsedClockSchedule = parseClockCronExpression(intervalExpression);
// Parse interval supporting numbers (seconds), duration strings, and cron // Parse interval supporting numbers (seconds), duration strings, and cron
let intervalSeconds = 86400; // Default to daily (24 hours) let intervalSeconds = 86400; // Default to daily (24 hours)
try { try {
@@ -240,6 +291,11 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
return { return {
enabled: dbSchedule.enabled || false, enabled: dbSchedule.enabled || false,
interval: intervalSeconds, interval: intervalSeconds,
intervalExpression,
scheduleMode: parsedClockSchedule ? "clock" : "interval",
clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24,
startTime: parsedClockSchedule?.startTime ?? "22:00",
timezone: normalizeTimezone(dbSchedule.timezone || "UTC"),
lastRun: dbSchedule.lastRun || null, lastRun: dbSchedule.lastRun || null,
nextRun: dbSchedule.nextRun || null, nextRun: dbSchedule.nextRun || null,
}; };

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { buildGithubSourceAuthPayload } from "./mirror-source-auth";
describe("buildGithubSourceAuthPayload", () => {
test("uses configured owner when available", () => {
const auth = buildGithubSourceAuthPayload({
token: "ghp_test_token",
githubOwner: "ConfiguredOwner",
githubUsername: "fallback-user",
repositoryOwner: "repo-owner",
});
expect(auth).toEqual({
auth_username: "ConfiguredOwner",
auth_password: "ghp_test_token",
auth_token: "ghp_test_token",
});
});
test("falls back to configured username then repository owner", () => {
const authFromUsername = buildGithubSourceAuthPayload({
token: "token1",
githubUsername: "configured-user",
repositoryOwner: "repo-owner",
});
expect(authFromUsername.auth_username).toBe("configured-user");
const authFromRepoOwner = buildGithubSourceAuthPayload({
token: "token2",
repositoryOwner: "repo-owner",
});
expect(authFromRepoOwner.auth_username).toBe("repo-owner");
});
test("uses x-access-token as last-resort username", () => {
const auth = buildGithubSourceAuthPayload({
token: "ghp_test_token",
});
expect(auth.auth_username).toBe("x-access-token");
});
test("trims token whitespace", () => {
const auth = buildGithubSourceAuthPayload({
token: " ghp_trimmed ",
githubUsername: "user",
});
expect(auth.auth_password).toBe("ghp_trimmed");
expect(auth.auth_token).toBe("ghp_trimmed");
});
test("throws when token is missing", () => {
expect(() =>
buildGithubSourceAuthPayload({
token: " ",
githubUsername: "user",
})
).toThrow("GitHub token is required to mirror private repositories.");
});
});

View File

@@ -0,0 +1,46 @@
interface BuildGithubSourceAuthPayloadParams {
token?: string | null;
githubOwner?: string | null;
githubUsername?: string | null;
repositoryOwner?: string | null;
}
export interface GithubSourceAuthPayload {
auth_username: string;
auth_password: string;
auth_token: string;
}
const DEFAULT_GITHUB_AUTH_USERNAME = "x-access-token";
function normalize(value?: string | null): string {
return typeof value === "string" ? value.trim() : "";
}
/**
* Build source credentials for private GitHub repository mirroring.
* GitHub expects username + token-as-password over HTTPS (not the GitLab-style "oauth2" username).
*/
export function buildGithubSourceAuthPayload({
token,
githubOwner,
githubUsername,
repositoryOwner,
}: BuildGithubSourceAuthPayloadParams): GithubSourceAuthPayload {
const normalizedToken = normalize(token);
if (!normalizedToken) {
throw new Error("GitHub token is required to mirror private repositories.");
}
const authUsername =
normalize(githubOwner) ||
normalize(githubUsername) ||
normalize(repositoryOwner) ||
DEFAULT_GITHUB_AUTH_USERNAME;
return {
auth_username: authUsername,
auth_password: normalizedToken,
auth_token: normalizedToken,
};
}

View File

@@ -0,0 +1,65 @@
import { expect, test } from "bun:test";
import {
buildClockCronExpression,
getNextCronOccurrence,
getNextScheduledRun,
isCronExpression,
normalizeTimezone,
parseClockCronExpression,
} from "./schedule-utils";
test("isCronExpression - detects 5-part cron expressions", () => {
expect(isCronExpression("0 22 * * *")).toBe(true);
expect(isCronExpression("8h")).toBe(false);
expect(isCronExpression(3600)).toBe(false);
});
test("buildClockCronExpression - creates daily and hourly expressions", () => {
expect(buildClockCronExpression("22:00", 24)).toBe("0 22 * * *");
expect(buildClockCronExpression("22:15", 8)).toBe("15 22,6,14 * * *");
expect(buildClockCronExpression("10:30", 1)).toBe("30 * * * *");
expect(buildClockCronExpression("10:30", 7)).toBeNull();
});
test("parseClockCronExpression - parses generated expressions", () => {
expect(parseClockCronExpression("0 22 * * *")).toEqual({
startTime: "22:00",
frequencyHours: 24,
});
expect(parseClockCronExpression("15 22,6,14 * * *")).toEqual({
startTime: "22:15",
frequencyHours: 8,
});
expect(parseClockCronExpression("30 * * * *")).toEqual({
startTime: "00:30",
frequencyHours: 1,
});
expect(parseClockCronExpression("0 3 * * 1-5")).toBeNull();
});
test("getNextCronOccurrence - computes next run in UTC", () => {
const from = new Date("2026-03-18T15:20:00.000Z");
const next = getNextCronOccurrence("0 22 * * *", from, "UTC");
expect(next.toISOString()).toBe("2026-03-18T22:00:00.000Z");
});
test("getNextCronOccurrence - respects timezone", () => {
const from = new Date("2026-03-18T15:20:00.000Z");
// 22:00 IST equals 16:30 UTC
const next = getNextCronOccurrence("0 22 * * *", from, "Asia/Kolkata");
expect(next.toISOString()).toBe("2026-03-18T16:30:00.000Z");
});
test("getNextScheduledRun - handles interval and cron schedules", () => {
const from = new Date("2026-03-18T00:00:00.000Z");
const intervalNext = getNextScheduledRun("8h", from, "UTC");
expect(intervalNext.toISOString()).toBe("2026-03-18T08:00:00.000Z");
const cronNext = getNextScheduledRun("0 */6 * * *", from, "UTC");
expect(cronNext.toISOString()).toBe("2026-03-18T06:00:00.000Z");
});
test("normalizeTimezone - falls back to UTC for invalid values", () => {
expect(normalizeTimezone("Invalid/Zone")).toBe("UTC");
expect(normalizeTimezone("Asia/Kolkata")).toBe("Asia/Kolkata");
});

View File

@@ -0,0 +1,420 @@
import { parseInterval } from "@/lib/utils/duration-parser";
const WEEKDAY_INDEX: Record<string, number> = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
const MONTH_INDEX: Record<string, number> = {
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
};
interface ParsedCronField {
wildcard: boolean;
values: Set<number>;
}
interface ZonedDateParts {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
}
interface ParsedCronExpression {
minute: ParsedCronField;
hour: ParsedCronField;
dayOfMonth: ParsedCronField;
month: ParsedCronField;
dayOfWeek: ParsedCronField;
}
const zonedPartsFormatterCache = new Map<string, Intl.DateTimeFormat>();
const zonedWeekdayFormatterCache = new Map<string, Intl.DateTimeFormat>();
function pad2(value: number): string {
return value.toString().padStart(2, "0");
}
export function isCronExpression(value: unknown): value is string {
return typeof value === "string" && value.trim().split(/\s+/).length === 5;
}
export function normalizeTimezone(timezone?: string): string {
const candidate = timezone?.trim() || "UTC";
try {
// Validate timezone eagerly.
new Intl.DateTimeFormat("en-US", { timeZone: candidate });
return candidate;
} catch {
return "UTC";
}
}
function getZonedPartsFormatter(timezone: string): Intl.DateTimeFormat {
const cacheKey = normalizeTimezone(timezone);
const cached = zonedPartsFormatterCache.get(cacheKey);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: cacheKey,
hour12: false,
hourCycle: "h23",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
zonedPartsFormatterCache.set(cacheKey, formatter);
return formatter;
}
function getZonedWeekdayFormatter(timezone: string): Intl.DateTimeFormat {
const cacheKey = normalizeTimezone(timezone);
const cached = zonedWeekdayFormatterCache.get(cacheKey);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: cacheKey,
weekday: "short",
});
zonedWeekdayFormatterCache.set(cacheKey, formatter);
return formatter;
}
function getZonedDateParts(date: Date, timezone: string): ZonedDateParts {
const safeTimezone = normalizeTimezone(timezone);
const parts = getZonedPartsFormatter(safeTimezone).formatToParts(date);
const month = Number(parts.find((part) => part.type === "month")?.value);
const dayOfMonth = Number(parts.find((part) => part.type === "day")?.value);
const hour = Number(parts.find((part) => part.type === "hour")?.value);
const minute = Number(parts.find((part) => part.type === "minute")?.value);
const weekdayLabel = getZonedWeekdayFormatter(safeTimezone)
.format(date)
.toLowerCase()
.slice(0, 3);
const dayOfWeek = WEEKDAY_INDEX[weekdayLabel];
if (
Number.isNaN(month) ||
Number.isNaN(dayOfMonth) ||
Number.isNaN(hour) ||
Number.isNaN(minute) ||
typeof dayOfWeek !== "number"
) {
throw new Error("Unable to extract timezone-aware date parts");
}
return {
month,
dayOfMonth,
hour,
minute,
dayOfWeek,
};
}
function parseCronAtom(
atom: string,
min: number,
max: number,
aliases?: Record<string, number>,
allowSevenAsSunday = false
): number {
const normalized = atom.trim().toLowerCase();
if (normalized.length === 0) {
throw new Error("Empty cron atom");
}
const aliasValue = aliases?.[normalized];
const parsed = aliasValue ?? Number(normalized);
if (!Number.isInteger(parsed)) {
throw new Error(`Invalid cron value: "${atom}"`);
}
const normalizedDowValue = allowSevenAsSunday && parsed === 7 ? 0 : parsed;
if (normalizedDowValue < min || normalizedDowValue > max) {
throw new Error(
`Cron value "${atom}" out of range (${min}-${max})`
);
}
return normalizedDowValue;
}
function addRangeValues(
target: Set<number>,
start: number,
end: number,
step: number,
min: number,
max: number
): void {
if (step <= 0) {
throw new Error(`Invalid cron step: ${step}`);
}
if (start < min || end > max || start > end) {
throw new Error(`Invalid cron range: ${start}-${end}`);
}
for (let value = start; value <= end; value += step) {
target.add(value);
}
}
function parseCronField(
field: string,
min: number,
max: number,
aliases?: Record<string, number>,
allowSevenAsSunday = false
): ParsedCronField {
const raw = field.trim();
if (raw === "*") {
const values = new Set<number>();
for (let i = min; i <= max; i += 1) values.add(i);
return { wildcard: true, values };
}
const values = new Set<number>();
const segments = raw.split(",");
for (const segment of segments) {
const trimmedSegment = segment.trim();
if (!trimmedSegment) {
throw new Error(`Invalid cron field "${field}"`);
}
const [basePart, stepPart] = trimmedSegment.split("/");
const step = stepPart ? Number(stepPart) : 1;
if (!Number.isInteger(step) || step <= 0) {
throw new Error(`Invalid cron step "${stepPart}"`);
}
if (basePart === "*") {
addRangeValues(values, min, max, step, min, max);
continue;
}
if (basePart.includes("-")) {
const [startRaw, endRaw] = basePart.split("-");
const start = parseCronAtom(
startRaw,
min,
max,
aliases,
allowSevenAsSunday
);
const end = parseCronAtom(
endRaw,
min,
max,
aliases,
allowSevenAsSunday
);
addRangeValues(values, start, end, step, min, max);
continue;
}
const value = parseCronAtom(
basePart,
min,
max,
aliases,
allowSevenAsSunday
);
values.add(value);
}
return { wildcard: false, values };
}
function parseCronExpression(expression: string): ParsedCronExpression {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(
'Cron expression must have 5 parts: "minute hour day month weekday"'
);
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
return {
minute: parseCronField(minute, 0, 59),
hour: parseCronField(hour, 0, 23),
dayOfMonth: parseCronField(dayOfMonth, 1, 31),
month: parseCronField(month, 1, 12, MONTH_INDEX),
dayOfWeek: parseCronField(dayOfWeek, 0, 6, WEEKDAY_INDEX, true),
};
}
function matchesCron(
cron: ParsedCronExpression,
parts: ZonedDateParts
): boolean {
if (!cron.minute.values.has(parts.minute)) return false;
if (!cron.hour.values.has(parts.hour)) return false;
if (!cron.month.values.has(parts.month)) return false;
const dayOfMonthWildcard = cron.dayOfMonth.wildcard;
const dayOfWeekWildcard = cron.dayOfWeek.wildcard;
const dayOfMonthMatches = cron.dayOfMonth.values.has(parts.dayOfMonth);
const dayOfWeekMatches = cron.dayOfWeek.values.has(parts.dayOfWeek);
if (dayOfMonthWildcard && dayOfWeekWildcard) return true;
if (dayOfMonthWildcard) return dayOfWeekMatches;
if (dayOfWeekWildcard) return dayOfMonthMatches;
return dayOfMonthMatches || dayOfWeekMatches;
}
export function getNextCronOccurrence(
expression: string,
fromDate: Date,
timezone = "UTC",
maxLookaheadMinutes = 2 * 365 * 24 * 60
): Date {
const cron = parseCronExpression(expression);
const safeTimezone = normalizeTimezone(timezone);
const base = new Date(fromDate);
base.setSeconds(0, 0);
const firstCandidateMs = base.getTime() + 60_000;
for (let offset = 0; offset <= maxLookaheadMinutes; offset += 1) {
const candidate = new Date(firstCandidateMs + offset * 60_000);
const candidateParts = getZonedDateParts(candidate, safeTimezone);
if (matchesCron(cron, candidateParts)) {
return candidate;
}
}
throw new Error(
`Could not find next cron occurrence for "${expression}" within ${maxLookaheadMinutes} minutes`
);
}
export function getNextScheduledRun(
schedule: string | number,
fromDate: Date,
timezone = "UTC"
): Date {
if (isCronExpression(schedule)) {
return getNextCronOccurrence(schedule, fromDate, timezone);
}
const intervalMs = parseInterval(schedule);
return new Date(fromDate.getTime() + intervalMs);
}
export function buildClockCronExpression(
startTime: string,
frequencyHours: number
): string | null {
const parsed = startTime.match(/^([01]\d|2[0-3]):([0-5]\d)$/);
if (!parsed) return null;
if (!Number.isInteger(frequencyHours) || frequencyHours <= 0) {
return null;
}
const hour = Number(parsed[1]);
const minute = Number(parsed[2]);
if (frequencyHours === 24) {
return `${minute} ${hour} * * *`;
}
if (frequencyHours === 1) {
return `${minute} * * * *`;
}
if (24 % frequencyHours !== 0) {
return null;
}
const hourCount = 24 / frequencyHours;
const hours: number[] = [];
for (let i = 0; i < hourCount; i += 1) {
hours.push((hour + i * frequencyHours) % 24);
}
return `${minute} ${hours.join(",")} * * *`;
}
export function parseClockCronExpression(
expression: string
): { startTime: string; frequencyHours: number } | null {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = parts;
if (dayRaw !== "*" || monthRaw !== "*" || weekdayRaw !== "*") {
return null;
}
const minute = Number(minuteRaw);
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
return null;
}
if (hourRaw === "*") {
return {
startTime: `00:${pad2(minute)}`,
frequencyHours: 1,
};
}
const hourTokens = hourRaw.split(",");
if (hourTokens.length === 0) return null;
const hours = hourTokens.map((token) => Number(token));
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
return null;
}
if (hours.length === 1) {
return {
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
frequencyHours: 24,
};
}
// Verify evenly spaced circular sequence to infer "every N hours".
const deltas: number[] = [];
for (let i = 0; i < hours.length; i += 1) {
const current = hours[i];
const next = i === hours.length - 1 ? hours[0] : hours[i + 1];
const delta = (next - current + 24) % 24;
deltas.push(delta);
}
const expectedDelta = deltas[0];
const uniform = deltas.every((delta) => delta === expectedDelta && delta > 0);
if (!uniform || expectedDelta <= 0 || 24 % expectedDelta !== 0) {
return null;
}
return {
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
frequencyHours: expectedDelta,
};
}

View File

@@ -9,6 +9,13 @@ import { auth } from './lib/auth';
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header'; import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
import { initializeConfigFromEnv } from './lib/env-config-loader'; import { initializeConfigFromEnv } from './lib/env-config-loader';
import { db, users } from './lib/db'; 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 // Flag to track if recovery has been initialized
let recoveryInitialized = false; let recoveryInitialized = false;
@@ -21,6 +28,8 @@ let envConfigInitialized = false;
let envConfigCheckCount = 0; // Track attempts to avoid excessive checking let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
export const onRequest = defineMiddleware(async (context, next) => { export const onRequest = defineMiddleware(async (context, next) => {
const basePath = getBasePath();
// First, try Better Auth session (cookie-based) // First, try Better Auth session (cookie-based)
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -217,5 +226,29 @@ export const onRequest = defineMiddleware(async (context, next) => {
} }
// Continue with the request // 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,
});
}); });

View File

@@ -2,6 +2,7 @@
import '../styles/global.css'; import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { NotFound } from '@/components/NotFound'; import { NotFound } from '@/components/NotFound';
import { withBase } from '@/lib/base-path';
const generator = Astro.generator; const generator = Astro.generator;
--- ---
@@ -10,7 +11,7 @@ const generator = Astro.generator;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={generator} />
<title>Page Not Found - Gitea Mirror</title> <title>Page Not Found - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />
@@ -34,4 +35,4 @@ const generator = Astro.generator;
transform: translateY(-10px); transform: translateY(-10px);
} }
} }
</style> </style>

View File

@@ -3,6 +3,7 @@ import '../styles/global.css';
import App from '@/components/layout/MainLayout'; import App from '@/components/layout/MainLayout';
import { db, mirrorJobs } from '@/lib/db'; import { db, mirrorJobs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Fetch activity data from the database // Fetch activity data from the database
let activityData = []; let activityData = [];
@@ -13,7 +14,6 @@ try {
activityData = jobs.flatMap((job: any) => { activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing // Check if log exists before parsing
if (!job.log) { if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return []; return [];
} }
@@ -54,7 +54,7 @@ const handleRefresh = () => {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={Astro.generator} />
<title>Activity Log - Gitea Mirror</title> <title>Activity Log - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -1,5 +1,6 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { stripBasePath, withBase } from "@/lib/base-path";
export const ALL: APIRoute = async (ctx) => { export const ALL: APIRoute = async (ctx) => {
// If you want to use rate limiting, make sure to set the 'x-forwarded-for' header // 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 { 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) { } catch (error) {
console.error("Auth handler error:", error); console.error("Auth handler error:", error);
@@ -18,7 +23,7 @@ export const ALL: APIRoute = async (ctx) => {
if (url.pathname.includes('/sso/callback')) { if (url.pathname.includes('/sso/callback')) {
// Redirect to error page for SSO errors // Redirect to error page for SSO errors
return Response.redirect( 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" error instanceof Error ? error.message : "SSO authentication failed"
)}`, )}`,
302 302
@@ -34,4 +39,4 @@ export const ALL: APIRoute = async (ctx) => {
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" }
}); });
} }
}; };

View File

@@ -6,6 +6,7 @@ import { db, ssoProviders } from "@/lib/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { normalizeOidcProviderConfig, OidcConfigError } from "@/lib/sso/oidc-config"; 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 // POST /api/auth/sso/register - Register a new SSO provider using Better Auth
export async function POST(context: APIContext) { export async function POST(context: APIContext) {
@@ -87,7 +88,9 @@ export async function POST(context: APIContext) {
registrationBody.samlConfig = { registrationBody.samlConfig = {
entryPoint, entryPoint,
cert, 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, audience: audience || context.url.origin,
wantAssertionsSigned, wantAssertionsSigned,
signatureAlgorithm, signatureAlgorithm,

View File

@@ -0,0 +1,53 @@
import type { APIRoute } from "astro";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import {
createGitHubClient,
getGithubStarredListNames,
} from "@/lib/github";
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const GET: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (!config) {
return jsonResponse({
data: { success: false, message: "No configuration found for this user" },
status: 404,
});
}
if (!config.githubConfig?.token) {
return jsonResponse({
data: { success: false, message: "GitHub token is missing in config" },
status: 400,
});
}
const token = getDecryptedGitHubToken(config);
const githubUsername = config.githubConfig?.owner || undefined;
const octokit = createGitHubClient(token, userId, githubUsername);
const lists = await getGithubStarredListNames({ octokit });
return jsonResponse({
data: {
success: true,
lists,
},
status: 200,
});
} catch (error) {
return createSecureErrorResponse(error, "starred lists fetch", 500);
}
};

View File

@@ -8,7 +8,7 @@ import type {
ScheduleSyncRepoResponse, ScheduleSyncRepoResponse,
} from "@/types/sync"; } from "@/types/sync";
import { createSecureErrorResponse } from "@/lib/utils"; import { createSecureErrorResponse } from "@/lib/utils";
import { parseInterval } from "@/lib/utils/duration-parser"; import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
import { requireAuthenticatedUserId } from "@/lib/auth-guards"; import { requireAuthenticatedUserId } from "@/lib/auth-guards";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
@@ -68,17 +68,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Calculate nextRun and update lastRun and nextRun in the config // Calculate nextRun and update lastRun and nextRun in the config
const currentTime = new Date(); const currentTime = new Date();
let intervalMs = 3600 * 1000; const scheduleSource =
config.scheduleConfig?.interval ||
config.giteaConfig?.mirrorInterval ||
"3600";
const timezone = normalizeTimezone(config.scheduleConfig?.timezone || "UTC");
let nextRun = new Date(currentTime.getTime() + 3600 * 1000);
try { try {
intervalMs = parseInterval( nextRun = getNextScheduledRun(scheduleSource, currentTime, timezone);
typeof config.scheduleConfig?.interval === 'number'
? config.scheduleConfig.interval
: (config.scheduleConfig?.interval as unknown as string) || '3600'
);
} catch { } catch {
intervalMs = 3600 * 1000; nextRun = new Date(currentTime.getTime() + 3600 * 1000);
} }
const nextRun = new Date(currentTime.getTime() + intervalMs);
// Update the full giteaConfig object // Update the full giteaConfig object
await db await db

View File

@@ -1,6 +1,7 @@
--- ---
import Layout from '@/layouts/main.astro'; import Layout from '@/layouts/main.astro';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { withBase } from '@/lib/base-path';
const error = Astro.url.searchParams.get('error'); const error = Astro.url.searchParams.get('error');
const errorDescription = Astro.url.searchParams.get('error_description'); 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"> <div class="mt-6 flex gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => window.location.href = '/login'} onClick={() => window.location.href = withBase('/login')}
> >
Back to Login Back to Login
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => window.location.href = '/'} onClick={() => window.location.href = withBase('/')}
> >
Go Home Go Home
</Button> </Button>
@@ -44,4 +45,4 @@ const errorDescription = Astro.url.searchParams.get('error_description');
</div> </div>
</div> </div>
</div> </div>
</Layout> </Layout>

View File

@@ -7,13 +7,14 @@ import { db, configs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config'; import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config';
import { withBase } from '@/lib/base-path';
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={Astro.generator} />
<title>Configuration - Gitea Mirror</title> <title>Configuration - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Advanced Topics - Gitea Mirror"> <MainLayout title="Advanced Topics - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <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" 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 <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: 'NODE_ENV', desc: 'Application environment', default: 'production' },
{ var: 'PORT', desc: 'Server port', default: '4321' }, { var: 'PORT', desc: 'Server port', default: '4321' },
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' }, { 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_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: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' }, { var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
].map((item, i) => ( ].map((item, i) => (
@@ -225,10 +227,20 @@ import MainLayout from '../../layouts/main.astro';
BETTER_AUTH_URL=https://gitea-mirror.example.com BETTER_AUTH_URL=https://gitea-mirror.example.com
PUBLIC_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> 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> </div>
<ul class="mt-3 space-y-1 text-sm"> <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">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">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li> <li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_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> <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> </ul>
</div> </div>
@@ -243,9 +255,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
image: ghcr.io/raylabshq/gitea-mirror:latest image: ghcr.io/raylabshq/gitea-mirror:latest
environment: environment:
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars - BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- BETTER_AUTH_URL=https://gitea-mirror.example.com - BASE_URL=/mirror
- PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com - BETTER_AUTH_URL=https://git.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com - PUBLIC_BETTER_AUTH_URL=https://git.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# ... other settings ...`}</code></pre> # ... other settings ...`}</code></pre>
</div> </div>
@@ -509,4 +522,4 @@ ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
</section> </section>
</article> </article>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Architecture - Gitea Mirror"> <MainLayout title="Architecture - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <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" 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 <span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Authentication & SSO - Gitea Mirror"> <MainLayout title="Authentication & SSO - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <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" 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 <span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="CA Certificates - Gitea Mirror"> <MainLayout title="CA Certificates - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <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" 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 <span aria-hidden="true">&larr;</span> Back to Documentation

View File

@@ -1,8 +1,10 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
const envVars = [ const envVars = [
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' }, { 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: '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: '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' }, { 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"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <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" 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 <span aria-hidden="true">&larr;</span> Back to Documentation
@@ -509,4 +511,4 @@ curl http://your-server:port/api/health`}</code></pre>
</section> </section>
</article> </article>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -1,6 +1,7 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu'; import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu';
import { withBase } from '@/lib/base-path';
// Define our documentation pages directly // Define our documentation pages directly
const docs = [ const docs = [
@@ -69,7 +70,7 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
return ( return (
<a <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" 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" tabindex="0"
> >
@@ -85,4 +86,4 @@ const sortedDocs = docs.sort((a, b) => a.order - b.order);
})} })}
</div> </div>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -1,12 +1,13 @@
--- ---
import MainLayout from '../../layouts/main.astro'; import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
--- ---
<MainLayout title="Quick Start Guide - Gitea Mirror"> <MainLayout title="Quick Start Guide - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12"> <main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6"> <div class="sticky top-4 z-10 mb-6">
<a <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" 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 <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="space-y-3">
<div class="flex gap-3"> <div class="flex gap-3">
<span class="text-primary">📖</span> <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>
<div class="flex gap-3"> <div class="flex gap-3">
<span class="text-primary">🏗️</span> <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>
<div class="flex gap-3"> <div class="flex gap-3">
<span class="text-primary">📊</span> <span class="text-primary">📊</span>
@@ -434,4 +435,4 @@ bun run start</code></pre>
</section> </section>
</article> </article>
</main> </main>
</MainLayout> </MainLayout>

View File

@@ -4,6 +4,7 @@ import App from '@/components/layout/MainLayout';
import { db, repositories, mirrorJobs, users } from '@/lib/db'; import { db, repositories, mirrorJobs, users } from '@/lib/db';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database // Check if any users exist in the database
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(users); 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 // Redirect to signup if no users exist
if (userCount === 0) { if (userCount === 0) {
return Astro.redirect('/signup'); return Astro.redirect(withBase('/signup'));
} }
// Fetch data from the database // Fetch data from the database
@@ -28,7 +29,6 @@ try {
activityData = jobs.flatMap((job: any) => { activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing // Check if log exists before parsing
if (!job.log) { if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return []; return [];
} }
try { try {
@@ -60,7 +60,7 @@ try {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={Astro.generator} />
<title>Dashboard - Gitea Mirror</title> <title>Dashboard - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />
@@ -68,4 +68,4 @@ try {
<body> <body>
<App page='dashboard' client:load /> <App page='dashboard' client:load />
</body> </body>
</html> </html>

View File

@@ -4,6 +4,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
import { LoginPage } from '@/components/auth/LoginPage'; import { LoginPage } from '@/components/auth/LoginPage';
import { db, users } from '@/lib/db'; import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database // Check if any users exist in the database
const userCountResult = await db const userCountResult = await db
@@ -13,7 +14,7 @@ const userCount = userCountResult[0].count;
// Redirect to signup if no users exist // Redirect to signup if no users exist
if (userCount === 0) { if (userCount === 0) {
return Astro.redirect('/signup'); return Astro.redirect(withBase('/signup'));
} }
const generator = Astro.generator; const generator = Astro.generator;
@@ -23,7 +24,7 @@ const generator = Astro.generator;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={generator} />
<title>Login - Gitea Mirror</title> <title>Login - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -3,11 +3,12 @@ import '@/styles/global.css';
import ConsentPage from '@/components/oauth/ConsentPage'; import ConsentPage from '@/components/oauth/ConsentPage';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import Providers from '@/components/layout/Providers'; import Providers from '@/components/layout/Providers';
import { withBase } from '@/lib/base-path';
// Check if user is authenticated // Check if user is authenticated
const sessionCookie = Astro.cookies.get('better-auth-session'); const sessionCookie = Astro.cookies.get('better-auth-session');
if (!sessionCookie) { if (!sessionCookie) {
return Astro.redirect('/login'); return Astro.redirect(withBase('/login'));
} }
--- ---
@@ -15,7 +16,7 @@ if (!sessionCookie) {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={Astro.generator} />
<title>Authorize Application - Gitea Mirror</title> <title>Authorize Application - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />
@@ -25,4 +26,4 @@ if (!sessionCookie) {
<ConsentPage client:load /> <ConsentPage client:load />
</Providers> </Providers>
</body> </body>
</html> </html>

View File

@@ -2,6 +2,7 @@
import '../styles/global.css'; import '../styles/global.css';
import App from '@/components/layout/MainLayout'; import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro'; 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> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={Astro.generator} />
<title>Organizations - Gitea Mirror</title> <title>Organizations - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -2,13 +2,14 @@
import '../styles/global.css'; import '../styles/global.css';
import App from '@/components/layout/MainLayout'; import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro'; import ThemeScript from '@/components/theme/ThemeScript.astro';
import { withBase } from '@/lib/base-path';
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={Astro.generator} />
<title>Repositories - Gitea Mirror</title> <title>Repositories - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -4,6 +4,7 @@ import ThemeScript from '@/components/theme/ThemeScript.astro';
import { SignupPage } from '@/components/auth/SignupPage'; import { SignupPage } from '@/components/auth/SignupPage';
import { db, users } from '@/lib/db'; import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { withBase } from '@/lib/base-path';
// Check if any users exist in the database // Check if any users exist in the database
const userCountResult = await db const userCountResult = await db
@@ -13,7 +14,7 @@ const userCount = userCountResult[0]?.count;
// Redirect to login if users already exist // Redirect to login if users already exist
if (userCount !== null && Number(userCount) > 0) { if (userCount !== null && Number(userCount) > 0) {
return Astro.redirect('/login'); return Astro.redirect(withBase('/login'));
} }
const generator = Astro.generator; const generator = Astro.generator;
@@ -23,7 +24,7 @@ const generator = Astro.generator;
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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} /> <meta name="generator" content={generator} />
<title>Setup Admin Account - Gitea Mirror</title> <title>Setup Admin Account - Gitea Mirror</title>
<ThemeScript /> <ThemeScript />

View File

@@ -4,6 +4,7 @@ export type GiteaOrgVisibility = "public" | "private" | "limited";
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed"; export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
export type StarredReposMode = "dedicated-org" | "preserve-owner"; export type StarredReposMode = "dedicated-org" | "preserve-owner";
export type BackupStrategy = "disabled" | "always" | "on-force-push" | "block-on-force-push"; export type BackupStrategy = "disabled" | "always" | "on-force-push" | "block-on-force-push";
export type ScheduleMode = "interval" | "clock";
export interface GiteaConfig { export interface GiteaConfig {
url: string; url: string;
@@ -29,7 +30,12 @@ export interface GiteaConfig {
export interface ScheduleConfig { export interface ScheduleConfig {
enabled: boolean; enabled: boolean;
interval: number; interval: number | string;
intervalExpression?: string;
scheduleMode?: ScheduleMode;
clockFrequencyHours?: number;
startTime?: string;
timezone?: string;
lastRun?: Date; lastRun?: Date;
nextRun?: Date; nextRun?: Date;
} }
@@ -55,6 +61,7 @@ export interface GitHubConfig {
token: string; token: string;
privateRepositories: boolean; privateRepositories: boolean;
mirrorStarred: boolean; mirrorStarred: boolean;
starredLists?: string[];
starredDuplicateStrategy?: DuplicateNameStrategy; starredDuplicateStrategy?: DuplicateNameStrategy;
starredReposMode?: StarredReposMode; starredReposMode?: StarredReposMode;
} }

86
www/pnpm-lock.yaml generated
View File

@@ -25,7 +25,7 @@ importers:
version: 1.12.69 version: 1.12.69
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.1 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': '@types/canvas-confetti':
specifier: ^1.9.0 specifier: ^1.9.0
version: 1.9.0 version: 1.9.0
@@ -1053,8 +1053,8 @@ packages:
decode-named-character-reference@1.3.0: decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
defu@6.1.4: defu@6.1.7:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
dequal@2.0.3: dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
@@ -1199,8 +1199,8 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
h3@1.15.5: h3@1.15.9:
resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} resolution: {integrity: sha512-H7UPnyIupUOYUQu7f2x7ABVeMyF/IbJjqn20WSXpMdnQB260luADUkSgJU7QTWLutq8h3tUayMQ1DdbSYX5LkA==}
hast-util-from-html@2.0.3: hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@@ -1655,12 +1655,12 @@ packages:
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1: picomatch@2.3.2:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
picomatch@4.0.3: picomatch@4.0.4:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'} engines: {node: '>=12'}
postcss@8.5.6: postcss@8.5.6:
@@ -1801,8 +1801,8 @@ packages:
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
smol-toml@1.6.0: smol-toml@1.6.1:
resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
source-map-js@1.2.1: source-map-js@1.2.1:
@@ -2010,8 +2010,8 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@7.3.1: vite@7.3.2:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -2091,7 +2091,7 @@ snapshots:
'@astrojs/internal-helpers@0.8.0': '@astrojs/internal-helpers@0.8.0':
dependencies: dependencies:
picomatch: 4.0.3 picomatch: 4.0.4
'@astrojs/markdown-remark@7.0.0': '@astrojs/markdown-remark@7.0.0':
dependencies: dependencies:
@@ -2109,7 +2109,7 @@ snapshots:
remark-rehype: 11.1.2 remark-rehype: 11.1.2
remark-smartypants: 3.0.2 remark-smartypants: 3.0.2
shiki: 4.0.2 shiki: 4.0.2
smol-toml: 1.6.0 smol-toml: 1.6.1
unified: 11.0.5 unified: 11.0.5
unist-util-remove-position: 5.0.0 unist-util-remove-position: 5.0.0
unist-util-visit: 5.1.0 unist-util-visit: 5.1.0
@@ -2146,12 +2146,12 @@ snapshots:
'@astrojs/internal-helpers': 0.8.0 '@astrojs/internal-helpers': 0.8.0
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@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 devalue: 5.6.4
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
ultrahtml: 1.6.0 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: transitivePeerDependencies:
- '@types/node' - '@types/node'
- jiti - jiti
@@ -2553,7 +2553,7 @@ snapshots:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
estree-walker: 2.0.2 estree-walker: 2.0.2
picomatch: 4.0.3 picomatch: 4.0.4
optionalDependencies: optionalDependencies:
rollup: 4.59.0 rollup: 4.59.0
@@ -2748,12 +2748,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
'@tailwindcss/oxide-win32-x64-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: dependencies:
'@tailwindcss/node': 4.2.1 '@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1 '@tailwindcss/oxide': 4.2.1
tailwindcss: 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': '@types/babel__core@7.20.5':
dependencies: dependencies:
@@ -2823,7 +2823,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@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: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@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 '@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.18.0 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: transitivePeerDependencies:
- supports-color - supports-color
@@ -2844,7 +2844,7 @@ snapshots:
anymatch@3.1.3: anymatch@3.1.3:
dependencies: dependencies:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.2
argparse@2.0.1: {} argparse@2.0.1: {}
@@ -2891,11 +2891,11 @@ snapshots:
p-queue: 9.1.0 p-queue: 9.1.0
package-manager-detector: 1.6.0 package-manager-detector: 1.6.0
piccolore: 0.1.3 piccolore: 0.1.3
picomatch: 4.0.3 picomatch: 4.0.4
rehype: 13.0.2 rehype: 13.0.2
semver: 7.7.4 semver: 7.7.4
shiki: 4.0.2 shiki: 4.0.2
smol-toml: 1.6.0 smol-toml: 1.6.1
svgo: 4.0.1 svgo: 4.0.1
tinyclip: 0.1.12 tinyclip: 0.1.12
tinyexec: 1.0.2 tinyexec: 1.0.2
@@ -2906,8 +2906,8 @@ snapshots:
unist-util-visit: 5.1.0 unist-util-visit: 5.1.0
unstorage: 1.17.4 unstorage: 1.17.4
vfile: 6.0.3 vfile: 6.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)
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))
xxhash-wasm: 1.1.0 xxhash-wasm: 1.1.0
yargs-parser: 22.0.0 yargs-parser: 22.0.0
zod: 4.3.6 zod: 4.3.6
@@ -3044,7 +3044,7 @@ snapshots:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
defu@6.1.4: {} defu@6.1.7: {}
dequal@2.0.3: {} dequal@2.0.3: {}
@@ -3181,9 +3181,9 @@ snapshots:
extend@3.0.2: {} extend@3.0.2: {}
fdir@6.5.0(picomatch@4.0.3): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.4
flattie@1.1.1: {} flattie@1.1.1: {}
@@ -3204,11 +3204,11 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
h3@1.15.5: h3@1.15.9:
dependencies: dependencies:
cookie-es: 1.2.2 cookie-es: 1.2.2
crossws: 0.3.5 crossws: 0.3.5
defu: 6.1.4 defu: 6.1.7
destr: 2.0.5 destr: 2.0.5
iron-webcrypto: 1.2.1 iron-webcrypto: 1.2.1
node-mock-http: 1.0.4 node-mock-http: 1.0.4
@@ -3987,9 +3987,9 @@ snapshots:
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.2: {}
picomatch@4.0.3: {} picomatch@4.0.4: {}
postcss@8.5.6: postcss@8.5.6:
dependencies: dependencies:
@@ -4247,7 +4247,7 @@ snapshots:
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
smol-toml@1.6.0: {} smol-toml@1.6.1: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
@@ -4294,8 +4294,8 @@ snapshots:
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.3 picomatch: 4.0.4
trim-lines@3.0.1: {} trim-lines@3.0.1: {}
@@ -4389,7 +4389,7 @@ snapshots:
anymatch: 3.1.3 anymatch: 3.1.3
chokidar: 5.0.0 chokidar: 5.0.0
destr: 2.0.5 destr: 2.0.5
h3: 1.15.5 h3: 1.15.9
lru-cache: 11.2.6 lru-cache: 11.2.6
node-fetch-native: 1.6.7 node-fetch-native: 1.6.7
ofetch: 1.5.1 ofetch: 1.5.1
@@ -4416,11 +4416,11 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.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: dependencies:
esbuild: 0.27.3 esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.3 picomatch: 4.0.4
postcss: 8.5.6 postcss: 8.5.6
rollup: 4.59.0 rollup: 4.59.0
tinyglobby: 0.2.15 tinyglobby: 0.2.15
@@ -4430,9 +4430,9 @@ snapshots:
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.31.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: 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: {} web-namespaces@2.0.1: {}