mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-04-10 13:08:13 +03:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fac30fc02 | ||
|
|
c3b1f933b1 | ||
|
|
a839915e3e | ||
|
|
5d95f4cd39 | ||
|
|
01a3b08dac | ||
|
|
c87513b648 | ||
|
|
4f3cbc866e | ||
|
|
60548f2062 | ||
|
|
74dab43e89 | ||
|
|
01a8025140 | ||
|
|
8346748f5a | ||
|
|
38002019ea | ||
|
|
32eb27c8a6 | ||
|
|
d33b4ff64f | ||
|
|
6f2e0cbca0 | ||
|
|
95e6eb7602 | ||
|
|
f50f49fc41 | ||
|
|
5ea2abff85 | ||
|
|
9d131b9a09 | ||
|
|
5f77fceaca |
@@ -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
|
||||||
|
|
||||||
|
|||||||
7
.github/workflows/nix-build.yml
vendored
7
.github/workflows/nix-build.yml
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -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"]
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -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
804
design/giteamirror.pen
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
BIN
docs/images/issue-240-automation-ui-v2.png
Normal file
BIN
docs/images/issue-240-automation-ui-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/images/issue-240-automation-ui.png
Normal file
BIN
docs/images/issue-240-automation-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/images/starred-lists-ui.png
Normal file
BIN
docs/images/starred-lists-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -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 = ./.;
|
||||||
|
|
||||||
|
|||||||
@@ -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
76
scripts/runtime-server.ts
Normal 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})`);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
86
src/lib/base-path.test.ts
Normal 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
83
src/lib/base-path.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
319
src/lib/github-star-lists.test.ts
Normal file
319
src/lib/github-star-lists.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
36
src/lib/utils/config-mapper.test.ts
Normal file
36
src/lib/utils/config-mapper.test.ts
Normal 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");
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
63
src/lib/utils/mirror-source-auth.test.ts
Normal file
63
src/lib/utils/mirror-source-auth.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/lib/utils/mirror-source-auth.ts
Normal file
46
src/lib/utils/mirror-source-auth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/lib/utils/schedule-utils.test.ts
Normal file
65
src/lib/utils/schedule-utils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
buildClockCronExpression,
|
||||||
|
getNextCronOccurrence,
|
||||||
|
getNextScheduledRun,
|
||||||
|
isCronExpression,
|
||||||
|
normalizeTimezone,
|
||||||
|
parseClockCronExpression,
|
||||||
|
} from "./schedule-utils";
|
||||||
|
|
||||||
|
test("isCronExpression - detects 5-part cron expressions", () => {
|
||||||
|
expect(isCronExpression("0 22 * * *")).toBe(true);
|
||||||
|
expect(isCronExpression("8h")).toBe(false);
|
||||||
|
expect(isCronExpression(3600)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildClockCronExpression - creates daily and hourly expressions", () => {
|
||||||
|
expect(buildClockCronExpression("22:00", 24)).toBe("0 22 * * *");
|
||||||
|
expect(buildClockCronExpression("22:15", 8)).toBe("15 22,6,14 * * *");
|
||||||
|
expect(buildClockCronExpression("10:30", 1)).toBe("30 * * * *");
|
||||||
|
expect(buildClockCronExpression("10:30", 7)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseClockCronExpression - parses generated expressions", () => {
|
||||||
|
expect(parseClockCronExpression("0 22 * * *")).toEqual({
|
||||||
|
startTime: "22:00",
|
||||||
|
frequencyHours: 24,
|
||||||
|
});
|
||||||
|
expect(parseClockCronExpression("15 22,6,14 * * *")).toEqual({
|
||||||
|
startTime: "22:15",
|
||||||
|
frequencyHours: 8,
|
||||||
|
});
|
||||||
|
expect(parseClockCronExpression("30 * * * *")).toEqual({
|
||||||
|
startTime: "00:30",
|
||||||
|
frequencyHours: 1,
|
||||||
|
});
|
||||||
|
expect(parseClockCronExpression("0 3 * * 1-5")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getNextCronOccurrence - computes next run in UTC", () => {
|
||||||
|
const from = new Date("2026-03-18T15:20:00.000Z");
|
||||||
|
const next = getNextCronOccurrence("0 22 * * *", from, "UTC");
|
||||||
|
expect(next.toISOString()).toBe("2026-03-18T22:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getNextCronOccurrence - respects timezone", () => {
|
||||||
|
const from = new Date("2026-03-18T15:20:00.000Z");
|
||||||
|
// 22:00 IST equals 16:30 UTC
|
||||||
|
const next = getNextCronOccurrence("0 22 * * *", from, "Asia/Kolkata");
|
||||||
|
expect(next.toISOString()).toBe("2026-03-18T16:30:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getNextScheduledRun - handles interval and cron schedules", () => {
|
||||||
|
const from = new Date("2026-03-18T00:00:00.000Z");
|
||||||
|
const intervalNext = getNextScheduledRun("8h", from, "UTC");
|
||||||
|
expect(intervalNext.toISOString()).toBe("2026-03-18T08:00:00.000Z");
|
||||||
|
|
||||||
|
const cronNext = getNextScheduledRun("0 */6 * * *", from, "UTC");
|
||||||
|
expect(cronNext.toISOString()).toBe("2026-03-18T06:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeTimezone - falls back to UTC for invalid values", () => {
|
||||||
|
expect(normalizeTimezone("Invalid/Zone")).toBe("UTC");
|
||||||
|
expect(normalizeTimezone("Asia/Kolkata")).toBe("Asia/Kolkata");
|
||||||
|
});
|
||||||
420
src/lib/utils/schedule-utils.ts
Normal file
420
src/lib/utils/schedule-utils.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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" }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
53
src/pages/api/github/starred-lists.ts
Normal file
53
src/pages/api/github/starred-lists.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">←</span> Back to Documentation
|
<span aria-hidden="true">←</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>
|
||||||
|
|||||||
@@ -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">←</span> Back to Documentation
|
<span aria-hidden="true">←</span> Back to Documentation
|
||||||
|
|||||||
@@ -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">←</span> Back to Documentation
|
<span aria-hidden="true">←</span> Back to Documentation
|
||||||
|
|||||||
@@ -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">←</span> Back to Documentation
|
<span aria-hidden="true">←</span> Back to Documentation
|
||||||
|
|||||||
@@ -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">←</span> Back to Documentation
|
<span aria-hidden="true">←</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">←</span> Back to Documentation
|
<span aria-hidden="true">←</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
86
www/pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user