mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-11-03 07:42:19 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91de0d1030 | ||
|
|
85bd1f4042 | ||
|
|
4a28015685 | ||
|
|
da23941369 | ||
|
|
906ce57e8c | ||
|
|
1b84c75a97 | ||
|
|
0b6b6b76bf | ||
|
|
7610a614da | ||
|
|
c28dcc209f | ||
|
|
40ee3cbc44 | ||
|
|
699a5771f5 | ||
|
|
e862714d6a | ||
|
|
716981aa04 | ||
|
|
66e3284898 | ||
|
|
695f0ff005 | ||
|
|
53f2cf36fc | ||
|
|
8ffcf3bdc6 | ||
|
|
a07af96f84 | ||
|
|
b1daa65228 | ||
|
|
dd1c42264e | ||
|
|
384fbbbe10 | ||
|
|
1a54010950 | ||
|
|
9bf22791a9 | ||
|
|
5d82f22b12 | ||
|
|
6979c3bb32 | ||
|
|
b9f14e55e2 | ||
|
|
2582988f94 | ||
|
|
4ea62a9f3d | ||
|
|
1f60b2cf39 | ||
|
|
a02865a1aa | ||
|
|
ad549dad9b | ||
|
|
20103220d9 | ||
|
|
fe2c825244 | ||
|
|
4b858a0251 | ||
|
|
585d2ceb84 | ||
|
|
7c1f24dc2f | ||
|
|
680b374c84 | ||
|
|
088467a57d | ||
|
|
adb436444e | ||
|
|
cc635485f0 | ||
|
|
6f343de5fd | ||
|
|
a18f262ca7 | ||
|
|
3798456f5d | ||
|
|
73f1609117 | ||
|
|
588567931a | ||
|
|
5c1317c759 | ||
|
|
5f1c37b320 | ||
|
|
083b342f38 | ||
|
|
92bb38b122 | ||
|
|
e7ac54a72a | ||
|
|
2ea250f081 | ||
|
|
c1712bc670 | ||
|
|
c4550196e9 | ||
|
|
8cb8fd6fe1 | ||
|
|
4b4ea9614b | ||
|
|
1644505043 | ||
|
|
e142524bfc | ||
|
|
8fac30fc02 | ||
|
|
c3b1f933b1 | ||
|
|
a839915e3e | ||
|
|
5d95f4cd39 | ||
|
|
01a3b08dac | ||
|
|
c87513b648 | ||
|
|
4f3cbc866e | ||
|
|
60548f2062 | ||
|
|
74dab43e89 | ||
|
|
01a8025140 | ||
|
|
8346748f5a | ||
|
|
38002019ea | ||
|
|
32eb27c8a6 | ||
|
|
d33b4ff64f | ||
|
|
6f2e0cbca0 | ||
|
|
95e6eb7602 | ||
|
|
f50f49fc41 | ||
|
|
5ea2abff85 | ||
|
|
9d131b9a09 | ||
|
|
5f77fceaca | ||
|
|
5d2462e5a0 | ||
|
|
0000a03ad6 | ||
|
|
d697cb2bc9 | ||
|
|
ddd071f7e5 | ||
|
|
4629ab4335 | ||
|
|
0f303c4b79 | ||
|
|
7c7c259d0a | ||
|
|
fe6bcc5288 | ||
|
|
e26ed3aa9c | ||
|
|
efb96b6e60 | ||
|
|
342cafed0e | ||
|
|
fc7c6b59d7 |
No files matched your search
+45
-2
@@ -9,6 +9,8 @@
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4321
|
||||
# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
|
||||
BASE_URL=/
|
||||
|
||||
# Database Configuration
|
||||
# For self-hosted, SQLite is used by default
|
||||
@@ -18,8 +20,42 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
# Better Auth log verbosity: debug | info | warn | error (default: warn).
|
||||
# Set to "debug" to trace SSO/OIDC sign-in and callback issues.
|
||||
# BETTER_AUTH_LOG_LEVEL=debug
|
||||
|
||||
# ===========================================
|
||||
# REVERSE PROXY CONFIGURATION
|
||||
# ===========================================
|
||||
# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.).
|
||||
# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank.
|
||||
#
|
||||
# Set all three to your external URL, e.g.:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
#
|
||||
# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set:
|
||||
# BASE_URL=/mirror
|
||||
# BETTER_AUTH_URL=https://git.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
#
|
||||
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
|
||||
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
|
||||
# (e.g. https://gitea-mirror.example.com,https://alt.example.com)
|
||||
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
|
||||
# ===========================================
|
||||
# HTTPS / TLS (Optional)
|
||||
# ===========================================
|
||||
# Set BOTH to have the server terminate TLS directly (no reverse proxy needed).
|
||||
# Leave unset when TLS is handled upstream by Nginx/Traefik/Caddy.
|
||||
# SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
|
||||
# SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
|
||||
|
||||
# ===========================================
|
||||
# DOCKER CONFIGURATION (Optional)
|
||||
@@ -27,7 +63,7 @@ BETTER_AUTH_URL=http://localhost:4321
|
||||
|
||||
# Docker Registry Configuration
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror:
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror
|
||||
DOCKER_TAG=latest
|
||||
|
||||
# ===========================================
|
||||
@@ -40,12 +76,19 @@ DOCKER_TAG=latest
|
||||
# GITHUB_TOKEN=your-github-personal-access-token
|
||||
# GITHUB_TYPE=personal # Options: personal, organization
|
||||
|
||||
# GitHub Enterprise (GHES / GHEC with data residency)
|
||||
# Leave unset for standard github.com. Examples:
|
||||
# GHES (self-hosted): https://ghe.example.com/api/v3
|
||||
# GHEC data residency: https://api.TENANT.ghe.com
|
||||
# GH_API_URL=https://ghe.example.com/api/v3
|
||||
|
||||
# Repository Selection
|
||||
# PRIVATE_REPOSITORIES=false
|
||||
# PUBLIC_REPOSITORIES=true
|
||||
# INCLUDE_ARCHIVED=false
|
||||
# SKIP_FORKS=false
|
||||
# MIRROR_STARRED=false
|
||||
# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos
|
||||
# STARRED_REPOS_ORG=starred # Organization name for starred repos
|
||||
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: '1.3.10'
|
||||
bun-version: '1.3.13'
|
||||
|
||||
- name: Check lockfile and install dependencies
|
||||
run: |
|
||||
@@ -48,6 +48,12 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: bun test --coverage
|
||||
|
||||
- name: Check Drizzle migrations
|
||||
run: bun run db:check
|
||||
|
||||
- name: Validate migrations (SQLite lint + upgrade path)
|
||||
run: bun test:migrations
|
||||
|
||||
- name: Build Astro project
|
||||
run: bunx --bun astro build
|
||||
|
||||
@@ -51,13 +51,13 @@ jobs:
|
||||
ref: ${{ env.SHA }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log into registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
|
||||
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
|
||||
- name: Log into Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
# Extract metadata for Docker
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
|
||||
labels: |
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
# Build and push Docker image
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
# Load image locally for security scanning (PRs only)
|
||||
- name: Load image for scanning
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
|
||||
# Docker Scout comprehensive security analysis
|
||||
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
||||
uses: docker/scout-action@v1
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
command: cves,recommendations
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
|
||||
# Docker Scout for Pull Requests (using local image)
|
||||
- name: Docker Scout - Vulnerability Analysis (PR)
|
||||
uses: docker/scout-action@v1
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: cves,recommendations
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
|
||||
# Compare to latest for PRs and pushes
|
||||
- name: Docker Scout - Compare to Latest
|
||||
uses: docker/scout-action@v1
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: compare
|
||||
|
||||
@@ -40,7 +40,7 @@ env:
|
||||
FAKE_GITHUB_PORT: 4580
|
||||
GIT_SERVER_PORT: 4590
|
||||
APP_PORT: 4321
|
||||
BUN_VERSION: "1.3.10"
|
||||
BUN_VERSION: "1.3.13"
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
@@ -88,6 +88,24 @@ jobs:
|
||||
|
||||
- name: Start Gitea and git-server containers
|
||||
run: |
|
||||
# Retry pulls before `up` — Docker Hub occasionally returns
|
||||
# "context deadline exceeded" on the first attempt. Pulling
|
||||
# separately also keeps the failure mode obvious if it persists.
|
||||
echo "Pulling images..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker compose -f tests/e2e/docker-compose.e2e.yml pull --quiet; then
|
||||
echo "✓ Images pulled (attempt $attempt)"
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: docker compose pull failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_s=$((attempt * 10))
|
||||
echo "Pull attempt $attempt failed, retrying in ${sleep_s}s..."
|
||||
sleep "$sleep_s"
|
||||
done
|
||||
|
||||
echo "Starting containers via docker compose..."
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml up -d
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -16,6 +18,8 @@ on:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'bun.nix'
|
||||
- 'bun.lock'
|
||||
- 'package.json'
|
||||
- '.github/workflows/nix-build.yml'
|
||||
|
||||
permissions:
|
||||
@@ -34,10 +38,13 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
|
||||
- name: Setup Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
|
||||
|
||||
- name: Regenerate bun.nix from bun.lock
|
||||
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
|
||||
|
||||
- name: Check flake
|
||||
run: nix flake check --accept-flake-config
|
||||
|
||||
@@ -52,3 +52,4 @@ tests/e2e/git-repos/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
.playwright-mcp/
|
||||
+15
-8
@@ -1,8 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.3.10-debian AS base
|
||||
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -16,6 +16,7 @@ COPY . .
|
||||
RUN bun run build
|
||||
RUN mkdir -p dist/scripts && \
|
||||
for script in scripts/*.ts; do \
|
||||
if [ "$(basename "$script")" = "runtime-server.ts" ]; then continue; fi; \
|
||||
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
|
||||
done
|
||||
|
||||
@@ -28,25 +29,29 @@ RUN bun install --production --omit=peer --frozen-lockfile
|
||||
# ----------------------------
|
||||
# Build git-lfs from source with patched Go to resolve Go stdlib CVEs
|
||||
FROM debian:trixie-slim AS git-lfs-builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates git make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ARG GO_VERSION=1.25.8
|
||||
ARG GO_VERSION=1.25.9
|
||||
ARG GIT_LFS_VERSION=3.7.1
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||
&& rm /tmp/go.tar.gz
|
||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
|
||||
# Force using our installed Go (not the version in go.mod toolchain directive)
|
||||
ENV GOTOOLCHAIN=local
|
||||
RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lfs/git-lfs.git /tmp/git-lfs \
|
||||
&& cd /tmp/git-lfs \
|
||||
&& go get golang.org/x/crypto@latest \
|
||||
&& go mod tidy \
|
||||
&& make \
|
||||
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
|
||||
|
||||
# ----------------------------
|
||||
FROM oven/bun:1.3.10-debian AS runner
|
||||
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS runner
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
git wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=git-lfs-builder /usr/local/bin/git-lfs /usr/local/bin/git-lfs
|
||||
@@ -55,6 +60,7 @@ COPY --from=pruner /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
COPY --from=builder /app/scripts/runtime-server.ts ./scripts/runtime-server.ts
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
|
||||
# Remove build-only packages that are not needed at runtime
|
||||
@@ -69,6 +75,7 @@ ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV DATABASE_URL=file:data/gitea-mirror.db
|
||||
ENV BASE_URL=/
|
||||
|
||||
# Create directories and setup permissions
|
||||
RUN mkdir -p /app/certs && \
|
||||
@@ -86,6 +93,6 @@ VOLUME /app/data
|
||||
EXPOSE 4321
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
|
||||
CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
|
||||
<h1>Gitea Mirror</h1>
|
||||
<p><i>Automatically mirror repositories from GitHub to your self-hosted Gitea instance.</i></p>
|
||||
<p><i>Automatically mirror repositories from GitHub to your self-hosted Gitea/Forgejo instance.</i></p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/releases/latest"><img src="https://img.shields.io/github/v/tag/RayLabsHQ/gitea-mirror?label=release" alt="release"/></a>
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/actions/workflows/astro-build-test.yml"><img src="https://img.shields.io/github/actions/workflow/status/RayLabsHQ/gitea-mirror/astro-build-test.yml?branch=main" alt="build"/></a>
|
||||
@@ -19,7 +19,7 @@ docker compose -f docker-compose.alt.yml up -d
|
||||
# Access at http://localhost:4321
|
||||
```
|
||||
|
||||
First user signup becomes admin. Configure GitHub and Gitea through the web interface!
|
||||
First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the web interface!
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/dashboard.png" alt="Dashboard" width="600" />
|
||||
@@ -28,7 +28,8 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🔁 Mirror public, private, and starred GitHub repos to Gitea
|
||||
- 🔁 Mirror public, private, and starred GitHub repos to Gitea/Forgejo
|
||||
- 🏛️ **GitHub Enterprise support** - Works with GHES and GHEC with data residency via `GH_API_URL`
|
||||
- 🏢 Mirror entire organizations with flexible strategies
|
||||
- 🎯 Custom destination control for repos and organizations
|
||||
- 📦 **Git LFS support** - Mirror large files with Git LFS
|
||||
@@ -199,12 +200,12 @@ bun run dev
|
||||
1. **First Time Setup**
|
||||
- Navigate to http://localhost:4321
|
||||
- Create admin account (first user signup)
|
||||
- Configure GitHub and Gitea connections
|
||||
- Configure GitHub and Gitea/Forgejo connections
|
||||
|
||||
2. **Mirror Strategies**
|
||||
- **Preserve Structure**: Maintains GitHub organization structure
|
||||
- **Single Organization**: All repos go to one Gitea organization
|
||||
- **Flat User**: All repos under your Gitea user account
|
||||
- **Single Organization**: All repos go to one Gitea/Forgejo organization
|
||||
- **Flat User**: All repos under your Gitea/Forgejo user account
|
||||
- **Mixed Mode**: Personal repos in one org, organization repos preserve structure
|
||||
|
||||
3. **Customization**
|
||||
@@ -217,13 +218,13 @@ bun run dev
|
||||
### Git LFS (Large File Storage)
|
||||
Mirror Git LFS objects along with your repositories:
|
||||
- Enable "Mirror LFS" option in Settings → Mirror Options
|
||||
- Requires Gitea server with LFS enabled (`LFS_START_SERVER = true`)
|
||||
- Requires Gitea/Forgejo server with LFS enabled (`LFS_START_SERVER = true`)
|
||||
- Requires Git v2.1.2+ on the server
|
||||
|
||||
### Metadata Mirroring
|
||||
Transfer complete repository metadata from GitHub to Gitea:
|
||||
Transfer complete repository metadata from GitHub to Gitea/Forgejo:
|
||||
- **Issues** - Mirror all issues with comments and labels
|
||||
- **Pull Requests** - Transfer PR discussions to Gitea
|
||||
- **Pull Requests** - Transfer PR discussions to Gitea/Forgejo
|
||||
- **Labels** - Preserve repository labels
|
||||
- **Milestones** - Keep project milestones
|
||||
- **Wiki** - Mirror wiki content
|
||||
@@ -243,7 +244,7 @@ Gitea Mirror provides powerful automatic synchronization features:
|
||||
#### Features (v3.4.0+)
|
||||
- **Auto-discovery**: Automatically discovers and imports new GitHub repositories
|
||||
- **Repository cleanup**: Removes repositories that no longer exist in GitHub
|
||||
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea's default 24h)
|
||||
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea/Forgejo's default 24h)
|
||||
- **Smart scheduling**: Only syncs repositories that need updating
|
||||
- **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set - no manual clicks required!
|
||||
|
||||
@@ -254,7 +255,7 @@ Navigate to the Configuration page and enable "Automatic Syncing" with your pref
|
||||
|
||||
**🚀 Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
|
||||
1. **Import** all your GitHub repositories on startup (no manual import needed!)
|
||||
2. **Mirror** them to Gitea immediately
|
||||
2. **Mirror** them to Gitea/Forgejo immediately
|
||||
3. **Keep them synchronized** based on your interval
|
||||
4. **Auto-discover** new repos you create/star on GitHub
|
||||
5. **Clean up** repos you delete from GitHub
|
||||
@@ -284,23 +285,52 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
|
||||
- **Auto-Start**: When `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set, the service automatically imports all GitHub repositories and mirrors them on startup. No manual "Import" or "Mirror" button clicks required!
|
||||
- The scheduler checks every minute for tasks to run. The `GITEA_MIRROR_INTERVAL` determines how often each repository is actually synced. For example, with `8h`, each repo syncs every 8 hours from its last successful sync.
|
||||
- **Large repo bootstrap**: For first-time mirroring of large repositories (especially with metadata/LFS), avoid very short intervals (for example `5m`). Start with a longer interval (`1h` to `8h`) or temporarily disable scheduling during the initial import/mirror run, then enable your regular interval after the first pass completes.
|
||||
- **Why this matters**: If your Gitea instance takes a long time to complete migrations/imports, aggressive schedules can cause repeated retries and duplicate-looking mirror attempts.
|
||||
- **Why this matters**: If your Gitea/Forgejo instance takes a long time to complete migrations/imports, aggressive schedules can cause repeated retries and duplicate-looking mirror attempts.
|
||||
|
||||
**🛡️ Backup Protection Features**:
|
||||
- **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors)
|
||||
- **Archive Never Deletes Data**: The `archive` action preserves all repository data:
|
||||
- Regular repositories: Made read-only using Gitea's archive feature
|
||||
- Mirror repositories: Renamed with `archived-` prefix (Gitea API limitation prevents archiving mirrors)
|
||||
- Regular repositories: Made read-only using Gitea/Forgejo's archive feature
|
||||
- Mirror repositories: Renamed with `archived-` prefix (Gitea/Forgejo API limitation prevents archiving mirrors)
|
||||
- Failed operations: Repository remains fully accessible even if marking as archived fails
|
||||
- **Manual Sync on Demand**: Archived mirrors stay in Gitea with automatic syncs disabled; trigger `Manual Sync` from the Repositories page whenever you need fresh data.
|
||||
- **The Whole Point of Backups**: Your Gitea mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||
- **Manual Sync on Demand**: Archived mirrors stay in Gitea/Forgejo with automatic syncs disabled; trigger `Manual Sync` from the Repositories page whenever you need fresh data.
|
||||
- **The Whole Point of Backups**: Your Gitea/Forgejo mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
|
||||
|
||||
### GitHub Enterprise (GHES / GHEC with Data Residency)
|
||||
|
||||
Gitea Mirror works with non-`github.com` GitHub deployments. Point the client at your Enterprise API via the `GH_API_URL` environment variable:
|
||||
|
||||
```bash
|
||||
# GitHub Enterprise Server (self-hosted)
|
||||
GH_API_URL=https://ghe.example.com/api/v3
|
||||
|
||||
# GitHub Enterprise Cloud with data residency
|
||||
GH_API_URL=https://api.TENANT.ghe.com
|
||||
```
|
||||
|
||||
Standard GitHub Enterprise Cloud on `github.com` needs no override. Use a token issued by the target Enterprise instance for `GITHUB_TOKEN`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Reverse Proxy Configuration
|
||||
|
||||
If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
|
||||
If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure:
|
||||
|
||||
```bash
|
||||
# BASE_URL handles the path prefix — auth URLs stay as origin only
|
||||
BASE_URL=/mirror
|
||||
BETTER_AUTH_URL=https://git.example.com
|
||||
PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `BASE_URL` sets the application path prefix.
|
||||
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` should be **origin only** (e.g. `https://git.example.com`). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
|
||||
- `BETTER_AUTH_TRUSTED_ORIGINS` should also contain origins only (no path).
|
||||
- `BASE_URL` is runtime configuration, so prebuilt registry images can be reused across different subpaths.
|
||||
|
||||
### Mirror Token Rotation (GitHub Token Changed)
|
||||
|
||||
@@ -309,7 +339,7 @@ For existing pull-mirror repositories, changing the GitHub token in Gitea Mirror
|
||||
If sync logs show authentication failures (for example `terminal prompts disabled`), do one of the following:
|
||||
|
||||
1. In Gitea/Forgejo, open repository **Settings → Mirror Settings** and update the mirror authorization password/token.
|
||||
2. Or delete and re-mirror the repository from Gitea Mirror so it is recreated with current credentials.
|
||||
2. Or delete and re-mirror the repository so it is recreated with current credentials.
|
||||
|
||||
### Re-sync Metadata After Changing Mirror Options
|
||||
|
||||
@@ -334,7 +364,7 @@ If your Gitea/Forgejo server has `mirror.MIN_INTERVAL` set to a higher value (fo
|
||||
To avoid this:
|
||||
|
||||
1. Set Gitea Mirror interval to a value greater than or equal to your server `MIN_INTERVAL`.
|
||||
2. Do not rely on manual per-repository mirror interval edits in Gitea/Forgejo, because Gitea Mirror will overwrite them on sync.
|
||||
2. Do not rely on manual per-repository mirror interval edits in Gitea/Forgejo, as they will be overwritten on sync.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -356,13 +386,13 @@ bun run build
|
||||
|
||||
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
|
||||
- **Backend**: Bun runtime, SQLite, Drizzle ORM
|
||||
- **APIs**: GitHub (Octokit), Gitea REST API
|
||||
- **APIs**: GitHub (Octokit), Gitea/Forgejo REST API
|
||||
- **Auth**: Better Auth with session-based authentication
|
||||
|
||||
## Security
|
||||
|
||||
### Token Encryption
|
||||
- All GitHub and Gitea API tokens are encrypted at rest using AES-256-GCM
|
||||
- All GitHub and Gitea/Forgejo API tokens are encrypted at rest using AES-256-GCM
|
||||
- Encryption is automatic and transparent to users
|
||||
- Set `ENCRYPTION_SECRET` environment variable for production deployments
|
||||
- Falls back to `BETTER_AUTH_SECRET` if not set
|
||||
@@ -456,13 +486,13 @@ Gitea Mirror can also act as an OIDC provider for other applications. Register O
|
||||
## Known Limitations
|
||||
|
||||
### Pull Request Mirroring Implementation
|
||||
Pull requests **cannot be created as actual PRs** in Gitea due to API limitations. Instead, they are mirrored as **enriched issues** with comprehensive metadata.
|
||||
Pull requests **cannot be created as actual PRs** in Gitea/Forgejo due to API limitations. Instead, they are mirrored as **enriched issues** with comprehensive metadata.
|
||||
|
||||
**Why real PR mirroring isn't possible:**
|
||||
- Gitea's API doesn't support creating pull requests from external sources
|
||||
- Gitea/Forgejo's API doesn't support creating pull requests from external sources
|
||||
- Real PRs require actual Git branches with commits to exist in the repository
|
||||
- Would require complex branch synchronization and commit replication
|
||||
- The mirror relationship is one-way (GitHub → Gitea) for repository content
|
||||
- The mirror relationship is one-way (GitHub → Gitea/Forgejo) for repository content
|
||||
|
||||
**How we handle Pull Requests:**
|
||||
PRs are mirrored as issues with rich metadata including:
|
||||
@@ -476,7 +506,7 @@ PRs are mirrored as issues with rich metadata including:
|
||||
- 🔀 Base and head branch information
|
||||
- ✅ Merge status tracking
|
||||
|
||||
This approach preserves all important PR information while working within Gitea's API constraints. The PRs appear in Gitea's issue tracker with clear visual distinction and comprehensive details.
|
||||
This approach preserves all important PR information while working within Gitea/Forgejo's API constraints. The PRs appear in the issue tracker with clear visual distinction and comprehensive details.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@better-auth/oauth-provider": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -38,14 +39,14 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"better-auth": "1.6.11",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -82,11 +83,13 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.5",
|
||||
"node-forge": "^1.3.3",
|
||||
"rollup": ">=4.59.0",
|
||||
"svgo": "^4.0.1",
|
||||
"@xmldom/xmldom": "^0.8.13",
|
||||
"devalue": "^5.8.1",
|
||||
"fast-uri": "^3.1.2",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"kysely": "^0.28.17",
|
||||
"lodash": "^4.18.1",
|
||||
"picomatch": "^4.0.4",
|
||||
},
|
||||
"packages": {
|
||||
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
|
||||
@@ -163,23 +166,25 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
|
||||
"@better-auth/core": ["@better-auth/core@1.6.11", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="],
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw=="],
|
||||
|
||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g=="],
|
||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "kysely": "^0.28.17" }, "optionalPeers": ["kysely"] }, "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg=="],
|
||||
|
||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0" } }, "sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g=="],
|
||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0" } }, "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q=="],
|
||||
|
||||
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg=="],
|
||||
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA=="],
|
||||
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg=="],
|
||||
"@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.6.11", "", { "dependencies": { "jose": "^6.1.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.11", "better-call": "1.3.5" } }, "sha512-iMywpOEAiAUdtvpaRS8yKye+wO3AieOB3Sfv8czkmPduzFuKBICCWuOEAElQEk5tQz3vzWx64zNlLBkgEAOhuw=="],
|
||||
|
||||
"@better-auth/sso": ["@better-auth/sso@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "fast-xml-parser": "^5.4.1", "jose": "^6.1.3", "samlify": "^2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "1.5.5", "better-auth": "1.5.5", "better-call": "1.3.2" } }, "sha512-G3tvv5oKtEfpmBrt7Db/hSl5A3xttUkB4EhEjb202UhHz/XBiT0Orv5CkRa0kmjRyyAwOzn/lKZzYsd3VrjViA=="],
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw=="],
|
||||
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="],
|
||||
"@better-auth/sso": ["@better-auth/sso@1.6.11", "", { "dependencies": { "fast-xml-parser": "^5.5.7", "jose": "^6.1.3", "samlify": "~2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.11", "better-call": "1.3.5" } }, "sha512-lJHmoCayp9Woh/MPKTHDfGq7k1oQbU2yz5tIOZXl/pzrgLxV7fMGo9aJCyabHkw3GHMjBes4byC6aakHYzpZIg=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="],
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||
|
||||
@@ -351,14 +356,14 @@
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
|
||||
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
|
||||
@@ -385,6 +390,8 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
|
||||
@@ -681,10 +688,6 @@
|
||||
|
||||
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
||||
|
||||
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
|
||||
|
||||
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
@@ -721,7 +724,7 @@
|
||||
|
||||
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
@@ -767,9 +770,9 @@
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
|
||||
"better-auth": ["better-auth@1.6.11", "", { "dependencies": { "@better-auth/core": "1.6.11", "@better-auth/drizzle-adapter": "1.6.11", "@better-auth/kysely-adapter": "1.6.11", "@better-auth/memory-adapter": "1.6.11", "@better-auth/mongo-adapter": "1.6.11", "@better-auth/prisma-adapter": "1.6.11", "@better-auth/telemetry": "1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ=="],
|
||||
|
||||
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
|
||||
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
@@ -779,15 +782,13 @@
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"camelcase": ["camelcase@9.0.0", "", {}, "sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw=="],
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
|
||||
|
||||
@@ -859,7 +860,7 @@
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
@@ -871,7 +872,7 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
|
||||
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
@@ -893,7 +894,7 @@
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||
|
||||
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||
|
||||
@@ -955,11 +956,11 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"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.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"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.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -1085,7 +1086,7 @@
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
|
||||
"kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
@@ -1111,7 +1112,7 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
@@ -1179,8 +1180,6 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
@@ -1257,10 +1256,6 @@
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="],
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -1279,6 +1274,8 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
|
||||
|
||||
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
@@ -1309,6 +1306,8 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
||||
@@ -1317,7 +1316,7 @@
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.1.3", "", {}, "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ=="],
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
@@ -1325,7 +1324,7 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
@@ -1429,7 +1428,7 @@
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"samlify": ["samlify@2.11.0", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "camelcase": "^9.0.0", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-1C9ukjlf0rRsuyqdzztqikdItqa33j9NCCDZgeBiWk0etU6vxNB+SWJKW4Flk07ZlhXeev/twALEKrPhIAyfDg=="],
|
||||
"samlify": ["samlify@2.10.2", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.6", "camelcase": "^6.2.0", "node-forge": "^1.3.0", "node-rsa": "^1.1.1", "pako": "^1.0.10", "uuid": "^8.3.2", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.32" } }, "sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA=="],
|
||||
|
||||
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
|
||||
|
||||
@@ -1465,8 +1464,6 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -1481,7 +1478,7 @@
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
@@ -1657,9 +1654,11 @@
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
|
||||
"xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
|
||||
|
||||
@@ -1687,8 +1686,6 @@
|
||||
|
||||
"@astrojs/react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
@@ -1745,8 +1742,6 @@
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"astro/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
|
||||
"astro/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
@@ -1761,8 +1756,6 @@
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
@@ -1771,6 +1764,8 @@
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
|
||||
|
||||
"samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"tough-cookie/tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
@@ -1923,10 +1918,6 @@
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url/tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"tough-cookie/tldts/tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
{
|
||||
"version": "2.9",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "eIiDx",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Scheduling Settings - Redesign",
|
||||
"width": 1080,
|
||||
"fill": "#09090B",
|
||||
"cornerRadius": 16,
|
||||
"gap": 24,
|
||||
"padding": 32,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7r0Wv",
|
||||
"name": "Automatic Syncing Card",
|
||||
"clip": true,
|
||||
"width": "fill_container",
|
||||
"fill": "#18181B",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#27272A"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gyCPG",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "OunzZ",
|
||||
"name": "headerIcon",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"iconFontName": "refresh-cw",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#A1A1AA"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fMdlX",
|
||||
"name": "headerTitle",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Automatic Syncing",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "4cX02",
|
||||
"name": "divider1",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "Kiezh",
|
||||
"name": "Toggle Section",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "QCPzN",
|
||||
"name": "Checkbox",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"fill": "#6366F1",
|
||||
"cornerRadius": 4,
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "4FTax",
|
||||
"x": 3,
|
||||
"y": 3,
|
||||
"name": "checkIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FTzs6",
|
||||
"name": "toggleText",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "1nJKC",
|
||||
"name": "toggleLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Enable automatic repository syncing",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "r1O5t",
|
||||
"name": "toggleDesc",
|
||||
"fill": "#71717A",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Periodically sync GitHub changes to Gitea",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "nvQ6R",
|
||||
"name": "divider2",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FOoBn",
|
||||
"name": "Schedule Builder",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 20,
|
||||
"padding": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "IqHEu",
|
||||
"name": "schedHeader",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "RnVoM",
|
||||
"name": "schedTitle",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "SCHEDULE",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "aVtIZ",
|
||||
"name": "tzBadge",
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 20,
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
4,
|
||||
10
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "iXpYV",
|
||||
"name": "tzIcon",
|
||||
"width": 12,
|
||||
"height": 12,
|
||||
"iconFontName": "globe",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "WjPMl",
|
||||
"name": "tzText",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "UTC",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "P02fk",
|
||||
"name": "formRow",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kcYK5",
|
||||
"name": "Frequency",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vMvsN",
|
||||
"name": "label2",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Frequency",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "3prth",
|
||||
"name": "select2",
|
||||
"width": "fill_container",
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ANY36",
|
||||
"name": "sel2Text",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Daily",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "GUWfd",
|
||||
"name": "sel2Icon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "xphp0",
|
||||
"name": "Start Time",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "l6VkR",
|
||||
"name": "label3",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Start Time",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lWBDi",
|
||||
"name": "timeInput",
|
||||
"width": "fill_container",
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fbuMi",
|
||||
"name": "timeText",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "10:00 PM",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "5xKW7",
|
||||
"name": "timeIcon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "clock-4",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "BtYt7",
|
||||
"name": "divider3",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "520Kb",
|
||||
"name": "Status Bar",
|
||||
"width": "fill_container",
|
||||
"padding": [
|
||||
16,
|
||||
24
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "J8JzX",
|
||||
"name": "lastSync",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "MS5VM",
|
||||
"name": "lastIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "history",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "8KJHY",
|
||||
"name": "lastLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Last sync",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Fz116",
|
||||
"name": "lastValue",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Never",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZbRFN",
|
||||
"name": "nextSync",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "wIKSk",
|
||||
"name": "nextIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "calendar",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ejqSP",
|
||||
"name": "nextLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Next sync",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "M4oJ7",
|
||||
"name": "nextValue",
|
||||
"fill": "#6366F1",
|
||||
"content": "Calculating...",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7PK7H",
|
||||
"name": "Database Maintenance Card",
|
||||
"clip": true,
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "#18181B",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#27272A"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "FAaon",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "64CaE",
|
||||
"name": "rHeaderIcon",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"iconFontName": "database",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#A1A1AA"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rvZlC",
|
||||
"name": "rHeaderTitle",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Database Maintenance",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "nsM0M",
|
||||
"name": "rDivider1",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "8zhPi",
|
||||
"name": "Toggle Section",
|
||||
"width": "fill_container",
|
||||
"gap": 14,
|
||||
"padding": [
|
||||
20,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "eQbZk",
|
||||
"name": "Checkbox",
|
||||
"width": 20,
|
||||
"height": 20,
|
||||
"fill": "#6366F1",
|
||||
"cornerRadius": 4,
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "t6PbY",
|
||||
"x": 3,
|
||||
"y": 3,
|
||||
"name": "rCheckIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "check",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lpBPI",
|
||||
"name": "rToggleText",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Kuy1S",
|
||||
"name": "rToggleLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Enable automatic database cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "OviVY",
|
||||
"name": "rToggleDesc",
|
||||
"fill": "#71717A",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Remove old activity logs to optimize storage",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "1og3D",
|
||||
"name": "rDivider2",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "J7576",
|
||||
"name": "Retention Section",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": 24,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "JZA6R",
|
||||
"name": "retLabelRow",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Diiak",
|
||||
"name": "retLabel",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "Data retention period",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "1qqCe",
|
||||
"name": "retInfoIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "info",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfUjs",
|
||||
"name": "retRow",
|
||||
"width": "fill_container",
|
||||
"gap": 16,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "9bhls",
|
||||
"name": "retSelect",
|
||||
"width": 180,
|
||||
"height": 40,
|
||||
"fill": "#27272A",
|
||||
"cornerRadius": 8,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#3F3F46"
|
||||
},
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "3NOod",
|
||||
"name": "retSelText",
|
||||
"fill": "#FAFAFA",
|
||||
"content": "1 month",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "8QBA8",
|
||||
"name": "retSelIcon",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "chevron-down",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#71717A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "GA6ye",
|
||||
"name": "retHelper",
|
||||
"fill": "#52525B",
|
||||
"content": "Cleanup runs every 2 days",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "WfXVB",
|
||||
"name": "rDivider3",
|
||||
"fill": "#27272A",
|
||||
"width": "fill_container",
|
||||
"height": 1
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "WpXnI",
|
||||
"name": "Cleanup Status",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
16,
|
||||
24
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fbpm5",
|
||||
"name": "lastCleanup",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "DdLix",
|
||||
"name": "lastCleanupLeft",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "FN2cj",
|
||||
"name": "lastCleanIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "history",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "JjmMa",
|
||||
"name": "lastCleanLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Last cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "l1Kph",
|
||||
"name": "lastCleanValue",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Never",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "AWHY8",
|
||||
"name": "nextCleanup",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "sj0qN",
|
||||
"name": "nextCleanupLeft",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "V6RTK",
|
||||
"name": "nextCleanIcon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "calendar",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#52525B"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wf0b4",
|
||||
"name": "nextCleanLabel",
|
||||
"fill": "#52525B",
|
||||
"content": "Next cleanup",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "YWZGH",
|
||||
"name": "nextCleanValue",
|
||||
"fill": "#6366F1",
|
||||
"content": "March 20, 2026 at 12:19 AM",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
-1
@@ -18,6 +18,11 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
|
||||
# REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
# Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL.
|
||||
|
||||
# === CORE SETTINGS ===
|
||||
# These are technically required but have working defaults
|
||||
@@ -25,6 +30,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# Optional concurrency controls (defaults match in-app defaults)
|
||||
# If you want perfect ordering of issues and PRs, set these at 1
|
||||
@@ -32,7 +38,11 @@ services:
|
||||
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
@@ -66,6 +66,7 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- BETTER_AUTH_SECRET=dev-secret-key
|
||||
# GitHub/Gitea Mirror Config
|
||||
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
|
||||
@@ -89,7 +90,11 @@ services:
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
+18
-1
@@ -30,8 +30,21 @@ services:
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BASE_URL=${BASE_URL:-/}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
|
||||
# you MUST set these three variables to your external URL. Example:
|
||||
# BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
|
||||
# If deployed under a path prefix (e.g. https://git.example.com/mirror), also set:
|
||||
# BASE_URL=/mirror
|
||||
# BETTER_AUTH_URL=https://git.example.com
|
||||
# PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
|
||||
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
|
||||
# - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-}
|
||||
# GitHub/Gitea Mirror Config
|
||||
@@ -74,7 +87,11 @@ services:
|
||||
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
|
||||
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
@@ -229,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP
|
||||
|
||||
# Start the application
|
||||
echo "Starting Gitea Mirror..."
|
||||
bun ./dist/server/entry.mjs &
|
||||
if [ -f "./scripts/runtime-server.ts" ]; then
|
||||
bun ./scripts/runtime-server.ts &
|
||||
elif [ -f "./dist/scripts/runtime-server.js" ]; then
|
||||
bun ./dist/scripts/runtime-server.js &
|
||||
else
|
||||
bun ./dist/server/entry.mjs &
|
||||
fi
|
||||
APP_PID=$!
|
||||
|
||||
# Wait for the application to finish
|
||||
|
||||
@@ -16,6 +16,7 @@ When environment variables are set:
|
||||
## Table of Contents
|
||||
|
||||
- [Core Configuration](#core-configuration)
|
||||
- [HTTPS / TLS](#https--tls)
|
||||
- [GitHub Configuration](#github-configuration)
|
||||
- [Gitea Configuration](#gitea-configuration)
|
||||
- [Mirror Options](#mirror-options)
|
||||
@@ -33,13 +34,39 @@ Essential application settings required for running Gitea Mirror.
|
||||
| `NODE_ENV` | Application environment | `production` | No |
|
||||
| `HOST` | Server host binding | `0.0.0.0` | No |
|
||||
| `PORT` | Server port | `4321` | No |
|
||||
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
|
||||
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
|
||||
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
|
||||
| `BETTER_AUTH_LOG_LEVEL` | Better Auth logger verbosity. Set to `debug` to surface the full SSO/OIDC sign-in and callback trace when troubleshooting authentication. Accepted values: `debug`, `info`, `warn`, `error`. (Better Auth does **not** use the `DEBUG` env var.) | `warn` | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
## HTTPS / TLS
|
||||
|
||||
Gitea Mirror can terminate TLS directly via the underlying `@astrojs/node` adapter — useful when you don't want a separate reverse proxy. When both variables below are set, the server starts as a real HTTPS listener instead of HTTP.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `SERVER_CERT_PATH` | Absolute path to the TLS certificate (PEM). Set together with `SERVER_KEY_PATH` to enable HTTPS. | - | No |
|
||||
| `SERVER_KEY_PATH` | Absolute path to the TLS private key (PEM). Set together with `SERVER_CERT_PATH` to enable HTTPS. | - | No |
|
||||
|
||||
**Example (systemd or `.env`):**
|
||||
|
||||
```bash
|
||||
SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
|
||||
SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
|
||||
PORT=443
|
||||
BETTER_AUTH_URL=https://mirror.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://mirror.example.com
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The process must have read access to both files. When binding to `PORT=443`, grant the binary the `CAP_NET_BIND_SERVICE` capability (or run as a user allowed to bind privileged ports) rather than running as root.
|
||||
- If you already terminate TLS at a reverse proxy (nginx, Traefik, Caddy), leave these unset and let the proxy handle certificates.
|
||||
- Works in Docker too — mount your certs and set both paths to locations inside the container.
|
||||
|
||||
## GitHub Configuration
|
||||
|
||||
Settings for connecting to and configuring GitHub repository sources.
|
||||
@@ -51,6 +78,21 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| `GITHUB_USERNAME` | Your GitHub username | - | - |
|
||||
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
|
||||
| `GITHUB_TYPE` | GitHub account type | `personal` | `personal`, `organization` |
|
||||
| `GH_API_URL` | GitHub API base URL. Override this to point at GitHub Enterprise Server or Enterprise Cloud with data residency. | `https://api.github.com` | e.g. `https://ghe.example.com/api/v3`, `https://api.TENANT.ghe.com` |
|
||||
|
||||
### GitHub Enterprise (GHES / GHEC with data residency)
|
||||
|
||||
Set `GH_API_URL` to point Octokit at a non-`github.com` API endpoint:
|
||||
|
||||
```bash
|
||||
# GitHub Enterprise Server (self-hosted)
|
||||
GH_API_URL=https://ghe.example.com/api/v3
|
||||
|
||||
# GitHub Enterprise Cloud with data residency
|
||||
GH_API_URL=https://api.TENANT.ghe.com
|
||||
```
|
||||
|
||||
Standard GitHub Enterprise Cloud on `github.com` works with the default — no override needed. Use a personal access token issued by the target Enterprise instance for `GITHUB_TOKEN`.
|
||||
|
||||
### Repository Selection
|
||||
|
||||
@@ -59,8 +101,10 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
|
||||
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
|
||||
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
|
||||
| `INCLUDE_COLLABORATOR_REPOS` | Include repositories where you are a collaborator (not just owned). Set to `false` to limit imports to repos you own. | `true` | `true`, `false` |
|
||||
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
|
||||
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
|
||||
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
|
||||
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
|
||||
|
||||
@@ -70,7 +114,7 @@ Settings for connecting to and configuring GitHub repository sources.
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_ORGANIZATIONS` | Mirror organization repositories | `false` | `true`, `false` |
|
||||
| `PRESERVE_ORG_STRUCTURE` | Preserve GitHub organization structure in Gitea | `false` | `true`, `false` |
|
||||
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal) | `false` | `true`, `false` |
|
||||
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal); sets `skipPersonalRepos: true` in GitHub config | `false` | `true`, `false` |
|
||||
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
|
||||
|
||||
### Advanced Settings
|
||||
@@ -301,6 +345,7 @@ services:
|
||||
environment:
|
||||
# Core Configuration
|
||||
- NODE_ENV=production
|
||||
- BASE_URL=/
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- BETTER_AUTH_SECRET=your-secure-secret-here
|
||||
# Primary access URL:
|
||||
@@ -369,6 +414,24 @@ This setup allows you to:
|
||||
|
||||
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
|
||||
|
||||
### Path Prefix Deployments
|
||||
|
||||
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
|
||||
|
||||
```bash
|
||||
# BASE_URL handles the path prefix — auth URLs stay as origin only
|
||||
BASE_URL=/mirror
|
||||
BETTER_AUTH_URL=https://git.example.com
|
||||
PUBLIC_BETTER_AUTH_URL=https://git.example.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
|
||||
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` must be **origin only** (scheme + host). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
|
||||
- `BETTER_AUTH_TRUSTED_ORIGINS` must also contain origins only (no path).
|
||||
- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
|
||||
|
||||
### Trusted Origins
|
||||
|
||||
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
|
||||
|
||||
@@ -78,7 +78,11 @@ These appear when any non-disabled strategy is selected:
|
||||
|
||||
### Snapshot Retention Count
|
||||
|
||||
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **20**.
|
||||
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **5**.
|
||||
|
||||
### Snapshot Retention Days
|
||||
|
||||
Maximum age (in days) for backup snapshots. Bundles older than this are deleted during retention enforcement, though at least one bundle is always kept. Set to `0` to disable time-based retention. Default: **30**.
|
||||
|
||||
### Snapshot Directory
|
||||
|
||||
@@ -96,7 +100,7 @@ The old `backupBeforeSync` boolean is still recognized:
|
||||
|
||||
| Old Setting | New Equivalent |
|
||||
|---|---|
|
||||
| `backupBeforeSync: true` | `backupStrategy: "always"` |
|
||||
| `backupBeforeSync: true` | `backupStrategy: "on-force-push"` |
|
||||
| `backupBeforeSync: false` | `backupStrategy: "disabled"` |
|
||||
| Neither set | `backupStrategy: "on-force-push"` (new default) |
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Notifications
|
||||
|
||||
Gitea Mirror supports push notifications for mirror events. You can be alerted when jobs succeed, fail, or when new repositories are discovered.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### 1. Ntfy.sh (Direct)
|
||||
|
||||
[Ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can use the public server at `https://ntfy.sh` or self-host your own instance.
|
||||
|
||||
**Setup (public server):**
|
||||
1. Go to **Configuration > Notifications**
|
||||
2. Enable notifications and select **Ntfy.sh** as the provider
|
||||
3. Set the **Topic** to a unique name (e.g., `my-gitea-mirror-abc123`)
|
||||
4. Leave the Server URL as `https://ntfy.sh`
|
||||
5. Subscribe to the same topic on your phone or desktop using the [ntfy app](https://ntfy.sh/docs/subscribe/phone/)
|
||||
|
||||
**Setup (self-hosted):**
|
||||
1. Deploy ntfy using Docker: `docker run -p 8080:80 binwiederhier/ntfy serve`
|
||||
2. Set the **Server URL** to your instance (e.g., `http://ntfy:8080`)
|
||||
3. If authentication is enabled, provide an **Access token**
|
||||
4. Set your **Topic** name
|
||||
|
||||
**Priority levels:**
|
||||
- `min` / `low` / `default` / `high` / `urgent`
|
||||
- Error notifications automatically use `high` priority regardless of the default setting
|
||||
|
||||
### 2. Apprise API (Aggregator)
|
||||
|
||||
[Apprise](https://github.com/caronc/apprise-api) is a notification aggregator that supports 100+ services (Slack, Discord, Telegram, Email, Pushover, and many more) through a single API.
|
||||
|
||||
**Setup:**
|
||||
1. Deploy the Apprise API server:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
apprise:
|
||||
image: caronc/apprise:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- apprise-config:/config
|
||||
volumes:
|
||||
apprise-config:
|
||||
```
|
||||
2. Configure your notification services in Apprise (via its web UI at `http://localhost:8000` or API)
|
||||
3. Create a configuration token/key in Apprise
|
||||
4. In Gitea Mirror, go to **Configuration > Notifications**
|
||||
5. Enable notifications and select **Apprise API**
|
||||
6. Set the **Server URL** to your Apprise instance (e.g., `http://apprise:8000`)
|
||||
7. Enter the **Token/path** you created in step 3
|
||||
|
||||
**Tag filtering:**
|
||||
- Optionally set a **Tag** to only notify specific Apprise services
|
||||
- Leave empty to notify all configured services
|
||||
|
||||
## Event Types
|
||||
|
||||
| Event | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| Sync errors | On | A mirror job failed |
|
||||
| Sync success | Off | A mirror job completed successfully |
|
||||
| New repo discovered | Off | A new GitHub repo was auto-imported during scheduled sync |
|
||||
|
||||
## Testing
|
||||
|
||||
Use the **Send Test Notification** button on the Notifications settings page to verify your configuration. The test sends a sample success notification to your configured provider.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Notifications not arriving:**
|
||||
- Check that notifications are enabled in the settings
|
||||
- Verify the provider configuration (URL, topic/token)
|
||||
- Use the Test button to check connectivity
|
||||
- Check the server logs for `[NotificationService]` messages
|
||||
|
||||
**Ntfy authentication errors:**
|
||||
- Ensure your access token is correct
|
||||
- If self-hosting, verify the ntfy server allows the topic
|
||||
|
||||
**Apprise connection refused:**
|
||||
- Verify the Apprise API server is running and accessible from the Gitea Mirror container
|
||||
- If using Docker, ensure both containers are on the same network
|
||||
- Check the Apprise server logs for errors
|
||||
|
||||
**Tokens and security:**
|
||||
- Notification tokens (ntfy access tokens, Apprise tokens) are encrypted at rest using the same AES-256-GCM encryption as GitHub/Gitea tokens
|
||||
- Tokens are decrypted only when sending notifications or displaying in the settings UI
|
||||
+95
-6
@@ -4,15 +4,16 @@ This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OI
|
||||
|
||||
## Overview
|
||||
|
||||
Gitea Mirror supports three authentication methods:
|
||||
Gitea Mirror supports four authentication methods:
|
||||
|
||||
1. **Email & Password** - Traditional authentication (always enabled)
|
||||
2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers
|
||||
3. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
|
||||
3. **Header / Forward Authentication** - Trust pre-authenticated requests from a reverse proxy (Authentik, Authelia, oauth2-proxy, etc.)
|
||||
4. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
|
||||
|
||||
## Configuration
|
||||
|
||||
All SSO and OIDC settings are managed through the web UI in the Configuration page under the "Authentication" tab.
|
||||
SSO and OIDC Provider settings are managed through the web UI in the Configuration page under the "Authentication" tab. Header / Forward Authentication is configured via environment variables only — see [Setting up Header / Forward Authentication](#setting-up-header--forward-authentication) below.
|
||||
|
||||
## Setting up SSO (Single Sign-On)
|
||||
|
||||
@@ -27,7 +28,7 @@ SSO allows your users to sign in using external identity providers like Google,
|
||||
#### Required Fields
|
||||
|
||||
- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`)
|
||||
- **Domain**: The email domain for this provider (e.g., `example.com`)
|
||||
- **Domain**: The email domain this provider serves (e.g., `example.com`). **This is load-bearing for account-linking trust — see [Account Linking](#account-linking) below.** Multi-domain IdPs can use a comma-separated list (`example.com,subsidiary.com`).
|
||||
- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`)
|
||||
- **Client ID**: The OAuth client ID from your provider
|
||||
- **Client Secret**: The OAuth client secret from your provider
|
||||
@@ -56,6 +57,22 @@ https://your-domain.com/api/auth/sso/callback/{provider-id}
|
||||
|
||||
Replace `{provider-id}` with your chosen Provider ID.
|
||||
|
||||
### Account Linking
|
||||
|
||||
When a user signs in via SSO whose email matches an **existing** email/password account, Gitea Mirror will try to *link* the SSO identity to that account so the user lands on the same dashboard — instead of being bounced to the login page or creating a duplicate account.
|
||||
|
||||
The trust model is **domain-scoped**:
|
||||
|
||||
- Every SSO provider you register is automatically marked `domainVerified: true` for the **Domain** field you set on registration.
|
||||
- An SSO sign-in is auto-linked to an existing local account **only when** the user's email address actually belongs to that registered domain. Cross-domain emails are rejected even if the provider is registered.
|
||||
- Local email/password accounts in this app are never verified (no email-verification flow exists), so we deliberately turn off `requireLocalEmailVerified` on the linker — otherwise no one could ever link.
|
||||
|
||||
**What this means for you:**
|
||||
|
||||
- Set the **Domain** field to the email domain(s) your IdP actually issues identities for. Don't set it to `example.com` if your IdP issues `@elsewhere.com` emails — auto-linking will silently refuse them.
|
||||
- If your IdP allows users to self-register or claim arbitrary emails *within the registered domain*, an attacker on that IdP can absorb a local account by claiming the same email. This is a trust decision: by registering an IdP, you're vouching for its identity model for that domain.
|
||||
- Multi-domain IdPs (one Authentik serving `acme.com,acquired.io`) work fine — list every domain comma-separated in the **Domain** field.
|
||||
|
||||
### Example: Google SSO Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
@@ -98,9 +115,80 @@ Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mir
|
||||
|
||||
Notes:
|
||||
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
|
||||
- Authentik must report the user’s email as verified (default behavior) so Gitea Mirror can auto-link accounts.
|
||||
- Set the **Domain** field to the email domain your Authentik users actually have. Auto-linking to an existing local admin only happens for emails in that domain — see [Account Linking](#account-linking) for the trust model. (Authentik's default email scope mapping returns `email_verified: False` for OIDC clients, which is why account linking is gated on the domain match here rather than the IdP's verified-email claim.)
|
||||
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
|
||||
|
||||
## Setting up Header / Forward Authentication
|
||||
|
||||
Header authentication trusts a reverse proxy (Authentik, Authelia, oauth2-proxy, Traefik forward-auth, etc.) to authenticate users upstream and pass identity in HTTP headers. When enabled, Gitea Mirror reads the configured headers on each request and resolves the user automatically — no login form, no callback.
|
||||
|
||||
> **Important — operator-controlled by design.** Header auth is configured via environment variables only, not the UI. This is intentional: trusting a header means trusting whatever upstream sets it, and that decision belongs to the operator who controls the reverse proxy, not to a logged-in user inside the app. Make sure your reverse proxy strips these headers from inbound client requests, otherwise anyone can spoof them.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HEADER_AUTH_ENABLED` | Master switch — must be `true` to enable | `false` |
|
||||
| `HEADER_AUTH_USER_HEADER` | Header containing the username | `X-Authentik-Username` |
|
||||
| `HEADER_AUTH_EMAIL_HEADER` | Header containing the email address | `X-Authentik-Email` |
|
||||
| `HEADER_AUTH_NAME_HEADER` | Header containing the display name | `X-Authentik-Name` |
|
||||
| `HEADER_AUTH_AUTO_PROVISION` | If `true`, create a new user when an unknown username arrives. If `false`, unknown users are rejected. | `false` |
|
||||
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated email domain allowlist. Empty = allow any. | _(empty)_ |
|
||||
|
||||
See also: [`docs/ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md#header-authentication-reverse-proxy-sso).
|
||||
|
||||
### Example: Docker Compose with Authentik
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
environment:
|
||||
HEADER_AUTH_ENABLED: "true"
|
||||
HEADER_AUTH_USER_HEADER: "X-Authentik-Username"
|
||||
HEADER_AUTH_EMAIL_HEADER: "X-Authentik-Email"
|
||||
HEADER_AUTH_NAME_HEADER: "X-Authentik-Name"
|
||||
HEADER_AUTH_AUTO_PROVISION: "true"
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,example.org"
|
||||
```
|
||||
|
||||
The defaults are Authentik-shaped, so for an Authentik proxy provider you generally only need to set `HEADER_AUTH_ENABLED=true` (and `HEADER_AUTH_AUTO_PROVISION=true` if you want first-login self-registration).
|
||||
|
||||
### Example: Authelia
|
||||
|
||||
Authelia uses different header names — override them:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
HEADER_AUTH_ENABLED: "true"
|
||||
HEADER_AUTH_USER_HEADER: "Remote-User"
|
||||
HEADER_AUTH_EMAIL_HEADER: "Remote-Email"
|
||||
HEADER_AUTH_NAME_HEADER: "Remote-Name"
|
||||
HEADER_AUTH_AUTO_PROVISION: "true"
|
||||
```
|
||||
|
||||
Then configure Authelia's `authz` rules to protect the gitea-mirror route and inject the `Remote-*` headers.
|
||||
|
||||
### Behaviour and lookup order
|
||||
|
||||
- The middleware checks for a cookie session **first**. Header auth only fires when there is no existing session, so users who logged in via password or SSO are not affected.
|
||||
- Lookup is by `HEADER_AUTH_USER_HEADER` value matched against `users.username`; if no match and `HEADER_AUTH_EMAIL_HEADER` is set, a second lookup is tried against `users.email`.
|
||||
- If neither matches:
|
||||
- With `HEADER_AUTH_AUTO_PROVISION=true`, a new user row is created (email defaults to `<username>@header-auth.local` if no email header is present).
|
||||
- With `HEADER_AUTH_AUTO_PROVISION=false`, the request is rejected with a warning logged.
|
||||
- If `HEADER_AUTH_ALLOWED_DOMAINS` is non-empty and the email header arrives, the email's domain must be in the list or the request is rejected.
|
||||
|
||||
### Verifying it's enabled
|
||||
|
||||
When header auth is active, the Authentication settings page renders a green "Header Authentication / Via reverse proxy" badge under the auth-methods status block. You can also probe `/api/auth/header-status`, which returns `{ "enabled": true, ... }` once the env vars are set.
|
||||
|
||||
### Security checklist
|
||||
|
||||
1. **Strip the headers at your edge.** Your reverse proxy must remove inbound `HEADER_AUTH_USER_HEADER` / email / name headers from client requests before re-injecting its own. Otherwise any unauthenticated client can set `X-Authentik-Username: admin` and walk in.
|
||||
2. **Bind the app to the proxy only.** Don't expose Gitea Mirror's port directly to the network when header auth is on; only the trusted proxy should reach it.
|
||||
3. **Use HTTPS between proxy and clients.** Headers travel in plaintext on the link they cross.
|
||||
4. **Be conservative with `HEADER_AUTH_AUTO_PROVISION`.** With it off, you provision users in the UI/DB once and the proxy fills in sessions thereafter — safer for shared deployments.
|
||||
|
||||
## Setting up OIDC Provider
|
||||
|
||||
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
|
||||
@@ -185,7 +273,8 @@ When an application requests authentication:
|
||||
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
|
||||
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
|
||||
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
|
||||
4. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
4. **`?error=UNKNOWN` on the homepage after a successful upstream login** (or `?error=account%20not%20linked` in development): the SSO callback succeeded but Better Auth refused to link the SSO identity to an existing local account. The most common cause is the SSO provider's registered **Domain** not matching the user's actual email domain — see [Account Linking](#account-linking). Set `BETTER_AUTH_LOG_LEVEL=debug` to see Better Auth's full callback trace (look for "User already exist but account isn't linked to ...") and confirm the diagnosis. The same symptom in production gets sanitized to `UNKNOWN` by Better Auth's error page before the redirect, which is why the visible error is opaque.
|
||||
5. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
|
||||
### OIDC Provider Issues
|
||||
|
||||
|
||||
+50
-2
@@ -125,11 +125,59 @@ npm start
|
||||
|
||||
### Debug Mode:
|
||||
|
||||
Enable debug logging by setting environment variable:
|
||||
> **Note:** Better Auth uses its own logger and does **not** read the `DEBUG`
|
||||
> environment variable (it is not based on the `debug` npm package). An older
|
||||
> version of this guide suggested `DEBUG=better-auth:*` — that has no effect.
|
||||
|
||||
Better Auth's logger defaults to the `warn` level, so SSO/OIDC sign-in and
|
||||
callback details are hidden. Set the log level to `debug` to surface the full
|
||||
trace:
|
||||
|
||||
```bash
|
||||
DEBUG=better-auth:* bun run dev
|
||||
# Local dev
|
||||
BETTER_AUTH_LOG_LEVEL=debug bun run dev
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Docker Compose
|
||||
services:
|
||||
gitea-mirror:
|
||||
environment:
|
||||
- BETTER_AUTH_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Then watch the server logs (e.g. `docker compose logs -f gitea-mirror`) while you
|
||||
attempt an SSO login. Lines are prefixed with `[Better Auth]:`. Accepted values
|
||||
are `debug`, `info`, `warn`, and `error`.
|
||||
|
||||
#### Debugging a login that bounces back to `/login`
|
||||
|
||||
If clicking the SSO button sends you to the provider and then straight back to
|
||||
the login screen, the OAuth flow itself usually succeeded but **no session
|
||||
cookie was persisted**. Work through these checks:
|
||||
|
||||
1. **Enable `BETTER_AUTH_LOG_LEVEL=debug`** (above) and look for errors during
|
||||
the `/api/auth/sso/callback/<provider-id>` request.
|
||||
2. **Check for `?error=UNKNOWN` on the landing URL** (or `?error=account%20not%20linked` in dev).
|
||||
That's Better Auth's account-linking step refusing to attach the SSO identity
|
||||
to an existing email/password account. The debug log line to look for is
|
||||
`User already exist but account isn't linked to <providerId>`. The fix is
|
||||
almost always to set the SSO provider's **Domain** field to the email domain
|
||||
your users actually have — auto-linking is gated on that domain match.
|
||||
See [docs/SSO-OIDC-SETUP.md#account-linking](./SSO-OIDC-SETUP.md#account-linking).
|
||||
3. **Check the redirect URI** registered in your IdP exactly matches
|
||||
`https://<your-domain>/api/auth/sso/callback/<provider-id>` (scheme, host,
|
||||
and provider ID — no trailing slash).
|
||||
4. **Confirm the session cookie is set.** In the browser DevTools → Network,
|
||||
inspect the callback response for a `Set-Cookie: better-auth-session=…`
|
||||
header, and DevTools → Application → Cookies for the stored cookie. Behind a
|
||||
reverse proxy, ensure `BETTER_AUTH_URL` is your **external HTTPS** URL so the
|
||||
cookie is issued with the correct domain and `Secure` flag, and that the
|
||||
proxy forwards `X-Forwarded-Proto: https` and `X-Forwarded-Host`.
|
||||
5. **A `401` on `/api/sso/applications` is unrelated** to client login — that
|
||||
endpoint backs the OAuth *provider* (consent) management UI and requires an
|
||||
existing session. It is not part of the Authentik/OIDC sign-in flow.
|
||||
|
||||
## Testing Different Scenarios
|
||||
|
||||
### 1. New User Registration
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -1,24 +1,149 @@
|
||||
ALTER TABLE `repositories` ADD `imported_at` integer DEFAULT (unixepoch()) NOT NULL;--> statement-breakpoint
|
||||
UPDATE `repositories`
|
||||
SET `imported_at` = COALESCE(
|
||||
(
|
||||
SELECT MIN(`mj`.`timestamp`)
|
||||
FROM `mirror_jobs` `mj`
|
||||
WHERE `mj`.`user_id` = `repositories`.`user_id`
|
||||
AND `mj`.`status` = 'imported'
|
||||
AND (
|
||||
(`mj`.`repository_id` IS NOT NULL AND `mj`.`repository_id` = `repositories`.`id`)
|
||||
OR (
|
||||
`mj`.`repository_id` IS NULL
|
||||
AND `mj`.`repository_name` IS NOT NULL
|
||||
AND (
|
||||
lower(trim(`mj`.`repository_name`)) = `repositories`.`normalized_full_name`
|
||||
OR lower(trim(`mj`.`repository_name`)) = lower(trim(`repositories`.`name`))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
`repositories`.`created_at`,
|
||||
`imported_at`
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `__new_repositories` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`config_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`full_name` text NOT NULL,
|
||||
`normalized_full_name` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`clone_url` text NOT NULL,
|
||||
`owner` text NOT NULL,
|
||||
`organization` text,
|
||||
`mirrored_location` text DEFAULT '',
|
||||
`is_private` integer DEFAULT false NOT NULL,
|
||||
`is_fork` integer DEFAULT false NOT NULL,
|
||||
`forked_from` text,
|
||||
`has_issues` integer DEFAULT false NOT NULL,
|
||||
`is_starred` integer DEFAULT false NOT NULL,
|
||||
`is_archived` integer DEFAULT false NOT NULL,
|
||||
`size` integer DEFAULT 0 NOT NULL,
|
||||
`has_lfs` integer DEFAULT false NOT NULL,
|
||||
`has_submodules` integer DEFAULT false NOT NULL,
|
||||
`language` text,
|
||||
`description` text,
|
||||
`default_branch` text NOT NULL,
|
||||
`visibility` text DEFAULT 'public' NOT NULL,
|
||||
`status` text DEFAULT 'imported' NOT NULL,
|
||||
`last_mirrored` integer,
|
||||
`error_message` text,
|
||||
`destination_org` text,
|
||||
`metadata` text,
|
||||
`imported_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_repositories` (
|
||||
`id`,
|
||||
`user_id`,
|
||||
`config_id`,
|
||||
`name`,
|
||||
`full_name`,
|
||||
`normalized_full_name`,
|
||||
`url`,
|
||||
`clone_url`,
|
||||
`owner`,
|
||||
`organization`,
|
||||
`mirrored_location`,
|
||||
`is_private`,
|
||||
`is_fork`,
|
||||
`forked_from`,
|
||||
`has_issues`,
|
||||
`is_starred`,
|
||||
`is_archived`,
|
||||
`size`,
|
||||
`has_lfs`,
|
||||
`has_submodules`,
|
||||
`language`,
|
||||
`description`,
|
||||
`default_branch`,
|
||||
`visibility`,
|
||||
`status`,
|
||||
`last_mirrored`,
|
||||
`error_message`,
|
||||
`destination_org`,
|
||||
`metadata`,
|
||||
`imported_at`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`repositories`.`id`,
|
||||
`repositories`.`user_id`,
|
||||
`repositories`.`config_id`,
|
||||
`repositories`.`name`,
|
||||
`repositories`.`full_name`,
|
||||
`repositories`.`normalized_full_name`,
|
||||
`repositories`.`url`,
|
||||
`repositories`.`clone_url`,
|
||||
`repositories`.`owner`,
|
||||
`repositories`.`organization`,
|
||||
`repositories`.`mirrored_location`,
|
||||
`repositories`.`is_private`,
|
||||
`repositories`.`is_fork`,
|
||||
`repositories`.`forked_from`,
|
||||
`repositories`.`has_issues`,
|
||||
`repositories`.`is_starred`,
|
||||
`repositories`.`is_archived`,
|
||||
`repositories`.`size`,
|
||||
`repositories`.`has_lfs`,
|
||||
`repositories`.`has_submodules`,
|
||||
`repositories`.`language`,
|
||||
`repositories`.`description`,
|
||||
`repositories`.`default_branch`,
|
||||
`repositories`.`visibility`,
|
||||
`repositories`.`status`,
|
||||
`repositories`.`last_mirrored`,
|
||||
`repositories`.`error_message`,
|
||||
`repositories`.`destination_org`,
|
||||
`repositories`.`metadata`,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT MIN(`mj`.`timestamp`)
|
||||
FROM `mirror_jobs` `mj`
|
||||
WHERE `mj`.`user_id` = `repositories`.`user_id`
|
||||
AND `mj`.`status` = 'imported'
|
||||
AND (
|
||||
(`mj`.`repository_id` IS NOT NULL AND `mj`.`repository_id` = `repositories`.`id`)
|
||||
OR (
|
||||
`mj`.`repository_id` IS NULL
|
||||
AND `mj`.`repository_name` IS NOT NULL
|
||||
AND (
|
||||
lower(trim(`mj`.`repository_name`)) = `repositories`.`normalized_full_name`
|
||||
OR lower(trim(`mj`.`repository_name`)) = lower(trim(`repositories`.`name`))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
`repositories`.`created_at`,
|
||||
unixepoch()
|
||||
) AS `imported_at`,
|
||||
`repositories`.`created_at`,
|
||||
`repositories`.`updated_at`
|
||||
FROM `repositories`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `repositories`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__new_repositories` RENAME TO `repositories`;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_user_id` ON `repositories` (`user_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_config_id` ON `repositories` (`config_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_status` ON `repositories` (`status`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_owner` ON `repositories` (`owner`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_organization` ON `repositories` (`organization`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_is_fork` ON `repositories` (`is_fork`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_is_starred` ON `repositories` (`is_starred`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_repositories_user_imported_at` ON `repositories` (`user_id`,`imported_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_repositories_user_full_name` ON `repositories` (`user_id`,`full_name`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_repositories_user_normalized_full_name` ON `repositories` (`user_id`,`normalized_full_name`);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add index for mirroredLocation lookups (used by name collision detection)
|
||||
CREATE INDEX IF NOT EXISTS `idx_repositories_mirrored_location` ON `repositories` (`user_id`, `mirrored_location`);
|
||||
|
||||
-- Add unique partial index to enforce that no two repos for the same user
|
||||
-- can claim the same non-empty mirroredLocation. This prevents race conditions
|
||||
-- during concurrent batch mirroring of starred repos with duplicate names.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `uniq_repositories_user_mirrored_location`
|
||||
ON `repositories` (`user_id`, `mirrored_location`)
|
||||
WHERE `mirrored_location` != '';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Migrate the OAuth/OIDC *provider* feature from the deprecated
|
||||
-- `oidc-provider` plugin to `@better-auth/oauth-provider`.
|
||||
--
|
||||
-- Tables: oauth_applications -> oauth_clients, oauth_access_tokens reshaped,
|
||||
-- new oauth_refresh_tokens, oauth_consent -> oauth_consents, plus a `jwks`
|
||||
-- table for the `jwt` plugin (id_token signing keys).
|
||||
--
|
||||
-- Data preservation:
|
||||
-- * Registered clients are copied from oauth_applications into oauth_clients,
|
||||
-- converting the legacy comma-separated `redirect_urls` into the JSON
|
||||
-- string[] (`redirect_uris`) the new adapter expects.
|
||||
-- * Access tokens and consent records are NOT migrated: access tokens are
|
||||
-- short-lived (and the column shape changed entirely), and consents are
|
||||
-- cheaply re-granted on next authorization. Both old tables are dropped.
|
||||
--
|
||||
-- NOTE: legacy client secrets were stored in plaintext, whereas the new
|
||||
-- provider stores them hashed. Migrated client secrets will therefore not
|
||||
-- validate as-is — affected applications must rotate their secret after
|
||||
-- upgrade.
|
||||
|
||||
CREATE TABLE `oauth_clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`client_secret` text,
|
||||
`name` text,
|
||||
`disabled` integer DEFAULT false,
|
||||
`skip_consent` integer,
|
||||
`enable_end_session` integer,
|
||||
`subject_type` text,
|
||||
`scopes` text,
|
||||
`user_id` text,
|
||||
`uri` text,
|
||||
`icon` text,
|
||||
`contacts` text,
|
||||
`tos` text,
|
||||
`policy` text,
|
||||
`software_id` text,
|
||||
`software_version` text,
|
||||
`software_statement` text,
|
||||
`redirect_uris` text NOT NULL,
|
||||
`post_logout_redirect_uris` text,
|
||||
`token_endpoint_auth_method` text,
|
||||
`grant_types` text,
|
||||
`response_types` text,
|
||||
`public` integer,
|
||||
`type` text,
|
||||
`require_pkce` integer,
|
||||
`reference_id` text,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_clients_client_id_unique` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_client_id` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_user_id` ON `oauth_clients` (`user_id`);--> statement-breakpoint
|
||||
INSERT INTO `oauth_clients` (
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
`redirect_uris`, `type`, `metadata`, `created_at`, `updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
'["' || replace(`redirect_urls`, ',', '","') || '"]',
|
||||
`type`, `metadata`, `created_at`, `updated_at`
|
||||
FROM `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_access_tokens`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_access_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`refresh_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_access_tokens_token_unique` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_token` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `oauth_refresh_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text NOT NULL,
|
||||
`reference_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`revoked` integer,
|
||||
`auth_time` integer,
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_refresh_tokens_token_unique` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_token` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_client_id` ON `oauth_refresh_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_user_id` ON `oauth_refresh_tokens` (`user_id`);--> statement-breakpoint
|
||||
DROP TABLE `oauth_consent`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_consents` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`scopes` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_client_id` ON `oauth_consents` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_user_id` ON `oauth_consents` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `jwks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`public_key` text NOT NULL,
|
||||
`private_key` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`expires_at` integer
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_organizations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`config_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`normalized_name` text NOT NULL,
|
||||
`avatar_url` text NOT NULL,
|
||||
`membership_role` text DEFAULT 'member' NOT NULL,
|
||||
`is_included` integer DEFAULT true NOT NULL,
|
||||
`destination_org` text,
|
||||
`status` text DEFAULT 'imported' NOT NULL,
|
||||
`last_mirrored` integer,
|
||||
`error_message` text,
|
||||
`repository_count` integer DEFAULT 0 NOT NULL,
|
||||
`public_repository_count` integer,
|
||||
`private_repository_count` integer,
|
||||
`fork_repository_count` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_organizations`("id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at") SELECT "id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at" FROM `organizations`;--> statement-breakpoint
|
||||
DROP TABLE `organizations`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_organizations` RENAME TO `organizations`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_user_id` ON `organizations` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_config_id` ON `organizations` (`config_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_status` ON `organizations` (`status`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_is_included` ON `organizations` (`is_included`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `saml_config` text;--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `domain_verified` integer DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -71,6 +71,34 @@
|
||||
"when": 1773542995732,
|
||||
"tag": "0009_nervous_tyger_tiger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1774054800000,
|
||||
"tag": "0010_mirrored_location_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1774058400000,
|
||||
"tag": "0011_notification_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1774062000000,
|
||||
"tag": "0012_oauth_provider_migration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1780377747526,
|
||||
"tag": "0013_slim_galactus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
# Build the application
|
||||
gitea-mirror = pkgs.stdenv.mkDerivation {
|
||||
pname = "gitea-mirror";
|
||||
version = "3.9.6";
|
||||
version = "3.14.1";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
+15
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.13.0",
|
||||
"version": "3.19.1",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -31,9 +31,10 @@
|
||||
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
|
||||
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
|
||||
"preview": "bunx --bun astro preview",
|
||||
"start": "bun dist/server/entry.mjs",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
|
||||
"start": "bun scripts/runtime-server.ts",
|
||||
"start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts",
|
||||
"test": "bun test",
|
||||
"test:migrations": "bun scripts/validate-migrations.ts",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"test:e2e": "bash tests/e2e/run-e2e.sh",
|
||||
@@ -44,18 +45,21 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"devalue": "^5.6.4",
|
||||
"fast-xml-parser": "^5.5.5",
|
||||
"node-forge": "^1.3.3",
|
||||
"svgo": "^4.0.1",
|
||||
"rollup": ">=4.59.0"
|
||||
"@xmldom/xmldom": "^0.8.13",
|
||||
"devalue": "^5.8.1",
|
||||
"fast-uri": "^3.1.2",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"kysely": "^0.28.17",
|
||||
"lodash": "^4.18.1",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@better-auth/oauth-provider": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -84,14 +88,14 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"better-auth": "1.6.11",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
|
||||
@@ -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})`);
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
repairDuplicateSsoColumns,
|
||||
restoreSsoDataAfter0013,
|
||||
} from "../src/lib/db/migration-repairs";
|
||||
|
||||
type JournalEntry = {
|
||||
idx: number;
|
||||
tag: string;
|
||||
when: number;
|
||||
breakpoints: boolean;
|
||||
};
|
||||
|
||||
type Migration = {
|
||||
entry: JournalEntry;
|
||||
statements: string[];
|
||||
};
|
||||
|
||||
type UpgradeFixture = {
|
||||
seed: (db: Database) => void;
|
||||
verify: (db: Database) => void;
|
||||
};
|
||||
|
||||
type TableInfoRow = {
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
const migrationsFolder = path.join(process.cwd(), "drizzle");
|
||||
const migrations = loadMigrations();
|
||||
const latestMigration = migrations.at(-1);
|
||||
|
||||
/**
|
||||
* Known SQLite limitations that Drizzle-kit's auto-generated migrations
|
||||
* can violate. Each rule is checked against every SQL statement.
|
||||
*/
|
||||
const SQLITE_LINT_RULES: { pattern: RegExp; message: string }[] = [
|
||||
{
|
||||
pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s*\(/i,
|
||||
message:
|
||||
"ALTER TABLE ADD COLUMN with an expression default (e.g. DEFAULT (unixepoch())) " +
|
||||
"is not allowed in SQLite. Use the table-recreation pattern instead " +
|
||||
"(CREATE new table, INSERT SELECT, DROP old, RENAME).",
|
||||
},
|
||||
{
|
||||
pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s+CURRENT_(TIME|DATE|TIMESTAMP)\b/i,
|
||||
message:
|
||||
"ALTER TABLE ADD COLUMN with DEFAULT CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP " +
|
||||
"is not allowed in SQLite. Use the table-recreation pattern instead.",
|
||||
},
|
||||
];
|
||||
|
||||
function loadMigrations(): Migration[] {
|
||||
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
|
||||
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
||||
entries: JournalEntry[];
|
||||
};
|
||||
|
||||
return journal.entries.map((entry) => {
|
||||
const migrationPath = path.join(migrationsFolder, `${entry.tag}.sql`);
|
||||
const statements = readFileSync(migrationPath, "utf8")
|
||||
.split("--> statement-breakpoint")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return { entry, statements };
|
||||
});
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function runMigration(db: Database, migration: Migration) {
|
||||
db.run("BEGIN");
|
||||
|
||||
try {
|
||||
for (const statement of migration.statements) {
|
||||
db.run(statement);
|
||||
}
|
||||
|
||||
db.run("COMMIT");
|
||||
} catch (error) {
|
||||
try {
|
||||
db.run("ROLLBACK");
|
||||
} catch {
|
||||
// Ignore rollback errors so the original failure is preserved.
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrations(db: Database, selectedMigrations: Migration[]) {
|
||||
for (const migration of selectedMigrations) {
|
||||
runMigration(db, migration);
|
||||
}
|
||||
}
|
||||
|
||||
function seedPre0009Database(db: Database) {
|
||||
// Seed every existing table so ALTER TABLE paths run against non-empty data.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('c1', 'u1', 'Default', '{}', '{}', '{}', '{}')");
|
||||
db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, access_token, refresh_token, id_token, access_token_expires_at, refresh_token_expires_at, scope) VALUES ('acct1', 'acct-1', 'u1', 'github', 'access-token', 'refresh-token', 'id-token', 2000, 3000, 'repo')");
|
||||
db.run("INSERT INTO events (id, user_id, channel, payload) VALUES ('evt1', 'u1', 'sync', '{\"status\":\"queued\"}')");
|
||||
db.run("INSERT INTO mirror_jobs (id, user_id, repository_id, repository_name, status, message, timestamp) VALUES ('job1', 'u1', 'r1', 'owner/repo', 'imported', 'Imported repository', 900)");
|
||||
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, public_repository_count, private_repository_count, fork_repository_count) VALUES ('org1', 'u1', 'c1', 'Example Org', 'https://example.com/org.png', 1, 0, 0)");
|
||||
db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, organization, default_branch, created_at, updated_at, metadata) VALUES ('r1', 'u1', 'c1', 'repo', 'owner/repo', 'owner/repo', 'https://example.com/repo', 'https://example.com/repo.git', 'owner', 'Example Org', 'main', 1000, 1100, '{\"issues\":true}')");
|
||||
db.run("INSERT INTO sessions (id, token, user_id, expires_at) VALUES ('sess1', 'session-token', 'u1', 4000)");
|
||||
db.run("INSERT INTO verification_tokens (id, token, identifier, type, expires_at) VALUES ('vt1', 'verify-token', 'u1@example.com', 'email', 5000)");
|
||||
db.run("INSERT INTO verifications (id, identifier, value, expires_at) VALUES ('ver1', 'u1@example.com', '123456', 6000)");
|
||||
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', '[\"https://example.com/callback\"]', 'confidential', 'u1')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'oauth-access-token', 'oauth-refresh-token', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
|
||||
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('consent1', 'u1', 'client-1', '[\"repo\"]', true)");
|
||||
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso1', 'https://issuer.example.com', 'example.com', '{}', 'u1', 'provider-1')");
|
||||
db.run("INSERT INTO rate_limits (id, user_id, provider, `limit`, remaining, used, reset, retry_after, status, last_checked) VALUES ('rl1', 'u1', 'github', 5000, 4999, 1, 9000, NULL, 'ok', 8500)");
|
||||
}
|
||||
|
||||
function verify0009Migration(db: Database) {
|
||||
const repositoryColumns = db.query("PRAGMA table_info(repositories)").all() as TableInfoRow[];
|
||||
const importedAtColumn = repositoryColumns.find((column) => column.name === "imported_at");
|
||||
|
||||
assert(importedAtColumn, "Expected repositories.imported_at column to exist after migration");
|
||||
assert(importedAtColumn.notnull === 1, "Expected repositories.imported_at to be NOT NULL");
|
||||
assert(importedAtColumn.dflt_value === "unixepoch()", `Expected repositories.imported_at default to be unixepoch(), got ${importedAtColumn.dflt_value ?? "null"}`);
|
||||
|
||||
const existingRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r1'").get() as { imported_at: number } | null;
|
||||
assert(existingRepo?.imported_at === 900, `Expected existing repository imported_at to backfill from mirror_jobs timestamp 900, got ${existingRepo?.imported_at ?? "null"}`);
|
||||
|
||||
db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) VALUES ('r2', 'u1', 'c1', 'repo-two', 'owner/repo-two', 'owner/repo-two', 'https://example.com/repo-two', 'https://example.com/repo-two.git', 'owner', 'main')");
|
||||
const newRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r2'").get() as { imported_at: number } | null;
|
||||
assert(typeof newRepo?.imported_at === "number" && newRepo.imported_at > 0, "Expected new repository insert to receive imported_at from the column default");
|
||||
|
||||
const importedAtIndex = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'repositories' AND name = 'idx_repositories_user_imported_at'")
|
||||
.get() as { name: string } | null;
|
||||
assert(importedAtIndex?.name === "idx_repositories_user_imported_at", "Expected repositories imported_at index to exist after migration");
|
||||
}
|
||||
|
||||
function seedPre0010Database(db: any) {
|
||||
// Seed a repo row to verify index creation doesn't break existing data
|
||||
seedPre0009Database(db);
|
||||
}
|
||||
|
||||
function verify0010Migration(db: any) {
|
||||
const indexes = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'"
|
||||
).all();
|
||||
if (indexes.length === 0) {
|
||||
throw new Error("Missing unique partial index uniq_repositories_user_mirrored_location");
|
||||
}
|
||||
|
||||
const lookupIdx = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_repositories_mirrored_location'"
|
||||
).all();
|
||||
if (lookupIdx.length === 0) {
|
||||
throw new Error("Missing lookup index idx_repositories_mirrored_location");
|
||||
}
|
||||
}
|
||||
|
||||
function seedPre0011Database(db: any) {
|
||||
seedPre0009Database(db);
|
||||
runMigration(db, migrations.find((m) => m.entry.tag === "0009_nervous_tyger_tiger")!);
|
||||
runMigration(db, migrations.find((m) => m.entry.tag === "0010_mirrored_location_index")!);
|
||||
}
|
||||
|
||||
function verify0011Migration(db: any) {
|
||||
const configColumns = db.query("PRAGMA table_info(configs)").all() as TableInfoRow[];
|
||||
const notificationConfigColumn = configColumns.find((column: any) => column.name === "notification_config");
|
||||
|
||||
assert(notificationConfigColumn, "Expected configs.notification_config column to exist after migration");
|
||||
assert(notificationConfigColumn.notnull === 1, "Expected configs.notification_config to be NOT NULL");
|
||||
assert(
|
||||
notificationConfigColumn.dflt_value !== null,
|
||||
"Expected configs.notification_config to have a default value",
|
||||
);
|
||||
|
||||
const existingConfig = db.query("SELECT notification_config FROM configs WHERE id = 'c1'").get() as { notification_config: string } | null;
|
||||
assert(existingConfig, "Expected existing config row to still exist");
|
||||
const parsed = JSON.parse(existingConfig.notification_config);
|
||||
assert(parsed.enabled === false, "Expected default notification_config.enabled to be false");
|
||||
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
|
||||
}
|
||||
|
||||
function seedPre0012Database(db: any) {
|
||||
// The harness has already run migrations 0000-0011, so the legacy
|
||||
// oidc-provider tables exist. Seed a registered client (with the legacy
|
||||
// comma-separated redirect_urls format) plus the related token/consent rows
|
||||
// to exercise the create/transform/drop paths in 0012.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
|
||||
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, disabled, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', 'https://example.com/callback,https://example.com/cb2', 'web', false, 'u1')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'tok', 'rtok', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
|
||||
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('cons1', 'u1', 'client-1', '[\"repo\"]', true)");
|
||||
}
|
||||
|
||||
function verify0012Migration(db: any) {
|
||||
// Old provider tables are dropped.
|
||||
for (const table of ["oauth_applications", "oauth_consent"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(!row, `Expected ${table} table to be dropped after migration`);
|
||||
}
|
||||
|
||||
// New provider tables exist.
|
||||
for (const table of ["oauth_clients", "oauth_access_tokens", "oauth_refresh_tokens", "oauth_consents", "jwks"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(row, `Expected ${table} table to exist after migration`);
|
||||
}
|
||||
|
||||
// The registered client is preserved and its redirect URIs converted from
|
||||
// the legacy comma-separated string into a JSON string[].
|
||||
const client = db
|
||||
.query("SELECT client_id, client_secret, name, redirect_uris, type, user_id FROM oauth_clients WHERE id = 'app1'")
|
||||
.get() as { client_id: string; client_secret: string; name: string; redirect_uris: string; type: string; user_id: string } | null;
|
||||
assert(client, "Expected migrated oauth_clients row for app1");
|
||||
assert(client.client_id === "client-1", "Expected client_id to be preserved");
|
||||
assert(client.name === "Example App", "Expected client name to be preserved");
|
||||
assert(client.user_id === "u1", "Expected owner user_id to be preserved");
|
||||
|
||||
const uris = JSON.parse(client.redirect_uris);
|
||||
assert(
|
||||
Array.isArray(uris) && uris.length === 2 && uris[0] === "https://example.com/callback" && uris[1] === "https://example.com/cb2",
|
||||
`Expected redirect_uris to be a JSON array of the two callbacks, got ${client.redirect_uris}`,
|
||||
);
|
||||
|
||||
// The reshaped tables accept the new column layout.
|
||||
db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app2', 'client-2', '[\"https://example.com/cb\"]')");
|
||||
db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt1', 'refresh-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at1', 'access-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('jwk1', 'public', 'private')");
|
||||
}
|
||||
|
||||
function seedPre0013Database(db: any) {
|
||||
// Migrations 0000-0012 have run, so sso_providers lacks samlConfig /
|
||||
// domainVerified and the organizations table still carries the inherited
|
||||
// DEFAULT '' on normalized_name from 0007. Seed both so the table-rebuild
|
||||
// and the column-adds can be verified end-to-end.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-sso', 'sso@example.com', 'sso', 'SSO User')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre13', 'u-sso', 'Default', 1, '{}', '{}', '{}', '{}')");
|
||||
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-pre13', 'https://idp.example.com', 'example.com', '{\"clientId\":\"x\"}', 'u-sso', 'idp-pre13')");
|
||||
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre13', 'u-sso', 'cfg-pre13', 'Example', 'https://example.com/a.png', 'example')");
|
||||
}
|
||||
|
||||
function verify0013Migration(db: any) {
|
||||
// New columns on sso_providers.
|
||||
const ssoCols = db
|
||||
.query("PRAGMA table_info(sso_providers)")
|
||||
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
const saml = ssoCols.find((c) => c.name === "saml_config");
|
||||
const domainVerified = ssoCols.find((c) => c.name === "domain_verified");
|
||||
assert(saml, "Expected sso_providers.saml_config column to exist");
|
||||
assert(saml.notnull === 0, "Expected saml_config to be nullable");
|
||||
assert(domainVerified, "Expected sso_providers.domain_verified column to exist");
|
||||
assert(domainVerified.notnull === 1, "Expected domain_verified to be NOT NULL");
|
||||
assert(
|
||||
domainVerified.dflt_value === "true",
|
||||
`Expected domain_verified DEFAULT true, got ${domainVerified.dflt_value}`,
|
||||
);
|
||||
|
||||
// Pre-existing SSO row picked up the default (1 = true) on domain_verified.
|
||||
const ssoRow = db
|
||||
.query("SELECT provider_id, saml_config, domain_verified FROM sso_providers WHERE id = 'sso-pre13'")
|
||||
.get() as { provider_id: string; saml_config: string | null; domain_verified: number } | null;
|
||||
assert(ssoRow, "Expected pre-existing OIDC provider row to survive migration");
|
||||
assert(ssoRow.saml_config === null, `Expected saml_config NULL, got ${ssoRow.saml_config}`);
|
||||
assert(ssoRow.domain_verified === 1, `Expected domain_verified=1, got ${ssoRow.domain_verified}`);
|
||||
|
||||
// Organizations rebuild preserved the seeded row and dropped the inherited
|
||||
// DEFAULT '' on normalized_name (drizzle reconciles to schema.ts).
|
||||
const orgRow = db
|
||||
.query("SELECT id, normalized_name FROM organizations WHERE id = 'org-pre13'")
|
||||
.get() as { id: string; normalized_name: string } | null;
|
||||
assert(orgRow, "Expected pre-existing organization row to survive table rebuild");
|
||||
assert(orgRow.normalized_name === "example", `Expected organization normalized_name preserved, got ${orgRow.normalized_name}`);
|
||||
const orgCols = db
|
||||
.query("PRAGMA table_info(organizations)")
|
||||
.all() as Array<{ name: string; dflt_value: string | null }>;
|
||||
const normName = orgCols.find((c) => c.name === "normalized_name");
|
||||
assert(normName, "Expected organizations.normalized_name column to exist");
|
||||
assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`);
|
||||
}
|
||||
|
||||
const MIGRATION_0012_TIMESTAMP = 1774062000000;
|
||||
const MIGRATION_0013_TIMESTAMP = 1780377747526;
|
||||
|
||||
/**
|
||||
* Reproduce the issue #312 crash state — sso_providers already carries
|
||||
* saml_config / domain_verified before migration 0013 runs (stranded on an
|
||||
* intermediate build), with __drizzle_migrations recorded only through 0012 —
|
||||
* and verify repairDuplicateSsoColumns()/restoreSsoDataAfter0013() let the
|
||||
* canonical 0013 run while preserving real SAML provider data.
|
||||
*/
|
||||
function validateBroken0013Repair() {
|
||||
const migration0013 = migrations.find((m) => m.entry.tag === "0013_slim_galactus");
|
||||
if (!migration0013) return; // 0013 not present (shouldn't happen) — nothing to test.
|
||||
|
||||
const db = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(db, migrations.slice(0, 13)); // 0000-0012
|
||||
|
||||
// A real upgraded instance has a __drizzle_migrations table recorded
|
||||
// through 0012 but not 0013.
|
||||
db.run(
|
||||
"CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)",
|
||||
);
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0012', ?)", [
|
||||
MIGRATION_0012_TIMESTAMP,
|
||||
]);
|
||||
|
||||
// Stranded columns from the intermediate build.
|
||||
db.run("ALTER TABLE sso_providers ADD saml_config text");
|
||||
db.run("ALTER TABLE sso_providers ADD domain_verified integer DEFAULT true NOT NULL");
|
||||
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'u1', 'User One')");
|
||||
const samlJson = '{"entryPoint":"https://idp.example.com/sso","cert":"ABC123"}';
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('oidc1', 'https://idp', 'a.com', '{}', 'u1', 'p-oidc', NULL, 1)",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('saml1', 'https://idp', 'b.com', '{}', 'u1', 'p-saml', ?, 1)",
|
||||
[samlJson],
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('unv1', 'https://idp', 'c.com', '{}', 'u1', 'p-unv', NULL, 0)",
|
||||
);
|
||||
|
||||
const preserved = repairDuplicateSsoColumns(db);
|
||||
|
||||
const colsAfterRepair = (db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]).map(
|
||||
(c) => c.name,
|
||||
);
|
||||
assert(!colsAfterRepair.includes("saml_config"), "Expected repair to drop stranded saml_config column");
|
||||
assert(
|
||||
!colsAfterRepair.includes("domain_verified"),
|
||||
"Expected repair to drop stranded domain_verified column",
|
||||
);
|
||||
const preservedIds = preserved.map((r) => r.id).sort();
|
||||
assert(
|
||||
preservedIds.length === 2 && preservedIds[0] === "saml1" && preservedIds[1] === "unv1",
|
||||
`Expected SAML + unverified rows to be preserved, got ${JSON.stringify(preservedIds)}`,
|
||||
);
|
||||
|
||||
// The canonical 0013 must now run without a duplicate-column error.
|
||||
runMigration(db, migration0013);
|
||||
restoreSsoDataAfter0013(db, preserved);
|
||||
|
||||
const rows = db
|
||||
.query("SELECT id, saml_config, domain_verified FROM sso_providers ORDER BY id")
|
||||
.all() as Array<{ id: string; saml_config: string | null; domain_verified: number }>;
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||||
|
||||
assert(byId.oidc1.saml_config === null, "Expected OIDC provider saml_config to remain NULL");
|
||||
assert(byId.oidc1.domain_verified === 1, "Expected OIDC provider domain_verified default 1");
|
||||
assert(byId.saml1.saml_config === samlJson, "Expected SAML provider config to be preserved");
|
||||
assert(byId.saml1.domain_verified === 1, "Expected SAML provider domain_verified preserved as 1");
|
||||
assert(byId.unv1.saml_config === null, "Expected unverified provider saml_config NULL");
|
||||
assert(byId.unv1.domain_verified === 0, "Expected explicit domain_verified=0 to be preserved");
|
||||
|
||||
// Idempotency: 0013 is now applied, so a re-run of the repair is a no-op.
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0013', ?)", [
|
||||
MIGRATION_0013_TIMESTAMP,
|
||||
]);
|
||||
const secondPass = repairDuplicateSsoColumns(db);
|
||||
assert(secondPass.length === 0, "Expected repair to no-op once migration 0013 is recorded");
|
||||
const colsAfterSecondPass = (
|
||||
db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]
|
||||
).map((c) => c.name);
|
||||
assert(
|
||||
colsAfterSecondPass.includes("saml_config") && colsAfterSecondPass.includes("domain_verified"),
|
||||
"Expected columns to remain intact on the no-op second pass",
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
"0009_nervous_tyger_tiger": {
|
||||
seed: seedPre0009Database,
|
||||
verify: verify0009Migration,
|
||||
},
|
||||
"0010_mirrored_location_index": {
|
||||
seed: seedPre0010Database,
|
||||
verify: verify0010Migration,
|
||||
},
|
||||
"0011_notification_config": {
|
||||
seed: seedPre0011Database,
|
||||
verify: verify0011Migration,
|
||||
},
|
||||
"0012_oauth_provider_migration": {
|
||||
seed: seedPre0012Database,
|
||||
verify: verify0012Migration,
|
||||
},
|
||||
"0013_slim_galactus": {
|
||||
seed: seedPre0013Database,
|
||||
verify: verify0013Migration,
|
||||
},
|
||||
};
|
||||
|
||||
function lintMigrations(selectedMigrations: Migration[]) {
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const migration of selectedMigrations) {
|
||||
for (const statement of migration.statements) {
|
||||
for (const rule of SQLITE_LINT_RULES) {
|
||||
if (rule.pattern.test(statement)) {
|
||||
violations.push(`[${migration.entry.tag}] ${rule.message}\n Statement: ${statement.slice(0, 120)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert(
|
||||
violations.length === 0,
|
||||
`SQLite lint found ${violations.length} violation(s):\n\n${violations.join("\n\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateMigrations() {
|
||||
assert(latestMigration, "No migrations found in drizzle/meta/_journal.json");
|
||||
|
||||
// Lint all migrations for known SQLite pitfalls before running anything.
|
||||
lintMigrations(migrations);
|
||||
|
||||
const emptyDb = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(emptyDb, migrations);
|
||||
} finally {
|
||||
emptyDb.close();
|
||||
}
|
||||
|
||||
const upgradeFixture = latestUpgradeFixtures[latestMigration.entry.tag];
|
||||
assert(
|
||||
upgradeFixture,
|
||||
`Missing upgrade fixture for latest migration ${latestMigration.entry.tag}. Add one in scripts/validate-migrations.ts.`,
|
||||
);
|
||||
|
||||
const upgradeDb = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(upgradeDb, migrations.slice(0, -1));
|
||||
upgradeFixture.seed(upgradeDb);
|
||||
runMigration(upgradeDb, latestMigration);
|
||||
upgradeFixture.verify(upgradeDb);
|
||||
} finally {
|
||||
upgradeDb.close();
|
||||
}
|
||||
|
||||
// Exercise the runtime repair for the issue #312 duplicate-column crash.
|
||||
validateBroken0013Repair();
|
||||
|
||||
console.log(
|
||||
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}, plus the #312 SSO-column repair.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
validateMigrations();
|
||||
} catch (error) {
|
||||
console.error("Migration validation failed:");
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
@@ -21,7 +22,7 @@ export function NotFound() {
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild className="w-full">
|
||||
<a href="/">
|
||||
<a href={withBase("/")}>
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go to Dashboard
|
||||
</a>
|
||||
@@ -45,21 +46,21 @@ export function NotFound() {
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<a
|
||||
href="/repositories"
|
||||
href={withBase("/repositories")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Repositories</span>
|
||||
</a>
|
||||
<a
|
||||
href="/config"
|
||||
href={withBase("/config")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Config</span>
|
||||
</a>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
@@ -77,4 +78,4 @@ export function NotFound() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react';
|
||||
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter, StopCircle } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -36,6 +36,7 @@ import { toast } from 'sonner';
|
||||
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
||||
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
||||
import { useNavigation } from '@/components/layout/MainLayout';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -84,6 +85,8 @@ export function ActivityLog() {
|
||||
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||
const [showCleanupDialog, setShowCleanupDialog] = useState(false);
|
||||
const [showCancelPendingDialog, setShowCancelPendingDialog] = useState(false);
|
||||
const [isCancelPendingLoading, setIsCancelPendingLoading] = useState(false);
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -321,7 +324,7 @@ export function ActivityLog() {
|
||||
setIsInitialLoading(true);
|
||||
setShowCleanupDialog(false);
|
||||
|
||||
const response = await fetch('/api/activities/cleanup', {
|
||||
const response = await fetch(withBase('/api/activities/cleanup'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
@@ -353,6 +356,40 @@ export function ActivityLog() {
|
||||
setShowCleanupDialog(false);
|
||||
};
|
||||
|
||||
const confirmCancelPending = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
setIsCancelPendingLoading(true);
|
||||
setShowCancelPendingDialog(false);
|
||||
|
||||
const response = await fetch(withBase('/api/job/cancel-pending'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error occurred' }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message);
|
||||
// Refresh to show the new activity log entry
|
||||
await fetchActivities(false);
|
||||
} else {
|
||||
showErrorToast(res.error || 'Failed to cancel pending mirrors.', toast);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling pending mirrors:', error);
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsCancelPendingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = !!(filter.status || filter.type || filter.name);
|
||||
const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length;
|
||||
@@ -551,11 +588,22 @@ export function ActivityLog() {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowCancelPendingDialog(true)}
|
||||
title="Stop pending mirrors"
|
||||
className="text-amber-600 hover:text-amber-600 h-10 w-10 shrink-0"
|
||||
disabled={isCancelPendingLoading}
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Delete all activities"
|
||||
title="Clear activity history"
|
||||
className="text-destructive hover:text-destructive h-10 w-10 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -682,12 +730,24 @@ export function ActivityLog() {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* cleanup all activities */}
|
||||
{/* stop pending mirrors */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowCancelPendingDialog(true)}
|
||||
title="Stop pending mirrors"
|
||||
className="text-amber-600 hover:text-amber-600 h-10 w-10"
|
||||
disabled={isCancelPendingLoading}
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* clear activity history */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCleanupClick}
|
||||
title="Delete all activities"
|
||||
title="Clear activity history"
|
||||
className="text-destructive hover:text-destructive h-10 w-10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -708,9 +768,12 @@ export function ActivityLog() {
|
||||
<Dialog open={showCleanupDialog} onOpenChange={setShowCleanupDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All Activities</DialogTitle>
|
||||
<DialogTitle>Clear Activity History</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete ALL activities? This action cannot be undone and will remove all mirror jobs and events from the database.
|
||||
This clears the activity <strong>history log</strong> (mirror job records and events) — it does not stop
|
||||
any pending or in-progress mirrors. Repositories keep their current status and the scheduler
|
||||
will continue to pick up pending work. To stop pending mirrors, use the
|
||||
“Stop Pending Mirrors” button instead.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -722,7 +785,35 @@ export function ActivityLog() {
|
||||
onClick={confirmCleanup}
|
||||
disabled={isInitialLoading}
|
||||
>
|
||||
{isInitialLoading ? 'Deleting...' : 'Delete All Activities'}
|
||||
{isInitialLoading ? 'Clearing...' : 'Clear History'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* cancel pending mirrors confirmation dialog */}
|
||||
<Dialog open={showCancelPendingDialog} onOpenChange={setShowCancelPendingDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stop Pending Mirrors</DialogTitle>
|
||||
<DialogDescription>
|
||||
This sets all repositories with status <strong>Imported</strong> or <strong>Failed</strong> to{' '}
|
||||
<strong>Ignored</strong>, preventing the scheduler from mirroring them automatically.
|
||||
Repositories that are currently mirroring are not affected.{' '}
|
||||
You can re-enable individual repositories from the Repositories page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCancelPendingDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
onClick={confirmCancelPending}
|
||||
disabled={isCancelPendingLoading}
|
||||
>
|
||||
{isCancelPendingLoading ? 'Stopping...' : 'Stop Pending Mirrors'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { KeyRound, LogOut, Mail } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChangePasswordDialog } from "./ChangePasswordDialog";
|
||||
import { ChangeEmailDialog } from "./ChangeEmailDialog";
|
||||
|
||||
export function AccountMenu() {
|
||||
const { user, logout, refreshUser } = useAuth();
|
||||
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setHasPassword(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const accounts = await authClient.listAccounts();
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(accounts) ? accounts : accounts?.data;
|
||||
setHasPassword(
|
||||
Array.isArray(list) && list.some((a) => a.providerId === "credential")
|
||||
);
|
||||
} catch {
|
||||
// Fail open: if we can't check, show the option rather than locking the
|
||||
// user out of changing their password.
|
||||
if (!cancelled) setHasPassword(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={withBase("/login")}>Login</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
toast.success("Logged out successfully");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="relative h-10 w-10 rounded-full p-0"
|
||||
>
|
||||
<Avatar className="h-full w-full">
|
||||
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
|
||||
<AvatarFallback>
|
||||
{(user.name || user.email || "U").charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{user.name && (
|
||||
<span className="text-sm font-medium leading-none">
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs leading-none text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{hasPassword && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPasswordOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<KeyRound className="h-4 w-4 mr-2" />
|
||||
Change password
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setEmailOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Change email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleLogout} className="cursor-pointer">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasPassword && (
|
||||
<ChangePasswordDialog
|
||||
open={passwordOpen}
|
||||
onOpenChange={setPasswordOpen}
|
||||
/>
|
||||
)}
|
||||
<ChangeEmailDialog
|
||||
open={emailOpen}
|
||||
onOpenChange={setEmailOpen}
|
||||
currentEmail={user.email}
|
||||
onUpdated={refreshUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ChangeEmailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentEmail: string;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
export function ChangeEmailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentEmail,
|
||||
onUpdated,
|
||||
}: ChangeEmailDialogProps) {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setNewEmail("");
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newEmail.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Please enter a new email");
|
||||
return;
|
||||
}
|
||||
if (trimmed.toLowerCase() === currentEmail.toLowerCase()) {
|
||||
toast.error("New email must differ from current email");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await authClient.changeEmail({ newEmail: trimmed });
|
||||
if (error) {
|
||||
toast.error(error.message || "Failed to change email");
|
||||
return;
|
||||
}
|
||||
toast.success("Email updated.");
|
||||
onUpdated?.();
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to change email");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Current: <span className="font-medium">{currentEmail}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-email">New email</Label>
|
||||
<Input
|
||||
id="new-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Update email"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ChangePasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialogProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [revokeOtherSessions, setRevokeOtherSessions] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setRevokeOtherSessions(true);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
toast.error("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword === currentPassword) {
|
||||
toast.error("New password must differ from current password");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message || "Failed to change password");
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
revokeOtherSessions
|
||||
? "Password updated. Other sessions signed out."
|
||||
: "Password updated."
|
||||
);
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to change password");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your current password and a new one. You'll stay signed in on this device.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="revoke-sessions"
|
||||
checked={revokeOtherSessions}
|
||||
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label htmlFor="revoke-sessions" className="text-sm font-normal cursor-pointer">
|
||||
Sign out other devices
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Update password"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
|
||||
export function LoginForm() {
|
||||
@@ -47,7 +48,7 @@ export function LoginForm() {
|
||||
toast.success('Login successful!');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
window.location.href = withBase('/');
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
@@ -64,12 +65,15 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321';
|
||||
const callbackURL =
|
||||
typeof window !== 'undefined'
|
||||
? new URL(withBase('/'), window.location.origin).toString()
|
||||
: `http://localhost:4321${withBase('/')}`;
|
||||
await authClient.signIn.sso({
|
||||
email: ssoEmail || undefined,
|
||||
domain: domain,
|
||||
providerId: providerId,
|
||||
callbackURL: `${baseURL}/`,
|
||||
callbackURL,
|
||||
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -85,7 +89,7 @@ export function LoginForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast, Toaster } from 'sonner';
|
||||
import { showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
export function SignupForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -42,7 +43,7 @@ export function SignupForm() {
|
||||
toast.success('Account created successfully! Redirecting to dashboard...');
|
||||
// Small delay before redirecting to see the success message
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
window.location.href = withBase('/');
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
@@ -57,7 +58,7 @@ export function SignupForm() {
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-8 w-10"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Zap,
|
||||
Info,
|
||||
Archive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -28,6 +30,10 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
} from "@/lib/utils/schedule-utils";
|
||||
|
||||
interface AutomationSettingsProps {
|
||||
scheduleConfig: ScheduleConfig;
|
||||
@@ -38,15 +44,13 @@ interface AutomationSettingsProps {
|
||||
isAutoSavingCleanup?: boolean;
|
||||
}
|
||||
|
||||
const scheduleIntervals = [
|
||||
{ label: "Every hour", value: 3600 },
|
||||
{ label: "Every 2 hours", value: 7200 },
|
||||
{ label: "Every 4 hours", value: 14400 },
|
||||
{ label: "Every 8 hours", value: 28800 },
|
||||
{ label: "Every 12 hours", value: 43200 },
|
||||
{ label: "Daily", value: 86400 },
|
||||
{ label: "Every 2 days", value: 172800 },
|
||||
{ label: "Weekly", value: 604800 },
|
||||
const clockFrequencies = [
|
||||
{ label: "Every hour", value: 1 },
|
||||
{ label: "Every 2 hours", value: 2 },
|
||||
{ label: "Every 4 hours", value: 4 },
|
||||
{ label: "Every 8 hours", value: 8 },
|
||||
{ label: "Every 12 hours", value: 12 },
|
||||
{ label: "Daily", value: 24 },
|
||||
];
|
||||
|
||||
const retentionPeriods = [
|
||||
@@ -85,6 +89,27 @@ export function AutomationSettings({
|
||||
isAutoSavingSchedule,
|
||||
isAutoSavingCleanup,
|
||||
}: AutomationSettingsProps) {
|
||||
const browserTimezone =
|
||||
typeof Intl !== "undefined"
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
: "UTC";
|
||||
|
||||
// Use saved timezone, but treat "UTC" as unset for users who never chose it
|
||||
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
|
||||
|
||||
const nextScheduledRun = useMemo(() => {
|
||||
if (!scheduleConfig.enabled) return null;
|
||||
const startTime = scheduleConfig.startTime || "22:00";
|
||||
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
|
||||
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
|
||||
if (!cronExpression) return null;
|
||||
try {
|
||||
return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
|
||||
|
||||
// Update nextRun for cleanup when settings change
|
||||
useEffect(() => {
|
||||
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
|
||||
@@ -125,7 +150,7 @@ export function AutomationSettings({
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Syncing Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-primary" />
|
||||
@@ -136,14 +161,21 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror"
|
||||
checked={scheduleConfig.enabled}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
enabled: !!checked,
|
||||
timezone: checked ? browserTimezone : scheduleConfig.timezone,
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
|
||||
scheduleMode: "clock",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
@@ -154,79 +186,148 @@ export function AutomationSettings({
|
||||
Enable automatic repository syncing
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Periodically check GitHub for changes and mirror them to Gitea
|
||||
Periodically sync GitHub changes to Gitea
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleConfig.enabled && (
|
||||
<div className="ml-6 space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="mirror-interval" className="text-sm">
|
||||
Sync frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={scheduleConfig.interval.toString()}
|
||||
onValueChange={(value) =>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Schedule
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
{effectiveTimezone}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-frequency"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={String(scheduleConfig.clockFrequencyHours || 24)}
|
||||
onValueChange={(value) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: parseInt(value, 10),
|
||||
startTime: scheduleConfig.startTime || "22:00",
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="clock-frequency" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clockFrequencies.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="clock-start-time"
|
||||
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Start time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
|
||||
<Clock className="size-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="clock-start-time"
|
||||
type="time"
|
||||
value={scheduleConfig.startTime || "22:00"}
|
||||
onChange={(event) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
scheduleMode: "clock",
|
||||
startTime: event.target.value,
|
||||
clockFrequencyHours:
|
||||
scheduleConfig.clockFrequencyHours || 24,
|
||||
timezone: effectiveTimezone,
|
||||
})
|
||||
}
|
||||
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-1">
|
||||
<Checkbox
|
||||
id="enable-auto-mirror-new"
|
||||
checked={scheduleConfig.autoMirror ?? false}
|
||||
className="mt-1.25"
|
||||
onCheckedChange={(checked) =>
|
||||
onScheduleChange({
|
||||
...scheduleConfig,
|
||||
interval: parseInt(value, 10),
|
||||
autoMirror: !!checked,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="mirror-interval" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scheduleIntervals.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value.toString()}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="enable-auto-mirror-new"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Auto-mirror new repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically mirror newly imported repositories on each scheduled sync. When off, new repos are imported for browsing but require a manual mirror click. (Starred repos have their own toggle in GitHub settings.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync
|
||||
</span>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last sync{" "}
|
||||
<span className="font-medium">
|
||||
{scheduleConfig.lastRun
|
||||
? formatDate(scheduleConfig.lastRun)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
{scheduleConfig.enabled ? (
|
||||
scheduleConfig.nextRun && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(scheduleConfig.nextRun)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Next sync{" "}
|
||||
<span className="font-medium text-primary">
|
||||
{scheduleConfig.nextRun
|
||||
? formatDate(scheduleConfig.nextRun)
|
||||
: nextScheduledRun
|
||||
? formatDate(nextScheduledRun)
|
||||
: "Calculating..."}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enable automatic syncing to schedule periodic repository updates
|
||||
</div>
|
||||
<span>Enable syncing to schedule updates</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Cleanup Section */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
@@ -237,7 +338,7 @@ export function AutomationSettings({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
@@ -255,13 +356,13 @@ export function AutomationSettings({
|
||||
Enable automatic database cleanup
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove old activity logs and events to optimize storage
|
||||
Remove old activity logs to optimize storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cleanupConfig.enabled && (
|
||||
<div className="ml-6 space-y-5">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
|
||||
Data retention period
|
||||
@@ -312,7 +413,7 @@ export function AutomationSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
|
||||
<div className="mt-auto space-y-2 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GitHubConfigForm } from './GitHubConfigForm';
|
||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { AutomationSettings } from './AutomationSettings';
|
||||
import { SSOSettings } from './SSOSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
import type {
|
||||
ConfigApiResponse,
|
||||
GiteaConfig,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
DatabaseCleanupConfig,
|
||||
MirrorOptions,
|
||||
AdvancedOptions,
|
||||
NotificationConfig,
|
||||
} from '@/types/config';
|
||||
import { Button } from '../ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -22,6 +24,7 @@ import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
type ConfigState = {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -30,8 +33,11 @@ type ConfigState = {
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
mirrorOptions: MirrorOptions;
|
||||
advancedOptions: AdvancedOptions;
|
||||
notificationConfig: NotificationConfig;
|
||||
};
|
||||
|
||||
const CONFIG_API_PATH = withBase('/api/config');
|
||||
|
||||
export function ConfigTabs() {
|
||||
const [config, setConfig] = useState<ConfigState>({
|
||||
githubConfig: {
|
||||
@@ -39,6 +45,7 @@ export function ConfigTabs() {
|
||||
token: '',
|
||||
privateRepositories: false,
|
||||
mirrorStarred: false,
|
||||
starredLists: [],
|
||||
},
|
||||
giteaConfig: {
|
||||
url: '',
|
||||
@@ -51,7 +58,8 @@ export function ConfigTabs() {
|
||||
starredReposMode: 'dedicated-org',
|
||||
preserveOrgStructure: false,
|
||||
backupStrategy: "on-force-push",
|
||||
backupRetentionCount: 20,
|
||||
backupRetentionCount: 5,
|
||||
backupRetentionDays: 30,
|
||||
backupDirectory: 'data/repo-backups',
|
||||
blockSyncOnBackupFailure: true,
|
||||
},
|
||||
@@ -85,6 +93,13 @@ export function ConfigTabs() {
|
||||
starredCodeOnly: false,
|
||||
autoMirrorStarred: false,
|
||||
},
|
||||
notificationConfig: {
|
||||
enabled: false,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: false,
|
||||
},
|
||||
});
|
||||
const { user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -94,10 +109,12 @@ export function ConfigTabs() {
|
||||
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
|
||||
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
|
||||
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
|
||||
const [isAutoSavingNotification, setIsAutoSavingNotification] = useState<boolean>(false);
|
||||
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSaveNotificationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isConfigFormValid = (): boolean => {
|
||||
const { githubConfig, giteaConfig } = config;
|
||||
@@ -184,7 +201,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -250,7 +267,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -315,7 +332,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -364,7 +381,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -404,7 +421,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -439,7 +456,7 @@ export function ConfigTabs() {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
@@ -459,6 +476,55 @@ export function ConfigTabs() {
|
||||
}
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
|
||||
|
||||
// Auto-save function for notification config changes
|
||||
const autoSaveNotificationConfig = useCallback(async (notifConfig: NotificationConfig) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSaveNotificationTimeoutRef.current) {
|
||||
clearTimeout(autoSaveNotificationTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the auto-save to prevent excessive API calls
|
||||
autoSaveNotificationTimeoutRef.current = setTimeout(async () => {
|
||||
setIsAutoSavingNotification(true);
|
||||
|
||||
const reqPayload = {
|
||||
userId: user.id!,
|
||||
githubConfig: config.githubConfig,
|
||||
giteaConfig: config.giteaConfig,
|
||||
scheduleConfig: config.scheduleConfig,
|
||||
cleanupConfig: config.cleanupConfig,
|
||||
mirrorOptions: config.mirrorOptions,
|
||||
advancedOptions: config.advancedOptions,
|
||||
notificationConfig: notifConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(CONFIG_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqPayload),
|
||||
});
|
||||
const result: SaveConfigApiResponse = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Silent success - no toast for auto-save
|
||||
invalidateConfigCache();
|
||||
} else {
|
||||
showErrorToast(
|
||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||
toast
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsAutoSavingNotification(false);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions, config.advancedOptions]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -474,6 +540,9 @@ export function ConfigTabs() {
|
||||
if (autoSaveGiteaTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveNotificationTimeoutRef.current) {
|
||||
clearTimeout(autoSaveNotificationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -505,6 +574,8 @@ export function ConfigTabs() {
|
||||
},
|
||||
advancedOptions:
|
||||
response.advancedOptions || config.advancedOptions,
|
||||
notificationConfig:
|
||||
(response as any).notificationConfig || config.notificationConfig,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -634,9 +705,10 @@ export function ConfigTabs() {
|
||||
|
||||
{/* Content section - Tabs layout */}
|
||||
<Tabs defaultValue="connections" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="connections">Connections</TabsTrigger>
|
||||
<TabsTrigger value="automation">Automation</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
||||
<TabsTrigger value="sso">Authentication</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -724,6 +796,17 @@ export function ConfigTabs() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" className="space-y-4">
|
||||
<NotificationSettings
|
||||
notificationConfig={config.notificationConfig}
|
||||
onNotificationChange={(newConfig) => {
|
||||
setConfig(prev => ({ ...prev, notificationConfig: newConfig }));
|
||||
autoSaveNotificationConfig(newConfig);
|
||||
}}
|
||||
isAutoSaving={isAutoSavingNotification}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sso" className="space-y-4">
|
||||
<SSOSettings />
|
||||
</TabsContent>
|
||||
|
||||
@@ -234,7 +234,7 @@ export function GitHubConfigForm({
|
||||
{
|
||||
value: "always",
|
||||
label: "Always Backup",
|
||||
desc: "Snapshot before every sync",
|
||||
desc: "Snapshot before every sync (high disk usage)",
|
||||
},
|
||||
{
|
||||
value: "on-force-push",
|
||||
@@ -272,7 +272,7 @@ export function GitHubConfigForm({
|
||||
|
||||
{(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="backup-retention" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot retention count
|
||||
@@ -282,11 +282,11 @@ export function GitHubConfigForm({
|
||||
name="backupRetentionCount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={giteaConfig.backupRetentionCount ?? 20}
|
||||
value={giteaConfig.backupRetentionCount ?? 5}
|
||||
onChange={(e) => {
|
||||
const newConfig = {
|
||||
...giteaConfig,
|
||||
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 20),
|
||||
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
|
||||
};
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
@@ -294,6 +294,28 @@ export function GitHubConfigForm({
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="backup-retention-days" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot retention days
|
||||
</label>
|
||||
<input
|
||||
id="backup-retention-days"
|
||||
name="backupRetentionDays"
|
||||
type="number"
|
||||
min={0}
|
||||
value={giteaConfig.backupRetentionDays ?? 30}
|
||||
onChange={(e) => {
|
||||
const newConfig = {
|
||||
...giteaConfig,
|
||||
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
|
||||
};
|
||||
setGiteaConfig(newConfig);
|
||||
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">0 = no time-based limit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="backup-directory" className="block text-sm font-medium mb-1.5">
|
||||
Snapshot directory
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
Check,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
@@ -31,7 +33,10 @@ import {
|
||||
ChevronDown,
|
||||
Funnel,
|
||||
HardDrive,
|
||||
FileCode2
|
||||
FileCode2,
|
||||
Plus,
|
||||
Users,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
|
||||
import {
|
||||
@@ -41,7 +46,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { githubApi } from "@/lib/api";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -60,8 +74,42 @@ export function GitHubMirrorSettings({
|
||||
onMirrorOptionsChange,
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
|
||||
const [starListsOpen, setStarListsOpen] = React.useState(false);
|
||||
const [starListSearch, setStarListSearch] = React.useState("");
|
||||
const [customStarListName, setCustomStarListName] = React.useState("");
|
||||
const [availableStarLists, setAvailableStarLists] = React.useState<string[]>([]);
|
||||
const [loadingStarLists, setLoadingStarLists] = React.useState(false);
|
||||
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
|
||||
const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false);
|
||||
|
||||
const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const list of lists) {
|
||||
const trimmed = list.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}, []);
|
||||
|
||||
const selectedStarLists = React.useMemo(
|
||||
() => normalizeStarListNames(githubConfig.starredLists),
|
||||
[githubConfig.starredLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const allKnownStarLists = React.useMemo(
|
||||
() => normalizeStarListNames([...availableStarLists, ...selectedStarLists]),
|
||||
[availableStarLists, selectedStarLists, normalizeStarListNames],
|
||||
);
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string | string[]) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
@@ -83,6 +131,59 @@ export function GitHubMirrorSettings({
|
||||
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
|
||||
};
|
||||
|
||||
const setSelectedStarLists = React.useCallback((lists: string[]) => {
|
||||
onGitHubConfigChange({
|
||||
...githubConfig,
|
||||
starredLists: normalizeStarListNames(lists),
|
||||
});
|
||||
}, [githubConfig, normalizeStarListNames, onGitHubConfigChange]);
|
||||
|
||||
const loadStarLists = React.useCallback(async () => {
|
||||
if (
|
||||
loadingStarLists ||
|
||||
loadedStarLists ||
|
||||
attemptedStarListLoad ||
|
||||
!githubConfig.mirrorStarred
|
||||
) return;
|
||||
|
||||
setAttemptedStarListLoad(true);
|
||||
setLoadingStarLists(true);
|
||||
try {
|
||||
const response = await githubApi.getStarredLists();
|
||||
setAvailableStarLists(normalizeStarListNames(response.lists));
|
||||
setLoadedStarLists(true);
|
||||
} catch {
|
||||
// Keep UX usable with manual custom input even if list fetch fails.
|
||||
// Allow retry on next popover open.
|
||||
setLoadedStarLists(false);
|
||||
} finally {
|
||||
setLoadingStarLists(false);
|
||||
}
|
||||
}, [
|
||||
attemptedStarListLoad,
|
||||
githubConfig.mirrorStarred,
|
||||
loadedStarLists,
|
||||
loadingStarLists,
|
||||
normalizeStarListNames,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen || !githubConfig.mirrorStarred) return;
|
||||
void loadStarLists();
|
||||
}, [starListsOpen, githubConfig.mirrorStarred, loadStarLists]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!githubConfig.mirrorStarred) {
|
||||
setStarListsOpen(false);
|
||||
}
|
||||
}, [githubConfig.mirrorStarred]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!starListsOpen) {
|
||||
setAttemptedStarListLoad(false);
|
||||
}
|
||||
}, [starListsOpen]);
|
||||
|
||||
// When metadata is disabled, all components should be disabled
|
||||
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
|
||||
|
||||
@@ -98,6 +199,17 @@ export function GitHubMirrorSettings({
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
const totalStarredOptions = 4; // releases, issues, PRs, wiki
|
||||
|
||||
const normalizedStarListSearch = starListSearch.trim();
|
||||
const canAddSearchAsStarList = normalizedStarListSearch.length > 0
|
||||
&& !allKnownStarLists.some((list) => list.toLowerCase() === normalizedStarListSearch.toLowerCase());
|
||||
|
||||
const addCustomStarList = () => {
|
||||
const trimmed = customStarListName.trim();
|
||||
if (!trimmed) return;
|
||||
setSelectedStarLists([...selectedStarLists, trimmed]);
|
||||
setCustomStarListName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
@@ -133,6 +245,26 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="collaborator-repos"
|
||||
checked={githubConfig.includeCollaboratorRepos ?? true}
|
||||
onCheckedChange={(checked) => handleGitHubChange('includeCollaboratorRepos', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="collaborator-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Include collaborator repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Also mirror repos where you're a collaborator but not the owner. Turn off to limit imports to repos you own.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
@@ -312,6 +444,143 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star list selection */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Star Lists (optional)
|
||||
</Label>
|
||||
<Popover open={starListsOpen} onOpenChange={setStarListsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={starListsOpen}
|
||||
className="w-full justify-between h-9 text-xs font-normal"
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedStarLists.length === 0
|
||||
? "All starred repositories"
|
||||
: `${selectedStarLists.length} list${selectedStarLists.length === 1 ? "" : "s"} selected`}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[360px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
value={starListSearch}
|
||||
onValueChange={setStarListSearch}
|
||||
placeholder="Search GitHub star lists..."
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loadingStarLists ? "Loading star lists..." : "No matching lists"}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allKnownStarLists.map((list) => {
|
||||
const isSelected = selectedStarLists.some(
|
||||
(selected) => selected.toLowerCase() === list.toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={list}
|
||||
value={list}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedStarLists([...selectedStarLists, list]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{list}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
{canAddSearchAsStarList && (
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => {
|
||||
setSelectedStarLists([...selectedStarLists, normalizedStarListSearch]);
|
||||
setStarListSearch("");
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add "{normalizedStarListSearch}"
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to mirror all starred repositories. Select one or more lists to limit syncing.
|
||||
</p>
|
||||
|
||||
{selectedStarLists.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedStarLists.map((list) => (
|
||||
<Badge key={list} variant="secondary" className="gap-1">
|
||||
<span>{list}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="rounded-sm hover:text-foreground/80"
|
||||
aria-label={`Remove ${list} list`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={customStarListName}
|
||||
onChange={(event) => setCustomStarListName(event.target.value)}
|
||||
placeholder="Add custom list name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addCustomStarList}
|
||||
disabled={!customStarListName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
@@ -658,6 +927,26 @@ export function GitHubMirrorSettings({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="skip-personal-repos"
|
||||
checked={advancedOptions.skipPersonalRepos ?? false}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('skipPersonalRepos', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="skip-personal-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Skip personal repositories (only mirror organization repos)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Exclude repositories owned by your personal GitHub account; only mirror repos belonging to organizations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { giteaApi } from "@/lib/api";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { giteaApi, type GiteaServerInfo } from "@/lib/api";
|
||||
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
|
||||
import { toast } from "sonner";
|
||||
import { OrganizationStrategy } from "./OrganizationStrategy";
|
||||
@@ -23,6 +25,7 @@ interface GiteaConfigFormProps {
|
||||
|
||||
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState<GiteaServerInfo | null>(null);
|
||||
|
||||
// Derive the mirror strategy from existing config for backward compatibility
|
||||
const getMirrorStrategy = (): MirrorStrategy => {
|
||||
@@ -128,13 +131,16 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
try {
|
||||
const result = await giteaApi.testConnection(config.url, config.token);
|
||||
if (result.success) {
|
||||
setServerInfo(result.serverInfo ?? null);
|
||||
toast.success("Successfully connected to Gitea!");
|
||||
} else {
|
||||
setServerInfo(null);
|
||||
toast.error(
|
||||
"Failed to connect to Gitea. Please check your URL and token."
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setServerInfo(null);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "An unknown error occurred"
|
||||
);
|
||||
@@ -162,6 +168,31 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
Forgejo {serverInfo.version} has a known mirror-credential bug
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
|
||||
<a
|
||||
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
PR #11909
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
<p>
|
||||
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos — or open each repo's Settings → Mirror Settings in Forgejo and re-enter the GitHub token once.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-username"
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bell, Activity, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
notificationConfig: NotificationConfig;
|
||||
onNotificationChange: (config: NotificationConfig) => void;
|
||||
isAutoSaving?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationSettings({
|
||||
notificationConfig,
|
||||
onNotificationChange,
|
||||
isAutoSaving,
|
||||
}: NotificationSettingsProps) {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const resp = await fetch(withBase("/api/notifications/test"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ notificationConfig }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (result.success) {
|
||||
toast.success("Test notification sent successfully!");
|
||||
} else {
|
||||
toast.error(`Test failed: ${result.error || "Unknown error"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Test failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
{isAutoSaving && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Enable/disable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifications-enabled" className="text-sm font-medium cursor-pointer">
|
||||
Enable notifications
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Receive alerts when mirror jobs complete or fail
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifications-enabled"
|
||||
checked={notificationConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notificationConfig.enabled && (
|
||||
<>
|
||||
{/* Provider selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notification-provider" className="text-sm font-medium">
|
||||
Notification provider
|
||||
</Label>
|
||||
<Select
|
||||
value={notificationConfig.provider}
|
||||
onValueChange={(value: "ntfy" | "apprise") =>
|
||||
onNotificationChange({ ...notificationConfig, provider: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="notification-provider">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
|
||||
<SelectItem value="apprise">Apprise API</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Ntfy configuration */}
|
||||
{notificationConfig.provider === "ntfy" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Ntfy.sh Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-url" className="text-sm">
|
||||
Server URL
|
||||
</Label>
|
||||
<Input
|
||||
id="ntfy-url"
|
||||
type="url"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={notificationConfig.ntfy?.url || "https://ntfy.sh"}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: e.target.value,
|
||||
topic: notificationConfig.ntfy?.topic || "",
|
||||
priority: notificationConfig.ntfy?.priority || "default",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use https://ntfy.sh for the public server or your self-hosted instance URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-topic" className="text-sm">
|
||||
Topic <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="ntfy-topic"
|
||||
placeholder="gitea-mirror"
|
||||
value={notificationConfig.ntfy?.topic || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
|
||||
topic: e.target.value,
|
||||
priority: notificationConfig.ntfy?.priority || "default",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a unique topic name. Anyone with the topic name can subscribe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-token" className="text-sm">
|
||||
Access token (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="ntfy-token"
|
||||
type="password"
|
||||
placeholder="tk_..."
|
||||
value={notificationConfig.ntfy?.token || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
|
||||
topic: notificationConfig.ntfy?.topic || "",
|
||||
token: e.target.value,
|
||||
priority: notificationConfig.ntfy?.priority || "default",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required if your ntfy server uses authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-priority" className="text-sm">
|
||||
Default priority
|
||||
</Label>
|
||||
<Select
|
||||
value={notificationConfig.ntfy?.priority || "default"}
|
||||
onValueChange={(value: "min" | "low" | "default" | "high" | "urgent") =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
ntfy: {
|
||||
...notificationConfig.ntfy!,
|
||||
url: notificationConfig.ntfy?.url || "https://ntfy.sh",
|
||||
topic: notificationConfig.ntfy?.topic || "",
|
||||
priority: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="ntfy-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="min">Min</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error notifications always use "high" priority regardless of this setting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apprise configuration */}
|
||||
{notificationConfig.provider === "apprise" && (
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Apprise API Settings</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-url" className="text-sm">
|
||||
Server URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="apprise-url"
|
||||
type="url"
|
||||
placeholder="http://apprise:8000"
|
||||
value={notificationConfig.apprise?.url || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
apprise: {
|
||||
...notificationConfig.apprise!,
|
||||
url: e.target.value,
|
||||
token: notificationConfig.apprise?.token || "",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL of your Apprise API server (e.g., http://apprise:8000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-token" className="text-sm">
|
||||
Token / path <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="apprise-token"
|
||||
placeholder="gitea-mirror"
|
||||
value={notificationConfig.apprise?.token || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
apprise: {
|
||||
...notificationConfig.apprise!,
|
||||
url: notificationConfig.apprise?.url || "",
|
||||
token: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Apprise API configuration token or key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apprise-tag" className="text-sm">
|
||||
Tag filter (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="apprise-tag"
|
||||
placeholder="all"
|
||||
value={notificationConfig.apprise?.tag || ""}
|
||||
onChange={(e) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
apprise: {
|
||||
...notificationConfig.apprise!,
|
||||
url: notificationConfig.apprise?.url || "",
|
||||
token: notificationConfig.apprise?.token || "",
|
||||
tag: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional tag to filter which Apprise services receive notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event toggles */}
|
||||
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
|
||||
<h3 className="text-sm font-medium">Notification Events</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-sync-error" className="text-sm font-normal cursor-pointer">
|
||||
Sync errors
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a mirror job fails
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-sync-error"
|
||||
checked={notificationConfig.notifyOnSyncError}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnSyncError: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-sync-success" className="text-sm font-normal cursor-pointer">
|
||||
Sync success
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a mirror job completes successfully
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-sync-success"
|
||||
checked={notificationConfig.notifyOnSyncSuccess}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnSyncSuccess: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-new-repo" className="text-sm font-normal cursor-pointer text-muted-foreground">
|
||||
New repository discovered (coming soon)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify when a new GitHub repository is auto-imported
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-new-repo"
|
||||
checked={notificationConfig.notifyOnNewRepo}
|
||||
disabled
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({ ...notificationConfig, notifyOnNewRepo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestNotification}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Activity className="h-4 w-4 animate-spin mr-2" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Test Notification
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Badge } from '../ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
|
||||
try {
|
||||
@@ -100,6 +101,9 @@ export function SSOSettings() {
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
const appOrigin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const buildAbsoluteAppUrl = (path: string) =>
|
||||
appOrigin ? new URL(withBase(path), appOrigin).toString() : withBase(path);
|
||||
|
||||
|
||||
|
||||
@@ -179,8 +183,8 @@ export function SSOSettings() {
|
||||
} else {
|
||||
requestData.entryPoint = providerForm.entryPoint;
|
||||
requestData.cert = providerForm.cert;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || `${window.location.origin}/api/auth/sso/saml2/callback/${providerForm.providerId}`;
|
||||
requestData.audience = providerForm.audience || window.location.origin;
|
||||
requestData.callbackUrl = providerForm.callbackUrl || buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId}`);
|
||||
requestData.audience = providerForm.audience || appOrigin;
|
||||
requestData.wantAssertionsSigned = providerForm.wantAssertionsSigned;
|
||||
requestData.signatureAlgorithm = providerForm.signatureAlgorithm;
|
||||
requestData.digestAlgorithm = providerForm.digestAlgorithm;
|
||||
@@ -517,7 +521,7 @@ export function SSOSettings() {
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>Redirect URL: {buildAbsoluteAppUrl(`/api/auth/sso/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||
@@ -563,8 +567,8 @@ export function SSOSettings() {
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Callback URL: {window.location.origin}/api/auth/sso/saml2/callback/{providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>SP Metadata: {window.location.origin}/api/auth/sso/saml2/sp/metadata?providerId={providerForm.providerId || '{provider-id}'}</p>
|
||||
<p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -724,4 +728,4 @@ export function SSOSettings() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
// Helper function to format last sync time
|
||||
function formatLastSyncTime(date: Date | null): string {
|
||||
@@ -110,7 +111,7 @@ export function Dashboard() {
|
||||
useEffectForToasts(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
|
||||
const eventSource = new EventSource(`${withBase("/api/events")}?userId=${user.id}`);
|
||||
|
||||
eventSource.addEventListener("rate-limit", (event) => {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Activity, Clock } from "lucide-react";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: MirrorJob[];
|
||||
@@ -14,7 +15,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/activity">View All</a>
|
||||
<a href={withBase("/activity")}>View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -27,7 +28,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/activity">
|
||||
<a href={withBase("/activity")}>
|
||||
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||
View History
|
||||
</a>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { GitFork } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface RepositoryListProps {
|
||||
repositories: Repository[];
|
||||
@@ -15,11 +17,6 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
|
||||
// Helper function to construct Gitea repository URL
|
||||
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||
const rawBaseUrl = giteaConfig?.externalUrl || giteaConfig?.url;
|
||||
if (!rawBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced'];
|
||||
if (!validStatuses.includes(repository.status)) {
|
||||
@@ -38,12 +35,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
repoPath = `${owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = rawBaseUrl.endsWith("/")
|
||||
? rawBaseUrl.slice(0, -1)
|
||||
: rawBaseUrl;
|
||||
|
||||
return `${baseUrl}/${repoPath}`;
|
||||
return buildGiteaWebUrl(giteaConfig, repoPath);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -51,7 +43,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/repositories">View All</a>
|
||||
<a href={withBase("/repositories")}>View All</a>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -63,7 +55,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
Configure your GitHub connection to start mirroring repositories.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/config">Configure GitHub</a>
|
||||
<a href={withBase("/config")}>Configure GitHub</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -2,18 +2,12 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { ModeToggle } from "@/components/theme/ModeToggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import { AccountMenu } from "@/components/auth/AccountMenu";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface HeaderProps {
|
||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||
@@ -25,7 +19,7 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
const { isLoading } = useAuth();
|
||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||
|
||||
@@ -46,13 +40,6 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh';
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
toast.success("Logged out successfully");
|
||||
// Small delay to show the toast before redirecting
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
logout();
|
||||
};
|
||||
|
||||
// Auth buttons skeleton loader
|
||||
function AuthButtonsSkeleton() {
|
||||
return (
|
||||
@@ -101,14 +88,14 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPage !== 'dashboard') {
|
||||
window.history.pushState({}, '', '/');
|
||||
window.history.pushState({}, '', withBase('/'));
|
||||
onNavigate?.('dashboard');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
src={withBase('/logo.png')}
|
||||
alt="Gitea Mirror Logo"
|
||||
className="h-5 w-6"
|
||||
/>
|
||||
@@ -140,32 +127,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
{isLoading ? (
|
||||
<AuthButtonsSkeleton />
|
||||
) : user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
|
||||
<Avatar className="h-full w-full">
|
||||
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
|
||||
<AvatarFallback>
|
||||
{(user.name || user.email || "U").charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/login">Login</a>
|
||||
</Button>
|
||||
)}
|
||||
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRepoSync } from "@/hooks/useSyncRepo";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { stripBasePath, withBase } from "@/lib/base-path";
|
||||
|
||||
// Navigation context to signal when navigation happens
|
||||
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
|
||||
@@ -71,7 +72,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const path = window.location.pathname;
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
const pageMap: Record<string, AppProps['page']> = {
|
||||
'/': 'dashboard',
|
||||
'/repositories': 'repositories',
|
||||
@@ -125,7 +126,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
if (!authLoading && !user) {
|
||||
// Use window.location for client-side redirect
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = withBase('/login');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { stripBasePath, withBase } from "@/lib/base-path";
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
@@ -24,14 +25,14 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
|
||||
useEffect(() => {
|
||||
// Hydration happens here
|
||||
const path = window.location.pathname;
|
||||
const path = stripBasePath(window.location.pathname);
|
||||
setCurrentPath(path);
|
||||
}, []);
|
||||
|
||||
// Listen for URL changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
setCurrentPath(stripBasePath(window.location.pathname));
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
@@ -45,7 +46,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
if (currentPath === href) return;
|
||||
|
||||
// Update URL without page reload
|
||||
window.history.pushState({}, '', href);
|
||||
window.history.pushState({}, '', withBase(href));
|
||||
setCurrentPath(href);
|
||||
|
||||
// Map href to page name for the parent component
|
||||
@@ -163,7 +164,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
|
||||
@@ -177,7 +178,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="/docs"
|
||||
href={withBase("/docs")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
|
||||
@@ -90,41 +90,29 @@ export default function ConsentPage() {
|
||||
const handleConsent = async (accept: boolean) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// The OAuth provider redirected here with the authorization request in
|
||||
// the query string (client_id, scope, code). Hand that back via
|
||||
// `oauth_query` so the provider can resume the flow. It validates the
|
||||
// redirect URI server-side and returns the URL to navigate to — either
|
||||
// with an authorization code (accept) or an error (deny).
|
||||
const oauthQuery = window.location.search.replace(/^\?/, '');
|
||||
const result = await authClient.oauth2.consent({
|
||||
accept,
|
||||
oauth_query: oauthQuery,
|
||||
scope: Array.from(selectedScopes).join(' '),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'Consent failed');
|
||||
}
|
||||
|
||||
// The consent method should handle the redirect
|
||||
if (!accept) {
|
||||
// If denied, redirect back to the application with error
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectUri = params.get('redirect_uri');
|
||||
|
||||
if (redirectUri && application) {
|
||||
// Validate redirect URI against authorized URIs
|
||||
const authorizedUris = parseRedirectUris(application.redirectURLs);
|
||||
|
||||
if (isValidRedirectUri(redirectUri, authorizedUris)) {
|
||||
try {
|
||||
// Parse and reconstruct the URL to ensure it's safe
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
|
||||
// Safe to redirect - URI has been validated and sanitized
|
||||
window.location.href = url.toString();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse redirect URI:', e);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
} else {
|
||||
console.error('Unauthorized redirect URI:', redirectUri);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
}
|
||||
const redirectUri = (result.data as { redirect_uri?: string } | undefined)?.redirect_uri;
|
||||
if (redirectUri) {
|
||||
// The redirect URI is produced and validated server-side.
|
||||
window.location.href = redirectUri;
|
||||
} else if (!accept) {
|
||||
// No redirect target returned on denial — return to the app.
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
|
||||
@@ -9,8 +9,10 @@ import type { FilterParams } from "@/types/filter";
|
||||
import Fuse from "fuse.js";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -67,11 +69,6 @@ export function OrganizationList({
|
||||
|
||||
// Helper function to construct Gitea organization URL
|
||||
const getGiteaOrgUrl = (organization: Organization): string | null => {
|
||||
const rawBaseUrl = giteaConfig?.externalUrl || giteaConfig?.url;
|
||||
if (!rawBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for organizations that have been mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored'];
|
||||
if (!validStatuses.includes(organization.status || '')) {
|
||||
@@ -84,17 +81,12 @@ export function OrganizationList({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = rawBaseUrl.endsWith("/")
|
||||
? rawBaseUrl.slice(0, -1)
|
||||
: rawBaseUrl;
|
||||
|
||||
return `${baseUrl}/${orgName}`;
|
||||
return buildGiteaWebUrl(giteaConfig, orgName);
|
||||
};
|
||||
|
||||
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
|
||||
// Call API to update organization destination
|
||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -198,7 +190,7 @@ export function OrganizationList({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer truncate"
|
||||
>
|
||||
{org.name}
|
||||
@@ -273,7 +265,7 @@ export function OrganizationList({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
href={`${withBase('/repositories')}?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="text-xl font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
|
||||
@@ -50,6 +50,7 @@ import AddRepositoryDialog from "./AddRepositoryDialog";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
const REPOSITORY_SORT_OPTIONS = [
|
||||
{ value: "imported-desc", label: "Recently Imported" },
|
||||
@@ -97,6 +98,8 @@ export default function Repository() {
|
||||
const [repoToDelete, setRepoToDelete] = useState<Repository | null>(null);
|
||||
const [isDeleteRepoDialogOpen, setIsDeleteRepoDialogOpen] = useState(false);
|
||||
const [isDeletingRepo, setIsDeletingRepo] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isDeletingBulk, setIsDeletingBulk] = useState(false);
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
@@ -918,11 +921,9 @@ export default function Repository() {
|
||||
|
||||
setIsDeletingRepo(true);
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; error?: string }>(
|
||||
`/repositories/${repoToDelete.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
const response = await apiRequest<{ success: boolean; deleted?: number; error?: string }>(
|
||||
"/repositories",
|
||||
{ method: "DELETE", body: JSON.stringify({ ids: [repoToDelete.id] }) }
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
@@ -940,6 +941,30 @@ export default function Repository() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (!user || !user.id) return;
|
||||
setIsDeletingBulk(true);
|
||||
try {
|
||||
const response = await apiRequest<{ success: boolean; deleted?: number; error?: string }>(
|
||||
"/repositories",
|
||||
{ method: "DELETE", body: JSON.stringify({ ids: [...selectedRepoIds] }) }
|
||||
);
|
||||
if (response.success) {
|
||||
const count = response.deleted ?? selectedRepoIds.size;
|
||||
toast.success(`Removed ${count} ${count === 1 ? "repository" : "repositories"} from Gitea Mirror.`);
|
||||
setSelectedRepoIds(new Set());
|
||||
await fetchRepositories(false);
|
||||
} else {
|
||||
showErrorToast(response.error || "Failed to delete repositories", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setIsDeletingBulk(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine what actions are available for selected repositories
|
||||
const getAvailableActions = () => {
|
||||
if (selectedRepoIds.size === 0) return [];
|
||||
@@ -976,7 +1001,9 @@ export default function Repository() {
|
||||
if (selectedRepos.some(repo => repo.status === "ignored")) {
|
||||
actions.push('include');
|
||||
}
|
||||
|
||||
|
||||
actions.push('delete');
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
@@ -993,6 +1020,7 @@ export default function Repository() {
|
||||
retry: selectedRepos.filter(repo => repo.status === "failed").length,
|
||||
ignore: selectedRepos.filter(repo => repo.status !== "ignored").length,
|
||||
include: selectedRepos.filter(repo => repo.status === "ignored").length,
|
||||
delete: selectedRepos.length,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1311,111 +1339,122 @@ export default function Repository() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions on desktop - integrated into the same line */}
|
||||
{/* Mirror All action */}
|
||||
<div className="flex items-center gap-2 border-l pl-4">
|
||||
{selectedRepoIds.size === 0 ? (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedRepoIds.size} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => setSelectedRepoIds(new Set())}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{availableActions.includes('mirror') && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
onClick={handleBulkMirror}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror ({actionCounts.mirror})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('sync') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleBulkSync}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync ({actionCounts.sync})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('rerun-metadata') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleBulkRerunMetadata}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Re-run Metadata ({actionCounts.rerunMetadata})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('retry') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleBulkRetry}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('ignore') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
onClick={() => handleBulkSkip(true)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('include') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={() => handleBulkSkip(false)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Bulk actions row - shown when repos are selected */}
|
||||
{selectedRepoIds.size > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedRepoIds.size} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => setSelectedRepoIds(new Set())}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{availableActions.includes('mirror') && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
onClick={handleBulkMirror}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror ({actionCounts.mirror})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('sync') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleBulkSync}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync ({actionCounts.sync})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('rerun-metadata') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleBulkRerunMetadata}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Re-run Metadata ({actionCounts.rerunMetadata})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('retry') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleBulkRetry}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('ignore') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
onClick={() => handleBulkSkip(true)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('include') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={() => handleBulkSkip(false)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Include
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="default"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete ({actionCounts.delete})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons for mobile - only show when items are selected */}
|
||||
{selectedRepoIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap sm:hidden">
|
||||
@@ -1505,6 +1544,16 @@ export default function Repository() {
|
||||
Include
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete ({actionCounts.delete})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1518,7 +1567,7 @@ export default function Repository() {
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/config');
|
||||
window.history.pushState({}, '', withBase('/config'));
|
||||
// We need to trigger a page change event for the navigation system
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
@@ -1585,6 +1634,41 @@ export default function Repository() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isBulkDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isDeletingBulk) setIsBulkDeleteDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove {selectedRepoIds.size} {selectedRepoIds.size === 1 ? "repository" : "repositories"} from Gitea Mirror?</DialogTitle>
|
||||
<DialogDescription>
|
||||
These repositories will be deleted from Gitea Mirror only. Any mirrors on Gitea will remain untouched; remove them manually in Gitea if needed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(false)}
|
||||
disabled={isDeletingBulk}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleBulkDelete} disabled={isDeletingBulk}>
|
||||
{isDeletingBulk ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete {selectedRepoIds.size} {selectedRepoIds.size === 1 ? "repository" : "repositories"}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isDeleteRepoDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatLastSyncTime } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -97,11 +99,12 @@ export default function RepositoryTable({
|
||||
onDismissSync,
|
||||
}: RepositoryTableProps) {
|
||||
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
|
||||
// Call API to update repository destination
|
||||
const response = await fetch(`/api/repositories/${repoId}`, {
|
||||
const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -124,10 +127,6 @@ export default function RepositoryTable({
|
||||
|
||||
// Helper function to construct Gitea repository URL
|
||||
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for repositories that have been or are being mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived'];
|
||||
if (!validStatuses.includes(repository.status)) {
|
||||
@@ -144,12 +143,7 @@ export default function RepositoryTable({
|
||||
repoPath = `${owner}/${repository.name}`;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${repoPath}`;
|
||||
return buildGiteaWebUrl(giteaConfig, repoPath);
|
||||
};
|
||||
|
||||
const hasAnyFilter = [
|
||||
@@ -242,6 +236,7 @@ export default function RepositoryTable({
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
lastSelectedIndexRef.current = null;
|
||||
if (checked) {
|
||||
const allIds = new Set(
|
||||
visibleRepositories
|
||||
@@ -254,7 +249,7 @@ export default function RepositoryTable({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRepo = (repoId: string, checked: boolean) => {
|
||||
const handleSelectRepo = (repoId: string, checked: boolean, index?: number) => {
|
||||
const newSelection = new Set(selectedRepoIds);
|
||||
if (checked) {
|
||||
newSelection.add(repoId);
|
||||
@@ -262,6 +257,32 @@ export default function RepositoryTable({
|
||||
newSelection.delete(repoId);
|
||||
}
|
||||
onSelectionChange(newSelection);
|
||||
if (index !== undefined) {
|
||||
lastSelectedIndexRef.current = index;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShiftRangeSelect = (currentIndex: number) => {
|
||||
const lastIndex = lastSelectedIndexRef.current;
|
||||
if (lastIndex === null) {
|
||||
const repo = visibleRepositories[currentIndex];
|
||||
if (repo?.id) {
|
||||
const newSelection = new Set(selectedRepoIds);
|
||||
newSelection.add(repo.id);
|
||||
onSelectionChange(newSelection);
|
||||
lastSelectedIndexRef.current = currentIndex;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const newSelection = new Set(selectedRepoIds);
|
||||
for (let i = start; i <= end; i++) {
|
||||
const id = visibleRepositories[i]?.id;
|
||||
if (id) newSelection.add(id);
|
||||
}
|
||||
onSelectionChange(newSelection);
|
||||
lastSelectedIndexRef.current = currentIndex;
|
||||
};
|
||||
|
||||
const isAllSelected =
|
||||
@@ -270,7 +291,7 @@ export default function RepositoryTable({
|
||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||
|
||||
// Mobile card layout for repository
|
||||
const RepositoryCard = ({ repo }: { repo: Repository }) => {
|
||||
const RepositoryCard = ({ repo, index }: { repo: Repository; index: number }) => {
|
||||
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
|
||||
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
|
||||
const giteaUrl = getGiteaRepoUrl(repo);
|
||||
@@ -281,12 +302,21 @@ export default function RepositoryTable({
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header with checkbox and repo name */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
|
||||
className="mt-1 h-5 w-5"
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
<div
|
||||
onClickCapture={(e) => {
|
||||
if (e.shiftKey && repo.id) {
|
||||
e.stopPropagation();
|
||||
handleShiftRangeSelect(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean, index)}
|
||||
className="mt-1 h-5 w-5"
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-base truncate">{repo.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
@@ -642,8 +672,8 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
|
||||
{/* Repository cards */}
|
||||
{visibleRepositories.map((repo) => (
|
||||
<RepositoryCard key={repo.id} repo={repo} />
|
||||
{visibleRepositories.map((repo, index) => (
|
||||
<RepositoryCard key={repo.id} repo={repo} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -708,10 +738,18 @@ export default function RepositoryTable({
|
||||
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<div
|
||||
className="h-full p-3 flex items-center justify-center flex-[0.3]"
|
||||
onClickCapture={(e) => {
|
||||
if (e.shiftKey && repo.id) {
|
||||
e.stopPropagation();
|
||||
handleShiftRangeSelect(virtualRow.index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked, virtualRow.index)}
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
---
|
||||
import { BASE_PATH_WINDOW_KEY } from '@/lib/base-path';
|
||||
|
||||
const normalizeBasePath = (value) => {
|
||||
if (!value || !value.trim()) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
let normalized = value.trim();
|
||||
if (!normalized.startsWith('/')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/\/+$/, '');
|
||||
return normalized || '/';
|
||||
};
|
||||
|
||||
const runtimeBasePath = normalizeBasePath(process.env.BASE_URL);
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
<script is:inline define:vars={{ BASE_PATH_WINDOW_KEY, runtimeBasePath }}>
|
||||
window[BASE_PATH_WINDOW_KEY] = runtimeBasePath;
|
||||
|
||||
const getThemePreference = () => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { authApi } from "@/lib/api";
|
||||
import type { ExtendedUser } from "@/types/user";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface AuthContextType {
|
||||
user: ExtendedUser | null;
|
||||
@@ -61,9 +62,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Redirect user based on error
|
||||
if (err?.message === "No users found") {
|
||||
window.location.href = "/signup";
|
||||
window.location.href = withBase("/signup");
|
||||
} else {
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
}
|
||||
console.error("Auth check failed", err);
|
||||
} finally {
|
||||
@@ -111,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
await authApi.logout();
|
||||
setUser(null);
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
} catch (err) {
|
||||
console.error("Logout error:", err);
|
||||
} finally {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
|
||||
import type { Session, AuthUser } from "@/lib/auth-client";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
@@ -46,7 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/",
|
||||
callbackURL: withBase("/"),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -73,7 +74,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
email,
|
||||
password,
|
||||
name: username, // Better Auth uses 'name' field for display name
|
||||
callbackURL: "/",
|
||||
callbackURL: withBase("/"),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -94,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.href = "/login";
|
||||
window.location.href = withBase("/login");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -140,4 +141,4 @@ export function useAuth() {
|
||||
}
|
||||
|
||||
// Export the Better Auth session hook for direct use when needed
|
||||
export { useBetterAuthSession };
|
||||
export { useBetterAuthSession };
|
||||
@@ -65,14 +65,16 @@ export function useConfigStatus(): ConfigStatus {
|
||||
if (isCacheValid && hasCheckedRef.current) {
|
||||
const configResponse = configCache.data!;
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
@@ -108,14 +110,16 @@ export function useConfigStatus(): ConfigStatus {
|
||||
userId: user.id
|
||||
};
|
||||
|
||||
const isGitHubConfigured = !!(
|
||||
configResponse?.githubConfig?.username &&
|
||||
configResponse?.githubConfig?.token
|
||||
);
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
configResponse?.giteaConfig?.username &&
|
||||
configResponse?.giteaConfig?.token
|
||||
);
|
||||
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface UseSSEOptions {
|
||||
userId?: string;
|
||||
@@ -41,7 +42,7 @@ export const useSSE = ({
|
||||
}
|
||||
|
||||
// Create new EventSource connection
|
||||
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
|
||||
const eventSource = new EventSource(`${withBase("/api/sse")}?userId=${userId}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "./useAuth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
interface UseRepoSyncOptions {
|
||||
userId?: string;
|
||||
@@ -51,7 +52,7 @@ export function useRepoSync({
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/job/schedule-sync-repo", {
|
||||
const response = await fetch(withBase("/api/job/schedule-sync-repo"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import '../styles/global.css';
|
||||
import '../styles/docs.css';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
import { withBase } from '@/lib/base-path';
|
||||
|
||||
// Accept title as a prop with a default value
|
||||
const { title = 'Gitea Mirror' } = Astro.props;
|
||||
@@ -11,7 +12,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
|
||||
<title>{title}</title>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
|
||||
+21
-5
@@ -1,5 +1,7 @@
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
// Base API URL
|
||||
const API_BASE = "/api";
|
||||
const API_BASE = withBase("/api");
|
||||
|
||||
// Helper function for API requests
|
||||
async function apiRequest<T>(
|
||||
@@ -78,15 +80,29 @@ export const githubApi = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
getStarredLists: () =>
|
||||
apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", {
|
||||
method: "GET",
|
||||
}),
|
||||
};
|
||||
|
||||
// Gitea API
|
||||
export interface GiteaServerInfo {
|
||||
type: "forgejo" | "gitea";
|
||||
version: string;
|
||||
raw: string;
|
||||
hasMirrorCredBug: boolean;
|
||||
}
|
||||
|
||||
export const giteaApi = {
|
||||
testConnection: (url: string, token: string) =>
|
||||
apiRequest<{ success: boolean }>("/gitea/test-connection", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, token }),
|
||||
}),
|
||||
apiRequest<{ success: boolean; serverInfo?: GiteaServerInfo; message?: string }>(
|
||||
"/gitea/test-connection",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url, token }),
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
// Health API
|
||||
|
||||
+11
-6
@@ -1,8 +1,14 @@
|
||||
import "@/lib/polyfills/buffer";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
function normalizeAuthBaseUrl(url: string): string {
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin;
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
|
||||
@@ -18,9 +24,8 @@ export const authClient = createAuthClient({
|
||||
// Validate and clean the URL if provided
|
||||
if (url && typeof url === 'string' && url.trim() !== '') {
|
||||
try {
|
||||
// Validate URL format and remove trailing slash
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin; // Use origin to ensure clean URL without path
|
||||
// Validate URL format and preserve optional base path
|
||||
return normalizeAuthBaseUrl(url);
|
||||
} catch (e) {
|
||||
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
|
||||
}
|
||||
@@ -34,9 +39,9 @@ export const authClient = createAuthClient({
|
||||
// Default for SSR - always return a valid URL
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
basePath: withBase('/api/auth'), // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
oauthProviderClient(),
|
||||
ssoClient(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, mock, test, beforeEach } from "bun:test";
|
||||
|
||||
// Stub `./auth` so we can drive the response shape `mintSessionFromHeaders`
|
||||
// sees from the plugin endpoint without standing up the full Better Auth
|
||||
// stack + DB.
|
||||
const signInWithHeaderMock = mock<(args: unknown) => Promise<Response>>(
|
||||
async () => new Response(null, { status: 500 }),
|
||||
);
|
||||
|
||||
mock.module("@/lib/auth", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
signInWithHeader: signInWithHeaderMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { mintSessionFromHeaders } from "./auth-header-bridge";
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): Request {
|
||||
return new Request("http://localhost/test", { headers });
|
||||
}
|
||||
|
||||
describe("mintSessionFromHeaders", () => {
|
||||
beforeEach(() => {
|
||||
signInWithHeaderMock.mockReset();
|
||||
});
|
||||
|
||||
test("returns user, session, and Set-Cookie values on a 200 response", async () => {
|
||||
signInWithHeaderMock.mockImplementation(async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
user: { id: "user-1", email: "u@example.com" },
|
||||
session: { id: "sess-1", userId: "user-1" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
// Modern runtimes coalesce multiple Set-Cookie via append.
|
||||
response.headers.append("set-cookie", "better-auth-session=abc; Path=/");
|
||||
response.headers.append("set-cookie", "better-auth-remember=1; Path=/");
|
||||
return response;
|
||||
});
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.user.id).toBe("user-1");
|
||||
expect(result?.session.id).toBe("sess-1");
|
||||
expect(result?.setCookies).toEqual([
|
||||
"better-auth-session=abc; Path=/",
|
||||
"better-auth-remember=1; Path=/",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null when the endpoint responds non-2xx (header auth disabled / unauthorized)", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () => new Response("Unauthorized", { status: 401 }),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the endpoint throws (transient failure)", async () => {
|
||||
signInWithHeaderMock.mockImplementation(async () => {
|
||||
throw new Error("upstream unreachable");
|
||||
});
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the response body is missing user or session", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ token: "abc" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the body is malformed JSON", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response("not json at all", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns an empty setCookies array when no Set-Cookie headers were attached", async () => {
|
||||
// Defensive — should never happen in practice because the plugin
|
||||
// always calls setSessionCookie. If it does happen, we still want
|
||||
// the user/session to come through so SSR works on this request.
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
user: { id: "user-1" },
|
||||
session: { id: "sess-1" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.setCookies).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { auth } from "./auth";
|
||||
|
||||
export interface BridgeResult {
|
||||
user: any;
|
||||
session: any;
|
||||
setCookies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the `header-auth` plugin endpoint to mint a real Better Auth
|
||||
* session from trusted upstream headers (Authentik / Authelia /
|
||||
* oauth2-proxy / Caddy), and returns the user, session, and the
|
||||
* `Set-Cookie` headers for the middleware to forward onto the
|
||||
* outbound response.
|
||||
*
|
||||
* Fail-open: returns null on any failure (endpoint disabled, headers
|
||||
* missing, DB blip, malformed response). The middleware then sets
|
||||
* locals to null and the request proceeds as anonymous — broken
|
||||
* header auth must never lock everyone out of the cookie-auth path.
|
||||
*
|
||||
* Cookie extraction prefers `Response.headers.getSetCookie()` (Node 18+
|
||||
* fetch, undici). Older runtimes that only expose `get('set-cookie')`
|
||||
* fall through to the single-header form; that path coalesces all
|
||||
* Set-Cookie values into one comma-separated string, which is wrong
|
||||
* for cookies whose attributes contain commas (`Expires` does). We
|
||||
* accept that risk because: (a) Bun and the supported Node versions
|
||||
* for this project both implement `getSetCookie`, and (b) the
|
||||
* fallback only fires on truly ancient runtimes that aren't in our
|
||||
* support matrix.
|
||||
*/
|
||||
export async function mintSessionFromHeaders(
|
||||
request: Request,
|
||||
): Promise<BridgeResult | null> {
|
||||
try {
|
||||
const response = await auth.api.signInWithHeader({
|
||||
headers: request.headers,
|
||||
asResponse: true,
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data?.user || !data?.session) return null;
|
||||
|
||||
const setCookies =
|
||||
typeof response.headers.getSetCookie === "function"
|
||||
? response.headers.getSetCookie()
|
||||
: response.headers.get("set-cookie")
|
||||
? [response.headers.get("set-cookie") as string]
|
||||
: [];
|
||||
|
||||
return { user: data.user, session: data.session, setCookies };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { headerAuthPlugin } from "./auth-header-plugin";
|
||||
|
||||
describe("headerAuthPlugin", () => {
|
||||
test("registers the `header-auth` plugin id", () => {
|
||||
const plugin = headerAuthPlugin();
|
||||
expect(plugin.id).toBe("header-auth");
|
||||
});
|
||||
|
||||
test("exposes a `signInWithHeader` endpoint", () => {
|
||||
const plugin = headerAuthPlugin();
|
||||
expect(plugin.endpoints?.signInWithHeader).toBeDefined();
|
||||
});
|
||||
|
||||
test("mounts the endpoint at POST /sign-in/header", () => {
|
||||
// The Astro API route is /api/auth/<plugin-path>, so the resolved
|
||||
// URL the middleware bridge talks to is /api/auth/sign-in/header.
|
||||
// Locking that path down in a test prevents an accidental rename
|
||||
// from silently breaking the React SPA's auth flow.
|
||||
const plugin = headerAuthPlugin();
|
||||
const endpoint = plugin.endpoints?.signInWithHeader as unknown as {
|
||||
path: string;
|
||||
options: { method: string };
|
||||
};
|
||||
|
||||
expect(endpoint.path).toBe("/sign-in/header");
|
||||
expect(endpoint.options.method).toBe("POST");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { APIError, createAuthEndpoint } from "better-auth/api";
|
||||
import { setSessionCookie } from "better-auth/cookies";
|
||||
import { authenticateWithHeaders, isHeaderAuthEnabled } from "./auth-header";
|
||||
|
||||
/**
|
||||
* Better Auth plugin that bridges header / forward authentication into a
|
||||
* real Better Auth session.
|
||||
*
|
||||
* Why this exists: the Astro middleware historically populated
|
||||
* `context.locals.user` from trusted upstream headers (Authentik /
|
||||
* Authelia / oauth2-proxy / Caddy), but never minted a Better Auth
|
||||
* session. Server-rendered pages saw the user, but the React SPA's
|
||||
* `/api/auth/get-session` call hit Better Auth's handler — which only
|
||||
* reads its session cookie — and got `null`. The auth guard then
|
||||
* bounced to `/login`, so header auth was end-to-end broken.
|
||||
*
|
||||
* This plugin exposes `POST /sign-in/header`, which the middleware
|
||||
* calls once per cold request (no cookie yet) when header auth is
|
||||
* enabled. It verifies the trusted headers, creates a real session
|
||||
* row via `internalAdapter.createSession`, and attaches the
|
||||
* `Set-Cookie` to the response. The middleware then forwards that
|
||||
* cookie to the outbound Astro response, so the SPA's next call to
|
||||
* `get-session` carries the cookie and works.
|
||||
*
|
||||
* The endpoint trusts whatever upstream sets the configured headers —
|
||||
* the security model here is "the operator controls the reverse
|
||||
* proxy." Make sure the proxy strips inbound copies of these headers
|
||||
* before forwarding (documented in docs/SSO-OIDC-SETUP.md).
|
||||
*/
|
||||
export const headerAuthPlugin = () =>
|
||||
({
|
||||
id: "header-auth",
|
||||
endpoints: {
|
||||
signInWithHeader: createAuthEndpoint(
|
||||
"/sign-in/header",
|
||||
{ method: "POST" },
|
||||
async (ctx) => {
|
||||
if (!isHeaderAuthEnabled()) {
|
||||
throw new APIError("NOT_FOUND", {
|
||||
message: "Header authentication is not enabled",
|
||||
});
|
||||
}
|
||||
|
||||
const headers = ctx.request?.headers ?? ctx.headers;
|
||||
if (!headers) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Request headers unavailable",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await authenticateWithHeaders(headers);
|
||||
if (!user) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Header authentication failed",
|
||||
});
|
||||
}
|
||||
|
||||
const session = await ctx.context.internalAdapter.createSession(
|
||||
user.id,
|
||||
);
|
||||
if (!session) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Failed to create session",
|
||||
});
|
||||
}
|
||||
|
||||
await setSessionCookie(ctx, { session, user });
|
||||
|
||||
return ctx.json({
|
||||
token: session.token,
|
||||
user,
|
||||
session,
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
}) satisfies BetterAuthPlugin;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
extractUserFromHeaders,
|
||||
isHeaderAuthEnabled,
|
||||
getHeaderAuthConfig,
|
||||
} from "./auth-header";
|
||||
|
||||
// `auth-header` reads config from `process.env` at call time. We snapshot
|
||||
// the relevant keys and restore them after each test so cases don't bleed.
|
||||
const HEADER_ENV_KEYS = [
|
||||
"HEADER_AUTH_ENABLED",
|
||||
"HEADER_AUTH_AUTO_PROVISION",
|
||||
"HEADER_AUTH_USER_HEADER",
|
||||
"HEADER_AUTH_EMAIL_HEADER",
|
||||
"HEADER_AUTH_NAME_HEADER",
|
||||
"HEADER_AUTH_ALLOWED_DOMAINS",
|
||||
] as const;
|
||||
|
||||
let savedEnv: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string | undefined>> = {};
|
||||
|
||||
function setEnv(vars: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string>>) {
|
||||
for (const key of HEADER_ENV_KEYS) {
|
||||
if (key in vars) {
|
||||
process.env[key] = vars[key]!;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = Object.fromEntries(
|
||||
HEADER_ENV_KEYS.map((k) => [k, process.env[k]]),
|
||||
) as typeof savedEnv;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of HEADER_ENV_KEYS) {
|
||||
const v = savedEnv[key];
|
||||
if (v === undefined) delete process.env[key];
|
||||
else process.env[key] = v;
|
||||
}
|
||||
});
|
||||
|
||||
describe("isHeaderAuthEnabled", () => {
|
||||
test("returns false when HEADER_AUTH_ENABLED is unset", () => {
|
||||
setEnv({});
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when HEADER_AUTH_ENABLED is anything other than the string 'true'", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "1" });
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
setEnv({ HEADER_AUTH_ENABLED: "yes" });
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true only for HEADER_AUTH_ENABLED='true' exactly", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
expect(isHeaderAuthEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractUserFromHeaders", () => {
|
||||
test("returns null when header auth is disabled", () => {
|
||||
setEnv({});
|
||||
const headers = new Headers({ "X-Authentik-Username": "u" });
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the configured user header is absent", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
const headers = new Headers({ "X-Some-Other-Header": "u" });
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns username, email, and name from default Authentik headers", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "alice",
|
||||
"X-Authentik-Email": "alice@example.com",
|
||||
"X-Authentik-Name": "Alice Q",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
name: "Alice Q",
|
||||
});
|
||||
});
|
||||
|
||||
test("respects HEADER_AUTH_USER_HEADER override (Caddy / caddy-security style)", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_USER_HEADER: "X-Token-User-Email",
|
||||
HEADER_AUTH_EMAIL_HEADER: "X-Token-User-Email",
|
||||
HEADER_AUTH_NAME_HEADER: "X-Token-User-Name",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Token-User-Email": "bob@example.com",
|
||||
"X-Token-User-Name": "Bob",
|
||||
});
|
||||
|
||||
// lanrat's reported config: username and email are both pulled from
|
||||
// the same header. Both should resolve to that value.
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "bob@example.com",
|
||||
email: "bob@example.com",
|
||||
name: "Bob",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects when email domain is not on the allow list", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "evil",
|
||||
"X-Authentik-Email": "evil@elsewhere.test",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("accepts when email domain matches the allow list", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "alice",
|
||||
"X-Authentik-Email": "alice@corp.example",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "alice",
|
||||
email: "alice@corp.example",
|
||||
name: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHeaderAuthConfig", () => {
|
||||
test("merges env overrides over defaults without leaking unset env values", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_USER_HEADER: "X-Forwarded-User",
|
||||
});
|
||||
|
||||
const config = getHeaderAuthConfig();
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.userHeader).toBe("X-Forwarded-User");
|
||||
// Unset overrides should fall back to defaults, not become undefined.
|
||||
expect(config.emailHeader).toBe("X-Authentik-Email");
|
||||
expect(config.nameHeader).toBe("X-Authentik-Name");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { resolveTrustedOrigins } from "./auth";
|
||||
|
||||
// Helper to create a mock Request with specific headers
|
||||
function mockRequest(headers: Record<string, string>): Request {
|
||||
return new Request("http://localhost:4321/api/auth/sign-in", {
|
||||
headers: new Headers(headers),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveTrustedOrigins", () => {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
// Save and clear relevant env vars
|
||||
for (const key of ["BETTER_AUTH_URL", "BETTER_AUTH_TRUSTED_ORIGINS"]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env vars
|
||||
for (const [key, val] of Object.entries(savedEnv)) {
|
||||
if (val === undefined) delete process.env[key];
|
||||
else process.env[key] = val;
|
||||
}
|
||||
});
|
||||
|
||||
test("includes localhost defaults when called without request", async () => {
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("http://localhost:4321");
|
||||
expect(origins).toContain("http://localhost:8080");
|
||||
});
|
||||
|
||||
test("includes BETTER_AUTH_URL from env", async () => {
|
||||
process.env.BETTER_AUTH_URL = "https://gitea-mirror.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("https://gitea-mirror.example.com");
|
||||
});
|
||||
|
||||
test("includes BETTER_AUTH_TRUSTED_ORIGINS (comma-separated)", async () => {
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://a.example.com, https://b.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).toContain("https://a.example.com");
|
||||
expect(origins).toContain("https://b.example.com");
|
||||
});
|
||||
|
||||
test("skips invalid URLs in env vars", async () => {
|
||||
process.env.BETTER_AUTH_URL = "not-a-url";
|
||||
process.env.BETTER_AUTH_TRUSTED_ORIGINS = "also-invalid, https://valid.example.com";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
expect(origins).not.toContain("not-a-url");
|
||||
expect(origins).not.toContain("also-invalid");
|
||||
expect(origins).toContain("https://valid.example.com");
|
||||
});
|
||||
|
||||
test("auto-detects origin from x-forwarded-host + x-forwarded-proto", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea-mirror.mydomain.tld",
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://gitea-mirror.mydomain.tld");
|
||||
});
|
||||
|
||||
test("falls back to host header when x-forwarded-host is absent", async () => {
|
||||
const req = mockRequest({
|
||||
host: "myserver.local:4321",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("http://myserver.local:4321");
|
||||
});
|
||||
|
||||
test("handles multi-value x-forwarded-host (chained proxies)", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "external.example.com, internal.proxy.local",
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://external.example.com");
|
||||
expect(origins).not.toContain("https://internal.proxy.local");
|
||||
});
|
||||
|
||||
test("handles multi-value x-forwarded-proto (chained proxies)", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.example.com",
|
||||
"x-forwarded-proto": "https, http",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("https://gitea.example.com");
|
||||
// Should NOT create an origin with "https, http" as proto
|
||||
expect(origins).not.toContain("https, http://gitea.example.com");
|
||||
});
|
||||
|
||||
test("rejects invalid x-forwarded-proto values", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.example.com",
|
||||
"x-forwarded-proto": "ftp",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).not.toContain("ftp://gitea.example.com");
|
||||
});
|
||||
|
||||
test("deduplicates origins", async () => {
|
||||
process.env.BETTER_AUTH_URL = "http://localhost:4321";
|
||||
const origins = await resolveTrustedOrigins();
|
||||
const count = origins.filter(o => o === "http://localhost:4321").length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("defaults proto to http when x-forwarded-proto is absent", async () => {
|
||||
const req = mockRequest({
|
||||
"x-forwarded-host": "gitea.internal",
|
||||
});
|
||||
const origins = await resolveTrustedOrigins(req);
|
||||
expect(origins).toContain("http://gitea.internal");
|
||||
});
|
||||
});
|
||||
+188
-57
@@ -1,10 +1,96 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { oidcProvider } from "better-auth/plugins";
|
||||
import { jwt } from "better-auth/plugins";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBase } from "./base-path";
|
||||
import { headerAuthPlugin } from "./auth-header-plugin";
|
||||
|
||||
/**
|
||||
* Resolves the list of trusted origins for Better Auth CSRF validation.
|
||||
* Exported for testing. Called per-request with the incoming Request,
|
||||
* or at startup with no request (static origins only).
|
||||
*/
|
||||
export async function resolveTrustedOrigins(request?: Request): Promise<string[]> {
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect origin from the incoming request's Host header when running
|
||||
// behind a reverse proxy. Helps with Better Auth's per-request CSRF check.
|
||||
if (request?.headers) {
|
||||
// Take first value only — headers can be comma-separated in chained proxy setups
|
||||
const rawHost = request.headers.get("x-forwarded-host") || request.headers.get("host");
|
||||
const host = rawHost?.split(",")[0].trim();
|
||||
if (host) {
|
||||
const rawProto = request.headers.get("x-forwarded-proto") || "http";
|
||||
const proto = rawProto.split(",")[0].trim().toLowerCase();
|
||||
if (proto === "http" || proto === "https") {
|
||||
try {
|
||||
const detected = new URL(`${proto}://${host}`);
|
||||
origins.push(detected.origin);
|
||||
} catch {
|
||||
// Malformed header, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
if (!request) {
|
||||
console.info("Trusted origins (static):", uniqueOrigins);
|
||||
}
|
||||
return uniqueOrigins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Better Auth logger level from BETTER_AUTH_LOG_LEVEL.
|
||||
* Returns undefined for unset/invalid values so Better Auth falls back
|
||||
* to its built-in default ("warn"). "success" is intentionally excluded
|
||||
* — it is an output level, not a valid threshold.
|
||||
*/
|
||||
function resolveAuthLogLevel(): "debug" | "info" | "warn" | "error" | undefined {
|
||||
const raw = process.env.BETTER_AUTH_LOG_LEVEL?.trim().toLowerCase();
|
||||
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
|
||||
return raw;
|
||||
}
|
||||
if (raw) {
|
||||
console.warn(`Invalid BETTER_AUTH_LOG_LEVEL: "${raw}", using default ("warn")`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Database configuration
|
||||
@@ -17,6 +103,17 @@ export const auth = betterAuth({
|
||||
// Secret for signing tokens
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
|
||||
// Logger configuration.
|
||||
//
|
||||
// Better Auth ships its own logger (it does NOT read the `DEBUG` env
|
||||
// var / the `debug` npm package — `DEBUG=better-auth:*` is a no-op).
|
||||
// The default level is "warn", so SSO/OIDC debug and info messages
|
||||
// are hidden out of the box. Set BETTER_AUTH_LOG_LEVEL=debug to surface
|
||||
// the full sign-in / callback trace when troubleshooting SSO.
|
||||
logger: {
|
||||
level: resolveAuthLogLevel(),
|
||||
},
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
@@ -31,7 +128,7 @@ export const auth = betterAuth({
|
||||
try {
|
||||
// Validate URL format and ensure it's a proper origin
|
||||
const validatedUrl = new URL(url.trim());
|
||||
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
|
||||
const cleanUrl = validatedUrl.origin;
|
||||
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
||||
return cleanUrl;
|
||||
} catch (e) {
|
||||
@@ -41,50 +138,13 @@ export const auth = betterAuth({
|
||||
return defaultUrl;
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
basePath: withBase("/api/auth"), // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
// This is where users can specify multiple access URLs
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
// Validate each additional origin
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and empty strings, then return
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
console.info('Trusted origins:', uniqueOrigins);
|
||||
return uniqueOrigins;
|
||||
})(),
|
||||
// Trusted origins - this is how we support multiple access URLs.
|
||||
// Uses the function form so that the origin can be auto-detected from
|
||||
// the incoming request's Host / X-Forwarded-* headers, which makes the
|
||||
// app work behind a reverse proxy without manual env var configuration.
|
||||
trustedOrigins: (request?: Request) => resolveTrustedOrigins(request),
|
||||
|
||||
// Authentication methods
|
||||
emailAndPassword: {
|
||||
@@ -115,28 +175,79 @@ export const auth = betterAuth({
|
||||
input: false, // Don't show in signup form - we'll derive from email
|
||||
}
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
// Email verification isn't wired up (sendResetPassword is a TODO),
|
||||
// so allow direct updates. Safe here because emails stay unverified.
|
||||
updateEmailWithoutVerification: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Account linking configuration.
|
||||
//
|
||||
// Lets a user who first registered with email/password sign in through SSO
|
||||
// and land on the *same* account, instead of being bounced back to /login.
|
||||
// Better Auth's auto-link path (link-account.mjs) refuses unless BOTH sides
|
||||
// pass:
|
||||
// - upstream: provider is "trusted" (either listed in `trustedProviders`
|
||||
// or the SSO plugin marks it trusted via domainVerified +
|
||||
// domain match) OR userInfo.emailVerified === true
|
||||
// - local: existing user is verified (or requireLocalEmailVerified=false)
|
||||
//
|
||||
// We don't wire an email-verification flow, so the local admin always has
|
||||
// emailVerified=false — `requireLocalEmailVerified: false` is required.
|
||||
//
|
||||
// We deliberately do NOT use the catch-all `trustedProviders` list (which
|
||||
// would blanket-trust every registered IdP). Instead the SSO provider's
|
||||
// own `domainVerified` flag — set to true at registration time, scoped to
|
||||
// the operator-supplied `domain` — gates linking. The SSO plugin enforces
|
||||
// `validateEmailDomain(userInfo.email, provider.domain)` on top of it, so
|
||||
// a sign-in is only auto-linked when (a) the operator vouched for the IdP
|
||||
// by registering it, and (b) the user's email actually belongs to the
|
||||
// domain that was vouched for. Cross-domain claims from a compromised or
|
||||
// permissive IdP do not silently absorb local accounts. (Same-domain
|
||||
// claims still require the operator to trust their IdP's identity model.)
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
requireLocalEmailVerified: false,
|
||||
// Keep the default (false): never link accounts whose emails differ.
|
||||
allowDifferentEmails: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins configuration
|
||||
plugins: [
|
||||
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
||||
oidcProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/oauth/consent",
|
||||
// JWT plugin — provides the JWKS keypair that the OAuth provider uses to
|
||||
// sign OIDC id_tokens with an asymmetric key (what most relying parties
|
||||
// expect). Required by @better-auth/oauth-provider unless disableJwtPlugin
|
||||
// is set. Keys are persisted in the `jwks` table.
|
||||
jwt(),
|
||||
|
||||
// OAuth 2.1 / OIDC Provider — allows this app to act as an identity
|
||||
// provider for other applications. Replaces the deprecated `oidcProvider`
|
||||
// plugin (which Better Auth will remove in a future release). Client and
|
||||
// consent records now live in the oauth_clients / oauth_consents tables.
|
||||
oauthProvider({
|
||||
loginPage: withBase("/login"),
|
||||
consentPage: withBase("/oauth/consent"),
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
// For now, we'll use dynamic registration
|
||||
// Customize user info claims based on scopes
|
||||
getAdditionalUserInfoClaim: (user, scopes) => {
|
||||
// Mirror the old getAdditionalUserInfoClaim: expose our extra `username`
|
||||
// field on both the userinfo endpoint and the id_token when the
|
||||
// "profile" scope is granted.
|
||||
customUserInfoClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) {
|
||||
claims.username = user.username;
|
||||
}
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
customIdTokenClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
// SSO plugin - allows users to authenticate with external OIDC providers
|
||||
sso({
|
||||
// Provision new users when they sign in with SSO
|
||||
@@ -168,7 +279,27 @@ export const auth = betterAuth({
|
||||
disableImplicitSignUp: false,
|
||||
// Trust email_verified claims from the upstream provider so we can link by matching email
|
||||
trustEmailVerified: true,
|
||||
// Surface `domainVerified` to the model so account-linking can read it.
|
||||
//
|
||||
// Better Auth's adapter transformOutput (factory.mjs) only copies fields
|
||||
// that are declared in the plugin's model schema; the SSO plugin only
|
||||
// declares `domainVerified` when this option is enabled. Without it the
|
||||
// column is in the DB but stripped from the returned provider object, so
|
||||
// the trust check `"domainVerified" in provider` is silently false and
|
||||
// sign-ins land on /?error=UNKNOWN. We don't use the DNS-based
|
||||
// verify-domain flow this option also exposes — we set
|
||||
// `domainVerified: true` directly in src/pages/api/auth/sso/register.ts
|
||||
// after the plugin's create, scoped by the operator-supplied `domain`.
|
||||
domainVerification: { enabled: true },
|
||||
}),
|
||||
|
||||
// Header / forward authentication bridge. Exposes
|
||||
// POST /api/auth/sign-in/header so the middleware can mint a real
|
||||
// Better Auth session from trusted upstream headers (Authentik /
|
||||
// Authelia / oauth2-proxy / Caddy). Without this the SPA's
|
||||
// /api/auth/get-session call returns null on header-auth-only
|
||||
// requests and bounces the user to /login. See auth-header-plugin.ts.
|
||||
headerAuthPlugin(),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+59
-4
@@ -3,6 +3,7 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { repairDuplicateSsoColumns, restoreSsoDataAfter0013 } from "./migration-repairs";
|
||||
|
||||
// Skip database initialization in test environment
|
||||
let db: ReturnType<typeof drizzle>;
|
||||
@@ -35,13 +36,54 @@ if (process.env.NODE_ENV !== "test") {
|
||||
// Create drizzle instance with the SQLite client
|
||||
db = drizzle({ client: sqlite });
|
||||
|
||||
/**
|
||||
* Fix migration records that were marked as applied but whose DDL actually
|
||||
* failed (e.g. the v3.13.0 release where ALTER TABLE with expression default
|
||||
* was rejected by SQLite). Without this, Drizzle skips the migration on
|
||||
* retry because it thinks it already ran.
|
||||
*
|
||||
* Drizzle tracks migrations by `created_at` (= journal timestamp) and only
|
||||
* looks at the most recent record. If the last recorded timestamp is >= the
|
||||
* failed migration's timestamp but the expected column is missing, we delete
|
||||
* stale records so the migration re-runs.
|
||||
*/
|
||||
function repairFailedMigrations() {
|
||||
try {
|
||||
const migrationsTableExists = sqlite
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
.get();
|
||||
|
||||
if (!migrationsTableExists) return;
|
||||
|
||||
// Migration 0009 journal timestamp (from drizzle/meta/_journal.json)
|
||||
const MIGRATION_0009_TIMESTAMP = 1773542995732;
|
||||
|
||||
const lastMigration = sqlite
|
||||
.query("SELECT id, created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1")
|
||||
.get() as { id: number; created_at: number } | null;
|
||||
|
||||
if (!lastMigration || Number(lastMigration.created_at) < MIGRATION_0009_TIMESTAMP) return;
|
||||
|
||||
// Migration 0009 is recorded as applied — verify the column actually exists
|
||||
const columns = sqlite.query("PRAGMA table_info(repositories)").all() as { name: string }[];
|
||||
const hasImportedAt = columns.some((c) => c.name === "imported_at");
|
||||
|
||||
if (!hasImportedAt) {
|
||||
console.log("🔧 Detected failed migration 0009 (imported_at column missing). Removing stale record so it can re-run...");
|
||||
sqlite.prepare("DELETE FROM __drizzle_migrations WHERE created_at >= ?").run(MIGRATION_0009_TIMESTAMP);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Migration repair check failed (non-fatal):", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Drizzle migrations
|
||||
*/
|
||||
function runDrizzleMigrations() {
|
||||
try {
|
||||
console.log("🔄 Checking for pending migrations...");
|
||||
|
||||
|
||||
// Check if migrations table exists
|
||||
const migrationsTableExists = sqlite
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
@@ -51,9 +93,20 @@ if (process.env.NODE_ENV !== "test") {
|
||||
console.log("📦 First time setup - running initial migrations...");
|
||||
}
|
||||
|
||||
// Fix any migrations that were recorded but actually failed (e.g. v3.13.0 bug)
|
||||
repairFailedMigrations();
|
||||
|
||||
// Fix the v3.17.0 duplicate-column crash: reconcile stranded sso_providers
|
||||
// columns so migration 0013 can run (see #312). Returns data to re-apply
|
||||
// once 0013 has re-added the columns.
|
||||
const preservedSsoData = repairDuplicateSsoColumns(sqlite);
|
||||
|
||||
// Run migrations using Drizzle migrate function
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
|
||||
|
||||
// Re-apply any SSO provider data preserved by the repair above.
|
||||
restoreSsoDataAfter0013(sqlite, preservedSsoData);
|
||||
|
||||
console.log("✅ Database migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Error running migrations:", error);
|
||||
@@ -79,9 +132,11 @@ export {
|
||||
accounts,
|
||||
verificationTokens,
|
||||
verifications,
|
||||
oauthApplications,
|
||||
oauthClients,
|
||||
oauthAccessTokens,
|
||||
oauthConsent,
|
||||
oauthRefreshTokens,
|
||||
oauthConsents,
|
||||
jwkss,
|
||||
ssoProviders,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
|
||||
/**
|
||||
* Pre-migration repairs that reconcile a database into the exact shape Drizzle's
|
||||
* migrator expects, so a previously-failed migration can complete on the next
|
||||
* boot. These run BEFORE `migrate()` and are deliberately defensive: any failure
|
||||
* is logged and swallowed so they never make a recoverable database worse.
|
||||
*/
|
||||
|
||||
/** Migration 0013 journal timestamp (from drizzle/meta/_journal.json, idx 13). */
|
||||
const MIGRATION_0013_TIMESTAMP = 1780377747526;
|
||||
|
||||
export type PreservedSsoRow = {
|
||||
id: string;
|
||||
saml_config?: string | null;
|
||||
domain_verified?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repair the v3.17.0 (PR #307) "duplicate column name: saml_config" crash loop
|
||||
* reported in issue #312.
|
||||
*
|
||||
* Some instances ended up with `sso_providers.saml_config` / `domain_verified`
|
||||
* already present BEFORE migration 0013 ran — the columns were declared in
|
||||
* schema.ts and entered the DB via `db:push` or an SSO-register round-trip on an
|
||||
* intermediate build, while `__drizzle_migrations` never recorded a 0013 row.
|
||||
*
|
||||
* Migration 0013 runs as a single transaction (organizations rebuild + the two
|
||||
* `ALTER TABLE sso_providers ADD ...`). The ADD hits the pre-existing column,
|
||||
* throws "duplicate column", and rolls back the ENTIRE transaction — so 0013 is
|
||||
* never recorded and is retried, failing identically, on every boot.
|
||||
*
|
||||
* This is the mirror image of the 0009 repair in index.ts (record present,
|
||||
* column missing): here the column is present but the record is missing. We
|
||||
* reconcile `sso_providers` back to its true pre-0013 shape so the canonical
|
||||
* 0013 can run in full (the organizations rebuild MUST NOT be skipped),
|
||||
* preserving any real SAML provider config across the drop/re-add.
|
||||
*
|
||||
* Returns the rows whose values must be re-applied by {@link restoreSsoDataAfter0013}
|
||||
* once 0013 has re-added the columns. Returns an empty array when there is
|
||||
* nothing to do (fresh install, clean upgrade, or genuine pre-0013 shape).
|
||||
*/
|
||||
export function repairDuplicateSsoColumns(sqlite: Database): PreservedSsoRow[] {
|
||||
try {
|
||||
const migrationsTableExists = sqlite
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
.get();
|
||||
|
||||
// Fresh install — no migrations recorded yet, vanilla migrate() handles it.
|
||||
if (!migrationsTableExists) return [];
|
||||
|
||||
// 0013 already recorded (clean upgrade / already healed) — nothing to do.
|
||||
const alreadyApplied = sqlite
|
||||
.query("SELECT 1 FROM __drizzle_migrations WHERE created_at >= ? LIMIT 1")
|
||||
.get(MIGRATION_0013_TIMESTAMP);
|
||||
if (alreadyApplied) return [];
|
||||
|
||||
const ssoExists = sqlite
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='sso_providers'")
|
||||
.get();
|
||||
if (!ssoExists) return [];
|
||||
|
||||
const cols = sqlite.query("PRAGMA table_info(sso_providers)").all() as { name: string }[];
|
||||
const hasSaml = cols.some((c) => c.name === "saml_config");
|
||||
const hasDomainVerified = cols.some((c) => c.name === "domain_verified");
|
||||
|
||||
// Genuine pre-0013 shape — let migration 0013 add the columns as-is.
|
||||
if (!hasSaml && !hasDomainVerified) return [];
|
||||
|
||||
console.log(
|
||||
"🔧 Detected stranded SSO columns (migration 0013 not recorded). Reconciling sso_providers so 0013 can run...",
|
||||
);
|
||||
|
||||
// Preserve any real data before dropping. SAML providers store JSON config
|
||||
// in saml_config; domain_verified may have been explicitly set to false.
|
||||
const selectCols = ["id"];
|
||||
if (hasSaml) selectCols.push("saml_config");
|
||||
if (hasDomainVerified) selectCols.push("domain_verified");
|
||||
const preserved = sqlite
|
||||
.query(`SELECT ${selectCols.join(", ")} FROM sso_providers`)
|
||||
.all() as PreservedSsoRow[];
|
||||
|
||||
// SQLite >= 3.35 (bun:sqlite ships much newer) supports DROP COLUMN.
|
||||
if (hasSaml) sqlite.run("ALTER TABLE sso_providers DROP COLUMN saml_config");
|
||||
if (hasDomainVerified) sqlite.run("ALTER TABLE sso_providers DROP COLUMN domain_verified");
|
||||
|
||||
// Only rows whose values differ from the 0013 defaults (saml_config NULL,
|
||||
// domain_verified true/1) need restoring after the columns are re-added.
|
||||
return preserved.filter(
|
||||
(r) => (hasSaml && r.saml_config != null) || (hasDomainVerified && r.domain_verified === 0),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ SSO column repair check failed (non-fatal):", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply the SSO provider values preserved by {@link repairDuplicateSsoColumns}
|
||||
* once migration 0013 has re-added saml_config / domain_verified with their
|
||||
* defaults (saml_config NULL, domain_verified = 1). No-op when nothing was
|
||||
* preserved (the common OIDC-only case).
|
||||
*/
|
||||
export function restoreSsoDataAfter0013(sqlite: Database, preserved: PreservedSsoRow[]): void {
|
||||
if (preserved.length === 0) return;
|
||||
try {
|
||||
const stmt = sqlite.prepare(
|
||||
"UPDATE sso_providers SET saml_config = ?, domain_verified = ? WHERE id = ?",
|
||||
);
|
||||
for (const r of preserved) {
|
||||
stmt.run(r.saml_config ?? null, r.domain_verified ?? 1, r.id);
|
||||
}
|
||||
console.log(`✅ Restored ${preserved.length} preserved SSO provider value(s) after migration 0013.`);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Failed to restore preserved SSO data (non-fatal):", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
function decodeOutput(output: ArrayBufferLike | Uint8Array | null | undefined) {
|
||||
if (!output) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Buffer.from(output as ArrayBufferLike).toString("utf8");
|
||||
}
|
||||
|
||||
test("migration validation script passes", () => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ["bun", "scripts/validate-migrations.ts"],
|
||||
cwd: process.cwd(),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = decodeOutput(result.stdout);
|
||||
const stderr = decodeOutput(result.stderr);
|
||||
|
||||
expect(
|
||||
result.exitCode,
|
||||
`Migration validation script failed.\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
).toBe(0);
|
||||
});
|
||||
+145
-47
@@ -23,15 +23,18 @@ export const githubConfigSchema = z.object({
|
||||
includeArchived: z.boolean().default(false),
|
||||
includePrivate: z.boolean().default(true),
|
||||
includePublic: z.boolean().default(true),
|
||||
includeCollaboratorRepos: z.boolean().default(true),
|
||||
includeOrganizations: z.array(z.string()).default([]),
|
||||
starredReposOrg: z.string().optional(),
|
||||
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"),
|
||||
starredLists: z.array(z.string()).default([]),
|
||||
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
|
||||
defaultOrg: z.string().optional(),
|
||||
starredCodeOnly: z.boolean().default(false),
|
||||
autoMirrorStarred: z.boolean().default(false),
|
||||
skipStarredIssues: z.boolean().optional(), // Deprecated: kept for backward compatibility, use starredCodeOnly instead
|
||||
starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(),
|
||||
skipPersonalRepos: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const backupStrategyEnum = z.enum([
|
||||
@@ -75,7 +78,8 @@ export const giteaConfigSchema = z.object({
|
||||
mirrorMilestones: z.boolean().default(false),
|
||||
backupStrategy: backupStrategyEnum.default("on-force-push"),
|
||||
backupBeforeSync: z.boolean().default(true), // Deprecated: kept for backward compat, use backupStrategy
|
||||
backupRetentionCount: z.number().int().min(1).default(20),
|
||||
backupRetentionCount: z.number().int().min(1).default(5),
|
||||
backupRetentionDays: z.number().int().min(0).default(30),
|
||||
backupDirectory: z.string().optional(),
|
||||
blockSyncOnBackupFailure: z.boolean().default(true),
|
||||
});
|
||||
@@ -121,6 +125,31 @@ export const cleanupConfigSchema = z.object({
|
||||
nextRun: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const ntfyConfigSchema = z.object({
|
||||
url: z.string().default("https://ntfy.sh"),
|
||||
topic: z.string().default(""),
|
||||
token: z.string().optional(),
|
||||
priority: z.enum(["min", "low", "default", "high", "urgent"]).default("default"),
|
||||
});
|
||||
|
||||
export const appriseConfigSchema = z.object({
|
||||
url: z.string().default(""),
|
||||
token: z.string().default(""),
|
||||
tag: z.string().optional(),
|
||||
});
|
||||
|
||||
export const notificationConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
|
||||
notifyOnSyncError: z.boolean().default(true),
|
||||
notifyOnSyncSuccess: z.boolean().default(false),
|
||||
notifyOnNewRepo: z.boolean().default(false),
|
||||
ntfy: ntfyConfigSchema.optional(),
|
||||
apprise: appriseConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
|
||||
|
||||
export const configSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
@@ -128,7 +157,9 @@ export const configSchema = z.object({
|
||||
isActive: z.boolean().default(true),
|
||||
githubConfig: githubConfigSchema,
|
||||
giteaConfig: giteaConfigSchema,
|
||||
// Unused/reserved — stored for future glob support but not currently read
|
||||
include: z.array(z.string()).default(["*"]),
|
||||
// Unused/reserved — stored for future glob support but not currently read
|
||||
exclude: z.array(z.string()).default([]),
|
||||
scheduleConfig: scheduleConfigSchema,
|
||||
cleanupConfig: cleanupConfigSchema,
|
||||
@@ -336,6 +367,11 @@ export const configs = sqliteTable("configs", {
|
||||
.$type<z.infer<typeof cleanupConfigSchema>>()
|
||||
.notNull(),
|
||||
|
||||
notificationConfig: text("notification_config", { mode: "json" })
|
||||
.$type<z.infer<typeof notificationConfigSchema>>()
|
||||
.notNull()
|
||||
.default(sql`'{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}'`),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
@@ -417,6 +453,7 @@ export const repositories = sqliteTable("repositories", {
|
||||
index("idx_repositories_user_imported_at").on(table.userId, table.importedAt),
|
||||
uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName),
|
||||
uniqueIndex("uniq_repositories_user_normalized_full_name").on(table.userId, table.normalizedFullName),
|
||||
index("idx_repositories_mirrored_location").on(table.userId, table.mirroredLocation),
|
||||
]);
|
||||
|
||||
export const mirrorJobs = sqliteTable("mirror_jobs", {
|
||||
@@ -584,69 +621,121 @@ export const verifications = sqliteTable("verifications", {
|
||||
|
||||
// ===== OIDC Provider Tables =====
|
||||
|
||||
// OAuth Applications table
|
||||
export const oauthApplications = sqliteTable("oauth_applications", {
|
||||
// ===== OAuth 2.1 / OIDC Provider tables (@better-auth/oauth-provider) =====
|
||||
//
|
||||
// These back the OAuth/OIDC *provider* feature (gitea-mirror acting as an
|
||||
// identity provider for other apps). They are managed entirely by Better
|
||||
// Auth's drizzle adapter, so:
|
||||
// - the exported binding name must equal the plugin model name pluralized
|
||||
// under `usePlural: true` (oauthClient -> oauthClients, jwks -> jwkss);
|
||||
// - the object property names must match the plugin field names (camelCase),
|
||||
// while the SQL column names may be snake_case;
|
||||
// - `string[]` and `json` fields are serialized to JSON text by the adapter,
|
||||
// so they are plain `text` columns here.
|
||||
//
|
||||
// Migrated from the deprecated `oidc-provider` plugin (tables
|
||||
// oauth_applications / oauth_access_tokens / oauth_consent). See the
|
||||
// accompanying Drizzle migration for the data-preserving upgrade path.
|
||||
|
||||
// OAuth clients (replaces the old `oauth_applications` table)
|
||||
export const oauthClients = sqliteTable("oauth_clients", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull().unique(),
|
||||
clientSecret: text("client_secret").notNull(),
|
||||
name: text("name").notNull(),
|
||||
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
|
||||
metadata: text("metadata"), // JSON string
|
||||
type: text("type").notNull(), // web, mobile, etc
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
userId: text("user_id"), // Optional - owner of the application
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
clientSecret: text("client_secret"),
|
||||
name: text("name"),
|
||||
disabled: integer("disabled", { mode: "boolean" }).default(false),
|
||||
skipConsent: integer("skip_consent", { mode: "boolean" }),
|
||||
enableEndSession: integer("enable_end_session", { mode: "boolean" }),
|
||||
subjectType: text("subject_type"),
|
||||
scopes: text("scopes"), // JSON string[]
|
||||
userId: text("user_id").references(() => users.id),
|
||||
uri: text("uri"),
|
||||
icon: text("icon"),
|
||||
contacts: text("contacts"), // JSON string[]
|
||||
tos: text("tos"),
|
||||
policy: text("policy"),
|
||||
softwareId: text("software_id"),
|
||||
softwareVersion: text("software_version"),
|
||||
softwareStatement: text("software_statement"),
|
||||
redirectUris: text("redirect_uris").notNull(), // JSON string[]
|
||||
postLogoutRedirectUris: text("post_logout_redirect_uris"), // JSON string[]
|
||||
tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
|
||||
grantTypes: text("grant_types"), // JSON string[]
|
||||
responseTypes: text("response_types"), // JSON string[]
|
||||
public: integer("public", { mode: "boolean" }),
|
||||
type: text("type"),
|
||||
requirePKCE: integer("require_pkce", { mode: "boolean" }),
|
||||
referenceId: text("reference_id"),
|
||||
metadata: text("metadata"), // JSON
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_applications_client_id").on(table.clientId),
|
||||
index("idx_oauth_applications_user_id").on(table.userId),
|
||||
index("idx_oauth_clients_client_id").on(table.clientId),
|
||||
index("idx_oauth_clients_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Access Tokens table
|
||||
// OAuth access tokens
|
||||
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
||||
token: text("token").unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
refreshId: text("refresh_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_access_tokens_access_token").on(table.accessToken),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
index("idx_oauth_access_tokens_token").on(table.token),
|
||||
index("idx_oauth_access_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Consent table
|
||||
export const oauthConsent = sqliteTable("oauth_consent", {
|
||||
// OAuth refresh tokens (new in the OAuth 2.1 provider)
|
||||
export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
token: text("token").notNull().unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
revoked: integer("revoked", { mode: "timestamp" }),
|
||||
authTime: integer("auth_time", { mode: "timestamp" }),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_consent_user_id").on(table.userId),
|
||||
index("idx_oauth_consent_client_id").on(table.clientId),
|
||||
index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
|
||||
index("idx_oauth_refresh_tokens_token").on(table.token),
|
||||
index("idx_oauth_refresh_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_refresh_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth consent records
|
||||
export const oauthConsents = sqliteTable("oauth_consents", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_consents_client_id").on(table.clientId),
|
||||
index("idx_oauth_consents_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// JWKS keypairs for signing OIDC id_tokens (better-auth `jwt` plugin).
|
||||
// Model name "jwks" pluralizes to the binding name "jwkss" under usePlural,
|
||||
// while the physical table stays "jwks".
|
||||
export const jwkss = sqliteTable("jwks", {
|
||||
id: text("id").primaryKey(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
// ===== SSO Provider Tables =====
|
||||
|
||||
// SSO Providers table
|
||||
@@ -655,6 +744,15 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
issuer: text("issuer").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
|
||||
// The upgraded @better-auth/sso plugin writes this on every insert (null for OIDC providers).
|
||||
// Drizzle's adapter rejects unknown fields, so the column must exist.
|
||||
samlConfig: text("saml_config"),
|
||||
// Used by the SSO plugin's account-linking trust check: a sign-in is treated
|
||||
// as trusted when this is true AND the user's email domain matches `domain`
|
||||
// above. We set this to true on register (see /api/auth/sso/register.ts) so
|
||||
// domain-scoped auto-linking works out of the box; the column default keeps
|
||||
// existing rows trusted after upgrade.
|
||||
domainVerified: integer("domain_verified", { mode: "boolean" }).notNull().default(true),
|
||||
userId: text("user_id").notNull(), // Admin who created this provider
|
||||
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
|
||||
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { db, configs, users } from '@/lib/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { encrypt } from '@/lib/utils/encryption';
|
||||
|
||||
@@ -15,6 +15,7 @@ interface EnvConfig {
|
||||
type?: 'personal' | 'organization';
|
||||
privateRepositories?: boolean;
|
||||
publicRepositories?: boolean;
|
||||
includeCollaboratorRepos?: boolean;
|
||||
mirrorStarred?: boolean;
|
||||
skipForks?: boolean;
|
||||
includeArchived?: boolean;
|
||||
@@ -25,6 +26,7 @@ interface EnvConfig {
|
||||
autoMirrorStarred?: boolean;
|
||||
starredReposOrg?: string;
|
||||
starredReposMode?: 'dedicated-org' | 'preserve-owner';
|
||||
starredLists?: string[];
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
gitea: {
|
||||
@@ -99,6 +101,9 @@ function parseEnvConfig(): EnvConfig {
|
||||
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
|
||||
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const starredLists = process.env.MIRROR_STARRED_LISTS
|
||||
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
github: {
|
||||
@@ -107,6 +112,11 @@ function parseEnvConfig(): EnvConfig {
|
||||
type: process.env.GITHUB_TYPE as 'personal' | 'organization',
|
||||
privateRepositories: process.env.PRIVATE_REPOSITORIES === 'true',
|
||||
publicRepositories: process.env.PUBLIC_REPOSITORIES === 'true',
|
||||
// Tri-state parse so unset falls through to existingConfig / schema default (true).
|
||||
includeCollaboratorRepos:
|
||||
process.env.INCLUDE_COLLABORATOR_REPOS === 'true' ? true
|
||||
: process.env.INCLUDE_COLLABORATOR_REPOS === 'false' ? false
|
||||
: undefined,
|
||||
mirrorStarred: process.env.MIRROR_STARRED === 'true',
|
||||
skipForks: process.env.SKIP_FORKS === 'true',
|
||||
includeArchived: process.env.INCLUDE_ARCHIVED === 'true',
|
||||
@@ -117,6 +127,7 @@ function parseEnvConfig(): EnvConfig {
|
||||
autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
|
||||
starredLists,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
gitea: {
|
||||
@@ -219,10 +230,12 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
|
||||
console.log('[ENV Config Loader] Found environment configuration, initializing...');
|
||||
|
||||
// Get the first user (admin user)
|
||||
// Get the first user (admin user) — deterministic order so we always pick the
|
||||
// same row across restarts even if multiple users exist.
|
||||
const firstUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(sql`${users.createdAt} ASC`)
|
||||
.limit(1);
|
||||
|
||||
if (firstUser.length === 0) {
|
||||
@@ -232,11 +245,14 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
|
||||
const userId = firstUser[0].id;
|
||||
|
||||
// Check if config already exists for this user
|
||||
// Check if config already exists for this user — prefer the active config and
|
||||
// fall back to most-recently-updated so we never write env values into a stale
|
||||
// inactive stub while the populated active row sits untouched (see issue #271).
|
||||
const existingConfig = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
// Determine mirror strategy based on environment variables or use explicit value
|
||||
@@ -260,6 +276,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
includeArchived: envConfig.github.includeArchived ?? existingConfig?.[0]?.githubConfig?.includeArchived ?? false,
|
||||
includePrivate: envConfig.github.privateRepositories ?? existingConfig?.[0]?.githubConfig?.includePrivate ?? false,
|
||||
includePublic: envConfig.github.publicRepositories ?? existingConfig?.[0]?.githubConfig?.includePublic ?? true,
|
||||
includeCollaboratorRepos: envConfig.github.includeCollaboratorRepos ?? existingConfig?.[0]?.githubConfig?.includeCollaboratorRepos ?? true,
|
||||
includeOrganizations: envConfig.github.mirrorOrganizations ? [] : (existingConfig?.[0]?.githubConfig?.includeOrganizations ?? []),
|
||||
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
|
||||
starredReposMode: envConfig.github.starredReposMode || existingConfig?.[0]?.githubConfig?.starredReposMode || 'dedicated-org',
|
||||
@@ -267,6 +284,9 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
|
||||
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
|
||||
autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false,
|
||||
starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [],
|
||||
// ONLY_MIRROR_ORGS=true maps to skipPersonalRepos: true
|
||||
skipPersonalRepos: envConfig.github.onlyMirrorOrgs ?? existingConfig?.[0]?.githubConfig?.skipPersonalRepos ?? false,
|
||||
};
|
||||
|
||||
// Build Gitea config
|
||||
@@ -302,6 +322,13 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
|
||||
mirrorLabels: envConfig.mirror.mirrorLabels ?? existingConfig?.[0]?.giteaConfig?.mirrorLabels ?? false,
|
||||
mirrorMilestones: envConfig.mirror.mirrorMilestones ?? existingConfig?.[0]?.giteaConfig?.mirrorMilestones ?? false,
|
||||
// Backup options — preserve existing values so UI-configured settings survive restart
|
||||
backupStrategy: existingConfig?.[0]?.giteaConfig?.backupStrategy ?? 'on-force-push',
|
||||
backupBeforeSync: existingConfig?.[0]?.giteaConfig?.backupBeforeSync ?? true,
|
||||
backupRetentionCount: existingConfig?.[0]?.giteaConfig?.backupRetentionCount ?? 5,
|
||||
backupRetentionDays: existingConfig?.[0]?.giteaConfig?.backupRetentionDays ?? 30,
|
||||
backupDirectory: existingConfig?.[0]?.giteaConfig?.backupDirectory || undefined,
|
||||
blockSyncOnBackupFailure: existingConfig?.[0]?.giteaConfig?.blockSyncOnBackupFailure ?? true,
|
||||
};
|
||||
|
||||
// Build schedule config with support for interval as string or number
|
||||
|
||||
@@ -555,6 +555,63 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(releaseCall.octokit).toBeDefined();
|
||||
});
|
||||
|
||||
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
|
||||
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("ceph"));
|
||||
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo789",
|
||||
name: "test-repo",
|
||||
fullName: "ceph/test-repo",
|
||||
owner: "ceph",
|
||||
cloneUrl: "https://github.com/ceph/test-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
mirroredLocation: "starred/test-repo",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("/mirror-sync")
|
||||
);
|
||||
expect(mirrorSyncCalls).toHaveLength(1);
|
||||
expect(String(mirrorSyncCalls[0][0])).toContain("/api/v1/repos/starred/test-repo/mirror-sync");
|
||||
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
|
||||
});
|
||||
|
||||
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
|
||||
mockShouldCreatePreSyncBackup = true;
|
||||
mockShouldBlockSyncOnBackupFailure = true;
|
||||
@@ -575,7 +632,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: false,
|
||||
backupBeforeSync: true,
|
||||
backupStrategy: "always",
|
||||
blockSyncOnBackupFailure: true,
|
||||
},
|
||||
};
|
||||
@@ -732,7 +789,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("continues incremental issue and PR syncing when metadata was previously synced", async () => {
|
||||
test("skips issues and PRs when metadata shows they were already synced", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
@@ -791,11 +848,15 @@ describe("Enhanced Gitea Operations", () => {
|
||||
}
|
||||
);
|
||||
|
||||
// Metadata reconciliation now runs on every sync (mirror* functions
|
||||
// are idempotent and PATCH existing entries by marker/name/title).
|
||||
// Releases are still skipped here because the flag is off in this config.
|
||||
// Labels are still skipped because the issues path also handles labels.
|
||||
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1);
|
||||
expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1);
|
||||
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGitRepoMilestonesToGitea).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+228
-58
@@ -8,7 +8,8 @@
|
||||
|
||||
import type { Config } from "@/types/config";
|
||||
import type { Repository } from "./db/schema";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createGitHubClient } from "./github";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
|
||||
@@ -42,16 +43,56 @@ type SyncDependencies = {
|
||||
/**
|
||||
* Enhanced repository information including mirror status
|
||||
*/
|
||||
interface GiteaRepoInfo {
|
||||
export interface GiteaRepoInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: { login: string } | string;
|
||||
mirror: boolean;
|
||||
mirror_interval?: string;
|
||||
clone_url?: string;
|
||||
// Original migration source URL. Gitea/Forgejo populate this with the
|
||||
// upstream clone address for migrated/mirrored repos, so it is the
|
||||
// authoritative way to tell whether an existing mirror points at THIS
|
||||
// GitHub source (vs. a same-named mirror of a different source).
|
||||
original_url?: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
interface SyncTargetCandidate {
|
||||
owner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
|
||||
if (!location) return null;
|
||||
|
||||
const trimmed = location.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return null;
|
||||
|
||||
const owner = trimmed.slice(0, slashIndex).trim();
|
||||
const repoName = trimmed.slice(slashIndex + 1).trim();
|
||||
if (!owner || !repoName) return null;
|
||||
|
||||
return { owner, repoName };
|
||||
}
|
||||
|
||||
function dedupeSyncTargets(targets: SyncTargetCandidate[]): SyncTargetCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: SyncTargetCandidate[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const key = `${target.owner}/${target.repoName}`.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(target);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a repository exists in Gitea and return its details
|
||||
*/
|
||||
@@ -285,19 +326,78 @@ export async function syncGiteaRepoEnhanced({
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
// Get the expected owner
|
||||
// Resolve sync target in a backward-compatible order:
|
||||
// 1) recorded mirroredLocation (actual historical mirror location)
|
||||
// 2) owner derived from current strategy/config
|
||||
const dependencies = deps ?? (await import("./gitea"));
|
||||
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
||||
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
|
||||
const candidateTargets = dedupeSyncTargets([
|
||||
...(recordedTarget ? [recordedTarget] : []),
|
||||
{ owner: expectedOwner, repoName: repository.name },
|
||||
]);
|
||||
|
||||
// Check if repo exists and get its info
|
||||
const repoInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
let repoOwner = expectedOwner;
|
||||
let repoName = repository.name;
|
||||
let repoInfo: GiteaRepoInfo | null = null;
|
||||
let firstNonMirrorTarget: SyncTargetCandidate | null = null;
|
||||
|
||||
for (const target of candidateTargets) {
|
||||
const candidateInfo = await getGiteaRepoInfo({
|
||||
config,
|
||||
owner: target.owner,
|
||||
repoName: target.repoName,
|
||||
});
|
||||
|
||||
if (!candidateInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidateInfo.mirror) {
|
||||
if (!firstNonMirrorTarget) {
|
||||
firstNonMirrorTarget = target;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
repoOwner = target.owner;
|
||||
repoName = target.repoName;
|
||||
repoInfo = candidateInfo;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!repoInfo) {
|
||||
throw new Error(`Repository ${repository.name} not found in Gitea at ${repoOwner}/${repository.name}`);
|
||||
if (firstNonMirrorTarget) {
|
||||
console.warn(
|
||||
`[Sync] Repository ${repository.name} exists at ${firstNonMirrorTarget.owner}/${firstNonMirrorTarget.repoName} but is not configured as a mirror`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
status: repoStatusEnum.parse("failed"),
|
||||
updatedAt: new Date(),
|
||||
errorMessage: "Repository exists in Gitea but is not configured as a mirror. Manual intervention required.",
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
await createMirrorJob({
|
||||
userId: config.userId,
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Cannot sync ${repository.name}: Not a mirror repository`,
|
||||
details: `Repository ${repository.name} exists in Gitea but is not configured as a mirror. You may need to delete and recreate it as a mirror, or manually configure it as a mirror in Gitea.`,
|
||||
status: "failed",
|
||||
});
|
||||
|
||||
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Repository ${repository.name} not found in Gitea. Tried locations: ${candidateTargets
|
||||
.map((t) => `${t.owner}/${t.repoName}`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a mirror repository
|
||||
@@ -326,9 +426,18 @@ export async function syncGiteaRepoEnhanced({
|
||||
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
|
||||
}
|
||||
|
||||
// Parse repository metadata state up-front. The force-push
|
||||
// detection below needs the acknowledgedDeletions list to suppress
|
||||
// already-handled deletions, and a successful snapshot needs to
|
||||
// record new entries back into the same state object. The
|
||||
// metadata-mirroring block downstream reuses this same variable.
|
||||
const metadataState = parseRepositoryMetadataState(repository.metadata);
|
||||
let metadataUpdated = false;
|
||||
|
||||
// ---- Smart backup strategy with force-push detection ----
|
||||
const backupStrategy = resolveBackupStrategy(config);
|
||||
let forcePushDetected = false;
|
||||
let forcePushAffected: ReadonlyArray<{ name: string; reason: string; giteaSha: string }> = [];
|
||||
|
||||
if (backupStrategy !== "disabled") {
|
||||
// Run force-push detection if the strategy requires it
|
||||
@@ -337,18 +446,20 @@ export async function syncGiteaRepoEnhanced({
|
||||
try {
|
||||
const decryptedGithubToken = decryptedConfig.githubConfig?.token;
|
||||
if (decryptedGithubToken) {
|
||||
const fpOctokit = new Octokit({ auth: decryptedGithubToken });
|
||||
const fpOctokit = createGitHubClient(decryptedGithubToken);
|
||||
const detectionResult = await detectForcePush({
|
||||
giteaUrl: config.giteaConfig.url,
|
||||
giteaToken: decryptedConfig.giteaConfig.token,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepo: repository.name,
|
||||
giteaRepo: repoName,
|
||||
octokit: fpOctokit,
|
||||
githubOwner: repository.owner,
|
||||
githubRepo: repository.name,
|
||||
acknowledgedDeletions: metadataState.acknowledgedDeletions,
|
||||
});
|
||||
|
||||
forcePushDetected = detectionResult.detected;
|
||||
forcePushAffected = detectionResult.affectedBranches;
|
||||
|
||||
if (detectionResult.skipped) {
|
||||
console.log(
|
||||
@@ -362,6 +473,39 @@ export async function syncGiteaRepoEnhanced({
|
||||
`[Sync] Force-push detected on ${repository.name}: ${branchNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Record each detected "deleted" branch into the
|
||||
// acknowledged list. We do this based on detection alone,
|
||||
// BEFORE the backup attempt below, so that:
|
||||
// - concurrent sync invocations both add the entry
|
||||
// (the second one whose `createPreSyncBundleBackup`
|
||||
// short-circuits would otherwise skip the push and
|
||||
// race its empty in-memory state onto the metadata
|
||||
// row, undoing the first invocation's write);
|
||||
// - if the backup later fails the deletion is still
|
||||
// acknowledged (the branch is genuinely gone from
|
||||
// GitHub; not re-detecting it next sync is correct
|
||||
// regardless of whether THIS invocation's backup
|
||||
// succeeded — prior backups still exist on disk and
|
||||
// the user can re-trigger one manually if needed).
|
||||
const newAcknowledged = forcePushAffected
|
||||
.filter((b) => b.reason === "deleted")
|
||||
.map((b) => ({ branch: b.name, giteaSha: b.giteaSha }));
|
||||
if (newAcknowledged.length > 0) {
|
||||
const existingKeys = new Set(
|
||||
metadataState.acknowledgedDeletions.map(
|
||||
(e) => `${e.branch}@${e.giteaSha}`,
|
||||
),
|
||||
);
|
||||
for (const entry of newAcknowledged) {
|
||||
const key = `${entry.branch}@${entry.giteaSha}`;
|
||||
if (!existingKeys.has(key)) {
|
||||
metadataState.acknowledgedDeletions.push(entry);
|
||||
existingKeys.add(key);
|
||||
metadataUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[Sync] Skipping force-push detection for ${repository.name}: no GitHub token`,
|
||||
@@ -405,15 +549,18 @@ export async function syncGiteaRepoEnhanced({
|
||||
|
||||
// Create backup if strategy says so
|
||||
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
|
||||
const cloneUrl =
|
||||
repoInfo.clone_url ||
|
||||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`;
|
||||
// Always derive the clone URL from the user-configured Gitea URL rather
|
||||
// than repoInfo.clone_url (which reflects Gitea's ROOT_URL and may be
|
||||
// unreachable from the app — e.g. Tailscale MagicDNS deployments where
|
||||
// ROOT_URL resolves externally but the app talks to Gitea on a private
|
||||
// address).
|
||||
const cloneUrl = `${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repoName}.git`;
|
||||
|
||||
try {
|
||||
const backupResult = await createPreSyncBundleBackup({
|
||||
config,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
repoName,
|
||||
cloneUrl,
|
||||
force: true, // Strategy already decided to backup; skip legacy gate
|
||||
});
|
||||
@@ -424,8 +571,17 @@ export async function syncGiteaRepoEnhanced({
|
||||
repositoryName: repository.name,
|
||||
message: `Snapshot created for ${repository.name}`,
|
||||
details: `Pre-sync snapshot created at ${backupResult.bundlePath}.`,
|
||||
status: "syncing",
|
||||
// The snapshot is already complete (createPreSyncBundleBackup
|
||||
// returned above). Using "syncing" here left the row in a
|
||||
// non-terminal state and there was no later code path to
|
||||
// advance it, so every force-push-triggered backup leaked an
|
||||
// orphan row that accumulated on the jobs page forever.
|
||||
status: "synced",
|
||||
});
|
||||
// Note: acknowledging the deletion happens earlier (right
|
||||
// after detection) so concurrent sync invocations both
|
||||
// record the entry. Don't move it back here — see the
|
||||
// detection block above for the rationale.
|
||||
} catch (backupError) {
|
||||
const errorMessage =
|
||||
backupError instanceof Error ? backupError.message : String(backupError);
|
||||
@@ -462,32 +618,36 @@ export async function syncGiteaRepoEnhanced({
|
||||
}
|
||||
|
||||
// Update mirror interval if needed
|
||||
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
|
||||
// updating mirror credentials (mirror_username/mirror_password). Repos that
|
||||
// were originally migrated without credentials must be deleted and re-mirrored.
|
||||
if (config.giteaConfig?.mirrorInterval) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`;
|
||||
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
|
||||
await httpPatch(updateUrl, {
|
||||
mirror_interval: config.giteaConfig.mirrorInterval,
|
||||
}, {
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repoOwner}/${repoName}`);
|
||||
} catch (updateError) {
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError);
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repoOwner}/${repoName}:`, updateError);
|
||||
// Continue with sync even if interval update fails
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the sync
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
|
||||
|
||||
try {
|
||||
const response = await httpPost(apiUrl, undefined, {
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
|
||||
const metadataState = parseRepositoryMetadataState(repository.metadata);
|
||||
let metadataUpdated = false;
|
||||
// metadataState + metadataUpdated are hoisted above the backup
|
||||
// strategy block so force-push detection can read/write the
|
||||
// acknowledged-deletions list. Don't shadow them here.
|
||||
const skipMetadataForStarred =
|
||||
repository.isStarred && config.githubConfig?.starredCodeOnly;
|
||||
let metadataOctokit: Octokit | null = null;
|
||||
@@ -499,29 +659,27 @@ export async function syncGiteaRepoEnhanced({
|
||||
if (!decryptedConfig.githubConfig?.token) {
|
||||
return null;
|
||||
}
|
||||
metadataOctokit = new Octokit({
|
||||
auth: decryptedConfig.githubConfig.token,
|
||||
});
|
||||
metadataOctokit = createGitHubClient(decryptedConfig.githubConfig.token);
|
||||
return metadataOctokit;
|
||||
};
|
||||
|
||||
// Reconcile metadata on every sync (matches the release path).
|
||||
// The underlying mirror* functions are idempotent: issues/PRs are
|
||||
// matched via [GH-ISSUE #N] / [GH-PR #N] markers and PATCHed in
|
||||
// place, labels are deduped by name, milestones by title.
|
||||
const shouldMirrorReleases =
|
||||
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
|
||||
const shouldMirrorIssuesThisRun =
|
||||
!!config.giteaConfig?.mirrorIssues &&
|
||||
!skipMetadataForStarred;
|
||||
!!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred;
|
||||
const shouldMirrorPullRequests =
|
||||
!!config.giteaConfig?.mirrorPullRequests &&
|
||||
!skipMetadataForStarred;
|
||||
!!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred;
|
||||
// Labels-only path; issues run already creates/reconciles labels.
|
||||
const shouldMirrorLabels =
|
||||
!!config.giteaConfig?.mirrorLabels &&
|
||||
!skipMetadataForStarred &&
|
||||
!shouldMirrorIssuesThisRun &&
|
||||
!metadataState.components.labels;
|
||||
!shouldMirrorIssuesThisRun;
|
||||
const shouldMirrorMilestones =
|
||||
!!config.giteaConfig?.mirrorMilestones &&
|
||||
!skipMetadataForStarred &&
|
||||
!metadataState.components.milestones;
|
||||
!!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred;
|
||||
|
||||
if (shouldMirrorReleases) {
|
||||
const octokit = ensureOctokit();
|
||||
@@ -536,7 +694,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.releases = true;
|
||||
metadataUpdated = true;
|
||||
@@ -568,7 +726,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.issues = true;
|
||||
metadataState.components.labels = true;
|
||||
@@ -601,7 +759,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.pullRequests = true;
|
||||
metadataUpdated = true;
|
||||
@@ -631,7 +789,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.labels = true;
|
||||
metadataUpdated = true;
|
||||
@@ -648,13 +806,6 @@ export async function syncGiteaRepoEnhanced({
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
config.giteaConfig?.mirrorLabels &&
|
||||
metadataState.components.labels
|
||||
) {
|
||||
console.log(
|
||||
`[Sync] Labels already mirrored for ${repository.name}; skipping`
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldMirrorMilestones) {
|
||||
@@ -670,7 +821,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repository.name,
|
||||
giteaRepoName: repoName,
|
||||
});
|
||||
metadataState.components.milestones = true;
|
||||
metadataUpdated = true;
|
||||
@@ -687,13 +838,6 @@ export async function syncGiteaRepoEnhanced({
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
config.giteaConfig?.mirrorMilestones &&
|
||||
metadataState.components.milestones
|
||||
) {
|
||||
console.log(
|
||||
`[Sync] Milestones already mirrored for ${repository.name}; skipping`
|
||||
);
|
||||
}
|
||||
|
||||
if (metadataUpdated) {
|
||||
@@ -708,7 +852,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
updatedAt: new Date(),
|
||||
lastMirrored: new Date(),
|
||||
errorMessage: null,
|
||||
mirroredLocation: `${repoOwner}/${repository.name}`,
|
||||
mirroredLocation: `${repoOwner}/${repoName}`,
|
||||
metadata: metadataUpdated
|
||||
? serializeRepositoryMetadataState(metadataState)
|
||||
: repository.metadata ?? null,
|
||||
@@ -720,7 +864,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Sync requested for repository: ${repository.name}`,
|
||||
details: `Mirror sync was requested for ${repository.name}. Gitea/Forgejo performs the actual pull asynchronously; check remote logs for pull errors.`,
|
||||
details: `Mirror sync was requested for ${repoOwner}/${repoName}.`,
|
||||
status: "synced",
|
||||
});
|
||||
|
||||
@@ -750,6 +894,32 @@ export async function syncGiteaRepoEnhanced({
|
||||
status: "failed",
|
||||
});
|
||||
}
|
||||
} else if (syncError instanceof HttpError && syncError.status === 405) {
|
||||
// Gitea returns HTTP 405 (with an empty body) when the repository is not
|
||||
// a pull-mirror in its database — e.g. Gitea auto-disabled the mirror or
|
||||
// the repo lost its mirror state after a manual edit.
|
||||
const actionableMessage =
|
||||
`Gitea reports this repository is not a pull mirror (HTTP 405). ` +
|
||||
`In Gitea check Settings → Mirror Settings; if the mirror section is ` +
|
||||
`missing, delete the repository in Gitea and re-mirror it from gitea-mirror.`;
|
||||
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({
|
||||
status: repoStatusEnum.parse("failed"),
|
||||
updatedAt: new Date(),
|
||||
errorMessage: actionableMessage,
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
await createMirrorJob({
|
||||
userId: config.userId,
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Sync failed: ${repository.name} is not a pull mirror in Gitea (HTTP 405)`,
|
||||
details: actionableMessage,
|
||||
status: "failed",
|
||||
});
|
||||
}
|
||||
throw syncError;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Regression test for duplicate-issue creation on retry-after-deadlock.
|
||||
*
|
||||
* `mirrorGitRepoIssuesToGitea` pre-fetches all existing Gitea issues
|
||||
* into `giteaIssueByGitHubNumber` ONCE at function entry, then iterates
|
||||
* per-issue via `processWithRetry`. Each iteration uses the cached map
|
||||
* to decide CREATE vs PATCH.
|
||||
*
|
||||
* The bug: when Gitea's CreateIssue handler commits the issue insert
|
||||
* in one transaction and then deadlocks on the addLabel / repository
|
||||
* counter update in a second transaction, the issue row is committed
|
||||
* and visible — but the in-memory map is never refreshed between
|
||||
* retries. So `processWithRetry` would call the callback again,
|
||||
* `existingIssue` is still `undefined` from the stale map, and a fresh
|
||||
* `httpPost` creates a duplicate.
|
||||
*
|
||||
* Reproduces deterministically on MySQL (1213/40001) and PostgreSQL
|
||||
* (40P01). SQLite escapes because writes serialize globally.
|
||||
*
|
||||
* This test asserts on the *structure* of the source rather than
|
||||
* invoking the function, because behavioral tests for the issue-mirror
|
||||
* pipeline require heavy module mocks that pollute other test files
|
||||
* (bun's mock.module is process-wide). See
|
||||
* `gitea-mirror-failure-recovery.test.ts` for the same convention.
|
||||
*
|
||||
* The two structural guarantees this test enforces:
|
||||
* (1) Before the create-issue httpPost call, the code performs a
|
||||
* defensive recheck via httpGet that queries Gitea by
|
||||
* `[GH-ISSUE #N]` title marker — handles "previous attempt
|
||||
* committed the issue then threw" scenarios.
|
||||
* (2) After a successful httpPost create, the new issue is written
|
||||
* back into `giteaIssueByGitHubNumber` — handles "this attempt
|
||||
* created the issue, but a later step in the same callback
|
||||
* (e.g. comment sync) throws and triggers another retry"
|
||||
* scenarios.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(join(import.meta.dir, "gitea.ts"), "utf8");
|
||||
|
||||
/**
|
||||
* Locate the body of a function declaration by name. Walks from the
|
||||
* declaration, balances parens to skip the parameter list (which can
|
||||
* contain destructured object literals with their own braces), then
|
||||
* finds the body's opening brace and its matching close.
|
||||
*
|
||||
* Same helper as in `gitea-mirror-failure-recovery.test.ts`; kept
|
||||
* local to keep this test file self-contained.
|
||||
*/
|
||||
function extractFunctionBody(source: string, declarationStart: RegExp): string {
|
||||
const match = source.match(declarationStart);
|
||||
if (!match) {
|
||||
throw new Error(`Could not locate declaration ${declarationStart}`);
|
||||
}
|
||||
let i = match.index! + match[0].length;
|
||||
while (i < source.length && source[i] !== "(") i++;
|
||||
if (source[i] !== "(") {
|
||||
throw new Error(`No '(' after ${declarationStart}`);
|
||||
}
|
||||
let parenDepth = 0;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "(") parenDepth++;
|
||||
else if (source[i] === ")") {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (i < source.length && source[i] !== "{") i++;
|
||||
if (source[i] !== "{") {
|
||||
throw new Error(`No body '{' for ${declarationStart}`);
|
||||
}
|
||||
let braceDepth = 0;
|
||||
const startIdx = i;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") braceDepth++;
|
||||
else if (source[i] === "}") {
|
||||
braceDepth--;
|
||||
if (braceDepth === 0) {
|
||||
return source.slice(startIdx, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unterminated body for ${declarationStart}`);
|
||||
}
|
||||
|
||||
describe("issue dedup on retry-after-deadlock", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export const mirrorGitRepoIssuesToGitea = async\b/
|
||||
);
|
||||
|
||||
test("body contains the per-issue create branch we expect to guard", () => {
|
||||
// Sanity: make sure the test is looking at the right code path.
|
||||
// If these strings disappear due to a refactor, this test should
|
||||
// fail loudly so a human reviews whether the dedup guarantees
|
||||
// still hold in the new shape.
|
||||
expect(
|
||||
body.includes(
|
||||
"giteaIssueByGitHubNumber.get(issue.number)"
|
||||
),
|
||||
"expected the per-issue lookup against giteaIssueByGitHubNumber"
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.match(/await httpPost\(\s*`\$\{config\.giteaConfig!\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/issues`/),
|
||||
"expected the create-issue httpPost call"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("defensive recheck via httpGet runs BEFORE the create httpPost", () => {
|
||||
// The recheck must query Gitea by [GH-ISSUE #N] marker to catch
|
||||
// the partial-commit case. Without it, a deadlock-after-insert
|
||||
// returns 5xx, processWithRetry re-runs the callback, and the
|
||||
// create call produces a duplicate row.
|
||||
const recheckIdx = body.search(
|
||||
/await httpGet\([^)]*\[GH-ISSUE #\$\{issue\.number\}\]/
|
||||
);
|
||||
expect(
|
||||
recheckIdx,
|
||||
"defensive recheck via httpGet using [GH-ISSUE #N] marker must exist"
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The recheck must come before the create httpPost in source order.
|
||||
// The first httpPost on the .../issues endpoint inside this
|
||||
// function body is the create call; we anchor against it.
|
||||
const createIdx = body.search(
|
||||
/await httpPost\(\s*`\$\{config\.giteaConfig!\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/issues`/
|
||||
);
|
||||
expect(createIdx, "create httpPost call must exist").toBeGreaterThanOrEqual(0);
|
||||
|
||||
expect(
|
||||
recheckIdx,
|
||||
"the recheck must run BEFORE the create call so it can short-circuit on partial-commit duplicates"
|
||||
).toBeLessThan(createIdx);
|
||||
});
|
||||
|
||||
test("recheck hit short-circuits via PATCH and updates the cache", () => {
|
||||
// When the recheck finds a hit (i.e. a previous failed attempt
|
||||
// already created this issue), the code should:
|
||||
// - cache the hit into giteaIssueByGitHubNumber so subsequent
|
||||
// retries within this run also find it
|
||||
// - go down the PATCH path (httpPatch) instead of httpPost
|
||||
// - log a recognisable line so operators can spot recovery
|
||||
expect(
|
||||
body.includes(
|
||||
"giteaIssueByGitHubNumber.set(issue.number, recheckHit)"
|
||||
) ||
|
||||
body.match(/giteaIssueByGitHubNumber\.set\(\s*issue\.number\s*,\s*recheckHit/),
|
||||
"recheck hit must be written back into giteaIssueByGitHubNumber"
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
body.match(/Recovered orphan from prior failed attempt/i),
|
||||
"a log line should make the recovery path visible in operator logs"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("pre-fetch issues pagination uses Link header (not short-page heuristic)", () => {
|
||||
// The previous `if (pageIssues.length < issuesPerPage) break;`
|
||||
// heuristic was wrong in both directions:
|
||||
// - Gitea caps response size at `[api].MAX_RESPONSE_ITEMS`
|
||||
// (default 50), typically lower than `issuesPerPage` (100),
|
||||
// so the very first page already looks "short" and
|
||||
// pagination terminated after 50 items. Every issue past
|
||||
// that was misclassified as new and duplicated on every sync.
|
||||
// - Naive removal of that break, relying only on "break on
|
||||
// empty page", can loop forever because some Gitea endpoints
|
||||
// return the same data on every page when asked for a page
|
||||
// past the actual end (instead of returning []).
|
||||
//
|
||||
// The correct fix is to use the Link header (RFC 5988): if
|
||||
// `rel="next"` is absent, we're done.
|
||||
//
|
||||
// This test asserts:
|
||||
// - the broken short-page check is gone
|
||||
// - the existing-issues loop checks the Link header for next
|
||||
const issuesPaginationRegion = body.substring(
|
||||
body.indexOf("existingGiteaIssues.push"),
|
||||
body.indexOf("issuesPage += 1") + 30
|
||||
);
|
||||
expect(
|
||||
issuesPaginationRegion,
|
||||
"issues pagination region should be present"
|
||||
).not.toBe("");
|
||||
expect(
|
||||
/\bpageIssues\.length\s*<\s*issuesPerPage\b/.test(issuesPaginationRegion),
|
||||
"the short-page break (pageIssues.length < issuesPerPage) must be removed"
|
||||
).toBe(false);
|
||||
expect(
|
||||
/existingIssuesRes\.headers\.get\(\s*["']link["']\s*\)/.test(
|
||||
issuesPaginationRegion
|
||||
) && /rel="next"/.test(issuesPaginationRegion),
|
||||
"the issues pagination loop must use the Link header (rel=\"next\") " +
|
||||
"to decide whether to fetch the next page"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("per-issue comments pagination also uses Link header (not short-page heuristic)", () => {
|
||||
// Same correctness concerns as issues pagination above. The per-
|
||||
// issue comments endpoint is subject to the same Gitea page-size
|
||||
// cap, and naive empty-page detection has the same risk.
|
||||
expect(
|
||||
/\bpageComments\.length\s*<\s*commentsPerPage\b/.test(body),
|
||||
"the short-page break (pageComments.length < commentsPerPage) must be removed"
|
||||
).toBe(false);
|
||||
// Look only at the comments-fetch region (not the whole file) so
|
||||
// a future caller using a different response variable name in
|
||||
// another place won't false-positive this assertion.
|
||||
const commentsRegion = body.substring(
|
||||
body.indexOf("existingComments.push"),
|
||||
body.indexOf("commentsPage += 1") + 30
|
||||
);
|
||||
expect(
|
||||
commentsRegion,
|
||||
"comments pagination region should be present"
|
||||
).not.toBe("");
|
||||
expect(
|
||||
/existingCommentsRes\.headers\.get\(\s*["']link["']\s*\)/.test(
|
||||
commentsRegion
|
||||
) && /rel="next"/.test(commentsRegion),
|
||||
"the comments pagination loop must use the Link header (rel=\"next\") " +
|
||||
"to decide whether to fetch the next page"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("PR mirror has the same guarantees", () => {
|
||||
// mirrorGitRepoPullRequestsToGitea has parallel structure:
|
||||
// - pre-fetches existing Gitea "issues that are mirrored PRs",
|
||||
// keyed by `[PR #N]` marker in title
|
||||
// - per-PR callback decides PATCH vs CREATE
|
||||
// - same Gitea-side pagination cap and deadlock-after-commit
|
||||
// risks apply
|
||||
// The fix mirrors gitea-issues here.
|
||||
const prBody = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitRepoPullRequestsToGitea\b/
|
||||
);
|
||||
|
||||
test("PR pre-fetch pagination uses Link header", () => {
|
||||
expect(
|
||||
/\bpageIssues\.length\s*<\s*prIssuesPerPage\b/.test(prBody),
|
||||
"the short-page break (pageIssues.length < prIssuesPerPage) must be removed"
|
||||
).toBe(false);
|
||||
// The PR pre-fetch reuses the existingIssuesRes variable name
|
||||
expect(
|
||||
/existingIssuesRes\.headers\.get\(\s*["']link["']\s*\)/.test(prBody) &&
|
||||
/rel="next"/.test(prBody),
|
||||
"the PR pre-fetch loop must use the Link header (rel=\"next\")"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("PR create path defensively rechecks via [PR #N] before httpPost", () => {
|
||||
// Both the enriched and basic-fallback create paths must have
|
||||
// a recheck so partial-commit retries don't duplicate the PR.
|
||||
const rechecks =
|
||||
prBody.match(/Recovered orphan from prior failed attempt for PR/g) ||
|
||||
[];
|
||||
expect(
|
||||
rechecks.length,
|
||||
"expected at least two 'Recovered orphan' log lines " +
|
||||
"(one for the enriched create path, one for the basic-fallback path)"
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("successful create caches the new issue into the dedup map", () => {
|
||||
// Without this, a retry triggered by a *later* step in the same
|
||||
// per-issue callback (e.g. comment sync throwing) would re-enter
|
||||
// the create path on the next attempt — same root duplication
|
||||
// pattern, different trigger.
|
||||
expect(
|
||||
body.match(
|
||||
/giteaIssueByGitHubNumber\.set\(\s*issue\.number\s*,\s*createdIssue\.data\s*\)/
|
||||
),
|
||||
"after a successful create, the new issue must be stored in giteaIssueByGitHubNumber"
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Regression test for duplicate milestone & label creation on every sync.
|
||||
*
|
||||
* Observed in production (May 2026): a Gitea instance accumulated
|
||||
* 11,847 duplicate closed milestones across 4 mirrored repos after only
|
||||
* a handful of scheduled syncs. Two compounding bugs in
|
||||
* `mirrorGitRepoMilestonesToGitea`:
|
||||
*
|
||||
* (1) The existing-milestones GET to Gitea did NOT pass `state=all`.
|
||||
* Gitea's /milestones endpoint defaults to `state=open`, so the
|
||||
* `existingMilestones` Set never contained any closed milestone
|
||||
* title. Every closed GitHub milestone was misclassified as
|
||||
* missing and re-POSTed on every sync.
|
||||
*
|
||||
* (2) The existing-milestones GET was a single unpaginated call.
|
||||
* Gitea caps response size at `[api].MAX_RESPONSE_ITEMS`
|
||||
* (default 50), so any repo with more milestones than that
|
||||
* silently truncates even when (1) is fixed.
|
||||
*
|
||||
* `mirrorGitRepoLabelsToGitea` has the same pagination bug (2). It
|
||||
* doesn't have bug (1) because /labels has no state filter, and it
|
||||
* hadn't yet shown duplicates in production only because no mirrored
|
||||
* repo had crossed 50 distinct labels — but it would the moment one
|
||||
* did.
|
||||
*
|
||||
* These tests assert on the *structure* of the source rather than
|
||||
* invoking the functions, because behavioral tests for the metadata
|
||||
* pipeline require heavy module mocks that pollute other test files
|
||||
* (bun's mock.module is process-wide). Same convention as
|
||||
* `gitea-issue-dedup-on-retry.test.ts` and
|
||||
* `gitea-mirror-failure-recovery.test.ts`.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(join(import.meta.dir, "gitea.ts"), "utf8");
|
||||
|
||||
/**
|
||||
* Locate the body of a function declaration by name. Walks from the
|
||||
* declaration, balances parens to skip the parameter list (which can
|
||||
* contain destructured object literals with their own braces), then
|
||||
* finds the body's opening brace and its matching close.
|
||||
*/
|
||||
function extractFunctionBody(source: string, declarationStart: RegExp): string {
|
||||
const match = source.match(declarationStart);
|
||||
if (!match) {
|
||||
throw new Error(`Could not locate declaration ${declarationStart}`);
|
||||
}
|
||||
let i = match.index! + match[0].length;
|
||||
while (i < source.length && source[i] !== "(") i++;
|
||||
if (source[i] !== "(") {
|
||||
throw new Error(`No '(' after ${declarationStart}`);
|
||||
}
|
||||
let parenDepth = 0;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "(") parenDepth++;
|
||||
else if (source[i] === ")") {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (i < source.length && source[i] !== "{") i++;
|
||||
if (source[i] !== "{") {
|
||||
throw new Error(`No body '{' for ${declarationStart}`);
|
||||
}
|
||||
let braceDepth = 0;
|
||||
const startIdx = i;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") braceDepth++;
|
||||
else if (source[i] === "}") {
|
||||
braceDepth--;
|
||||
if (braceDepth === 0) {
|
||||
return source.slice(startIdx, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unterminated body for ${declarationStart}`);
|
||||
}
|
||||
|
||||
describe("milestone dedup on sync", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitRepoMilestonesToGitea\b/
|
||||
);
|
||||
|
||||
test("body contains the per-milestone create branch we expect to guard", () => {
|
||||
// Sanity check: anchor the rest of this suite to the right path.
|
||||
expect(
|
||||
body.includes("existingMilestones"),
|
||||
"expected the existingMilestones set/map used for dedup"
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.match(
|
||||
/await httpPost\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/milestones`/
|
||||
),
|
||||
"expected the create-milestone httpPost call"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("existing-milestones GET must include state=all", () => {
|
||||
// Without state=all, Gitea returns only open milestones, so every
|
||||
// closed GitHub milestone is misclassified as missing and re-POSTed
|
||||
// on every sync. This was the root cause of the production blowup.
|
||||
const getMatch = body.match(
|
||||
/httpGet\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/milestones\?[^`]*`/
|
||||
);
|
||||
expect(
|
||||
getMatch,
|
||||
"expected a milestones httpGet call with a query string"
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
/state=all/.test(getMatch![0]),
|
||||
"the existing-milestones GET must pass state=all (Gitea defaults to state=open)"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("existing-milestones GET must paginate with both Link and X-Total-Count fallback", () => {
|
||||
// Even with state=all, a single unpaginated call only ever sees
|
||||
// the first 50 milestones (Gitea's MAX_RESPONSE_ITEMS cap).
|
||||
//
|
||||
// Gitea's /milestones endpoint does NOT emit a Link header — only
|
||||
// `X-Total-Count`. A strict Link-only check terminates after page 1
|
||||
// and re-POSTs every milestone past index 50 on every sync (the
|
||||
// 9-milestone leak observed on Subnet-Calculator after the
|
||||
// Link-only version of this fix shipped).
|
||||
expect(
|
||||
/milestonesPage\s*\+=\s*1/.test(body),
|
||||
"expected a page-increment loop for existing milestones"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']link["']\s*\)/.test(body) &&
|
||||
/rel="next"/.test(body),
|
||||
"the milestones pagination loop must check the Link header (rel=\"next\")"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']x-total-count["']\s*\)/.test(body),
|
||||
"the milestones pagination loop must fall back to X-Total-Count when Link header is absent (Gitea /milestones only emits X-Total-Count)"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("newly-created milestone must be cached into existingMilestones", () => {
|
||||
// Defensive: if `milestones` ever contains a same-named entry
|
||||
// twice (unlikely but cheap to guard), we shouldn't POST it twice.
|
||||
expect(
|
||||
/existingMilestones\.add\(\s*milestone\.title\s*\)/.test(body),
|
||||
"after a successful create, the new milestone title must be added to existingMilestones"
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("label dedup on sync", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitRepoLabelsToGitea\b/
|
||||
);
|
||||
|
||||
test("body contains the per-label create branch we expect to guard", () => {
|
||||
expect(
|
||||
body.includes("existingLabels"),
|
||||
"expected the existingLabels set used for dedup"
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.match(
|
||||
/await httpPost\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/labels`/
|
||||
),
|
||||
"expected the create-label httpPost call"
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("existing-labels GET must paginate with both Link and X-Total-Count fallback", () => {
|
||||
// Same Gitea MAX_RESPONSE_ITEMS=50 cap as milestones / issues.
|
||||
// Gitea's /labels endpoint, like /milestones, does NOT emit a Link
|
||||
// header — only `X-Total-Count`. Strict Link-only check would
|
||||
// silently truncate after page 1.
|
||||
expect(
|
||||
/labelsPage\s*\+=\s*1/.test(body),
|
||||
"expected a page-increment loop for existing labels"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']link["']\s*\)/.test(body) &&
|
||||
/rel="next"/.test(body),
|
||||
"the labels pagination loop must check the Link header (rel=\"next\")"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/\.headers\.get\(\s*["']x-total-count["']\s*\)/.test(body),
|
||||
"the labels pagination loop must fall back to X-Total-Count when Link header is absent (Gitea /labels only emits X-Total-Count)"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("newly-created label must be cached into existingLabels", () => {
|
||||
expect(
|
||||
/existingLabels\.add\(\s*label\.name\s*\)/.test(body),
|
||||
"after a successful create, the new label name must be added to existingLabels"
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Regression test for issue #268.
|
||||
*
|
||||
* `let migrateSucceeded = false;` was declared *inside* the try block
|
||||
* of mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the
|
||||
* catch block referenced it. `let` is block-scoped to the try, so any
|
||||
* error inside try made the catch crash with `ReferenceError:
|
||||
* migrateSucceeded is not defined` before reaching the DB update that
|
||||
* marks the repo "failed". Result: repos stuck in "mirroring" forever
|
||||
* with no entry in the activity log (see issue logs).
|
||||
*
|
||||
* This test asserts the declaration is hoisted above the try block in
|
||||
* both functions. It deliberately reads the source rather than calling
|
||||
* the functions, because behavioral tests for these functions require
|
||||
* heavy module mocks that pollute other test files (bun's mock.module
|
||||
* is process-wide and persists across files).
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(
|
||||
join(import.meta.dir, "gitea.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
/**
|
||||
* Locate the body of a function declaration by name. Walks from the
|
||||
* declaration, balances parens to skip the parameter list (which can
|
||||
* contain destructured object literals with their own braces), then
|
||||
* finds the body's opening brace and its matching close.
|
||||
*/
|
||||
function extractFunctionBody(source: string, declarationStart: RegExp): string {
|
||||
const match = source.match(declarationStart);
|
||||
if (!match) {
|
||||
throw new Error(`Could not locate declaration ${declarationStart}`);
|
||||
}
|
||||
let i = match.index! + match[0].length;
|
||||
// Skip whitespace until the opening paren of the parameter list.
|
||||
while (i < source.length && source[i] !== "(") i++;
|
||||
if (source[i] !== "(") {
|
||||
throw new Error(`No '(' after ${declarationStart}`);
|
||||
}
|
||||
// Balance parens to find the end of the parameter list. Braces inside
|
||||
// the parameter list (e.g. destructured `{ foo, bar }`) are allowed
|
||||
// and ignored.
|
||||
let parenDepth = 0;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "(") parenDepth++;
|
||||
else if (source[i] === ")") {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip return-type annotation, => arrow, whitespace, until the body's `{`.
|
||||
while (i < source.length && source[i] !== "{") i++;
|
||||
if (source[i] !== "{") {
|
||||
throw new Error(`No body '{' for ${declarationStart}`);
|
||||
}
|
||||
// Balance braces for the body.
|
||||
let braceDepth = 0;
|
||||
const startIdx = i;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") braceDepth++;
|
||||
else if (source[i] === "}") {
|
||||
braceDepth--;
|
||||
if (braceDepth === 0) {
|
||||
return source.slice(startIdx, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Unterminated body for ${declarationStart}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm that within a function body, the first `let migrateSucceeded`
|
||||
* declaration occurs BEFORE the function's outermost `try {`.
|
||||
*
|
||||
* If the declaration is inside the try block, the catch block can't see
|
||||
* it (ReferenceError in production = repo stuck mirroring).
|
||||
*/
|
||||
function assertMigrateSucceededDeclaredBeforeTry(body: string, label: string) {
|
||||
const declIdx = body.indexOf("let migrateSucceeded");
|
||||
expect(declIdx, `${label}: 'let migrateSucceeded' should exist`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The function's outermost try is the first standalone `try {` in
|
||||
// the body — assignments and inner try/catches don't share its name.
|
||||
const tryIdx = body.search(/\btry\s*\{/);
|
||||
expect(tryIdx, `${label}: outermost 'try {' should exist`).toBeGreaterThanOrEqual(0);
|
||||
|
||||
expect(
|
||||
declIdx,
|
||||
`${label}: 'let migrateSucceeded' must be declared BEFORE the try block ` +
|
||||
`so the catch block can read it. If declared inside try, it's block-scoped ` +
|
||||
`and the catch will throw ReferenceError, leaving repos stuck in 'mirroring'. ` +
|
||||
`See issue #268.`
|
||||
).toBeLessThan(tryIdx);
|
||||
|
||||
// And it should still be assigned to true after the migrate call —
|
||||
// otherwise the catch can't tell whether to clear mirroredLocation.
|
||||
expect(
|
||||
body.includes("migrateSucceeded = true"),
|
||||
`${label}: 'migrateSucceeded = true' assignment should exist after the migrate call`
|
||||
).toBe(true);
|
||||
|
||||
// And the catch must read it.
|
||||
expect(
|
||||
body.includes("if (!migrateSucceeded)"),
|
||||
`${label}: catch block should read 'migrateSucceeded' to decide whether to clear mirroredLocation`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
describe("issue #268 — migrateSucceeded scoping regression", () => {
|
||||
test("mirrorGithubRepoToGitea declares migrateSucceeded outside try", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export const mirrorGithubRepoToGitea = async\b/
|
||||
);
|
||||
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGithubRepoToGitea");
|
||||
});
|
||||
|
||||
test("mirrorGitHubRepoToGiteaOrg declares migrateSucceeded outside try", () => {
|
||||
const body = extractFunctionBody(
|
||||
SOURCE,
|
||||
/export async function mirrorGitHubRepoToGiteaOrg\b/
|
||||
);
|
||||
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGitHubRepoToGiteaOrg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Unit tests for release reconciliation logic — regression for #310.
|
||||
*
|
||||
* Root-cause verdict: Theory A (not Theory B).
|
||||
*
|
||||
* The old `needsRecreation` check compared published_at-based expected indices
|
||||
* against Gitea's API order (which mirrors sort by tag-commit-date, not
|
||||
* published_at). For repos where published_at order permanently disagrees with
|
||||
* tag-commit-date order, `currentExpectedIdx < nextExpectedIdx` evaluates true
|
||||
* on every single sync, triggering delete-and-recreate forever.
|
||||
*
|
||||
* Discriminating evidence — unaconfig_dart fixture:
|
||||
* v0.1.1 published_at 2024-01-13T23:44 (earlier) → expectedOrder index 0
|
||||
* v0.1.0 published_at 2024-01-16T00:23 (later) → expectedOrder index 1
|
||||
* v0.1.0 tagged 2024-01-13T23:30 (earlier tag commit)
|
||||
* v0.1.1 tagged 2024-01-13T23:42 (later tag commit)
|
||||
* → Gitea tag-commit order: [v0.1.1, v0.1.0] (v0.1.1 has newer commit)
|
||||
* → Old check: currentExpectedIdx(v0.1.1)=0 < nextExpectedIdx(v0.1.0)=1 → TRUE
|
||||
* → needsRecreation fires on every sync, forever.
|
||||
*
|
||||
* Theory B (operator inversion) would fire for ALL repos with >1 release, but
|
||||
* field evidence shows only ~2 of 150 repos are affected — ruling it out.
|
||||
*
|
||||
* Fix: replaced `needsRecreation` machinery with set-based reconciliation via
|
||||
* `classifyReleasesForReconciliation`. Releases are created when missing, skipped
|
||||
* (or PATCH-updated if content drifted) when present. No deletions for ordering.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { classifyReleasesForReconciliation } from "@/lib/gitea";
|
||||
|
||||
describe("classifyReleasesForReconciliation", () => {
|
||||
describe("normal repo — published_at order matches tag-commit order", () => {
|
||||
it("creates releases missing in Gitea", () => {
|
||||
const github = ["v1.0.0", "v1.1.0", "v1.2.0"];
|
||||
const gitea: string[] = [];
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual(["v1.0.0", "v1.1.0", "v1.2.0"]);
|
||||
expect(toSkip).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips releases already present in Gitea", () => {
|
||||
const github = ["v1.0.0", "v1.1.0", "v1.2.0"];
|
||||
const gitea = ["v1.0.0", "v1.1.0", "v1.2.0"];
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual([]);
|
||||
expect(toSkip).toEqual(["v1.0.0", "v1.1.0", "v1.2.0"]);
|
||||
});
|
||||
|
||||
it("creates missing releases while skipping existing ones", () => {
|
||||
const github = ["v1.0.0", "v1.1.0", "v1.2.0"];
|
||||
const gitea = ["v1.0.0", "v1.2.0"]; // v1.1.0 is missing
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual(["v1.1.0"]);
|
||||
expect(toSkip).toEqual(["v1.0.0", "v1.2.0"]);
|
||||
});
|
||||
|
||||
it("does NOT produce any deletions — order mismatches are ignored", () => {
|
||||
// Even if Gitea has them in a different order, the function never suggests deletion
|
||||
const github = ["v1.0.0", "v1.1.0"];
|
||||
const gitea = ["v1.1.0", "v1.0.0"]; // reversed order from Gitea
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual([]);
|
||||
expect(toSkip).toHaveLength(2);
|
||||
expect(toSkip).toContain("v1.0.0");
|
||||
expect(toSkip).toContain("v1.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unaconfig_dart regression — published_at order disagrees with tag-commit order (#310)", () => {
|
||||
// v0.1.0: tagged 2024-01-13T23:30, published_at 2024-01-16T00:23 (published AFTER v0.1.1)
|
||||
// v0.1.1: tagged 2024-01-13T23:42, published_at 2024-01-13T23:44 (published BEFORE v0.1.0)
|
||||
//
|
||||
// Gitea display order (by tag-commit date): [v0.1.1, v0.1.0] (v0.1.1 tagged later)
|
||||
// GitHub published_at order (oldest first): [v0.1.1, v0.1.0] (v0.1.1 published earlier)
|
||||
// Wait — in this specific case the orders AGREE. The inversion scenario is:
|
||||
// GitHub sorts descending (newest first): [v0.1.0, v0.1.1]
|
||||
// Gitea API returns by tag-commit DESC: [v0.1.1, v0.1.0]
|
||||
// Old expectedOrder (ascending published): v0.1.1→0, v0.1.0→1
|
||||
// Check for [v0.1.1, v0.1.0]: current=v0.1.1(idx=0) < next=v0.1.0(idx=1) → TRUE every time
|
||||
|
||||
it("does NOT trigger recreation when published_at order and tag-commit order disagree", () => {
|
||||
// Both releases already in Gitea (as they would be after first successful sync).
|
||||
// Old code would fire needsRecreation=true here on every subsequent sync.
|
||||
// New code: set-based check — both present → toCreate is empty → no deletions.
|
||||
const github = ["v0.1.0", "v0.1.1"]; // GitHub API returns newest published_at first
|
||||
const gitea = ["v0.1.1", "v0.1.0"]; // Gitea tag-commit order (v0.1.1 tagged later)
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual([]); // nothing to create
|
||||
expect(toSkip).toHaveLength(2);
|
||||
expect(toSkip).toContain("v0.1.0");
|
||||
expect(toSkip).toContain("v0.1.1");
|
||||
});
|
||||
|
||||
it("creates v0.1.0 and v0.1.1 when Gitea has no releases yet (first sync)", () => {
|
||||
const github = ["v0.1.0", "v0.1.1"];
|
||||
const gitea: string[] = [];
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual(["v0.1.0", "v0.1.1"]);
|
||||
expect(toSkip).toEqual([]);
|
||||
});
|
||||
|
||||
it("only creates the missing release when one of the two already exists", () => {
|
||||
const github = ["v0.1.0", "v0.1.1"];
|
||||
const gitea = ["v0.1.1"]; // only v0.1.1 was created so far
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual(["v0.1.0"]);
|
||||
expect(toSkip).toEqual(["v0.1.1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty GitHub releases list", () => {
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation([], ["v1.0.0"]);
|
||||
expect(toCreate).toEqual([]);
|
||||
expect(toSkip).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles both lists empty", () => {
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation([], []);
|
||||
expect(toCreate).toEqual([]);
|
||||
expect(toSkip).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores Gitea releases that are not in the GitHub set (orphans, handled by retention cleanup)", () => {
|
||||
const github = ["v1.0.0"];
|
||||
const gitea = ["v1.0.0", "v0.9.0"]; // v0.9.0 is an orphan not in GitHub's limited set
|
||||
|
||||
const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea);
|
||||
|
||||
expect(toCreate).toEqual([]);
|
||||
expect(toSkip).toEqual(["v1.0.0"]);
|
||||
// v0.9.0 not mentioned in either output — handled by retention cleanup, not here
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { buildGiteaWebUrl, getGiteaWebBaseUrl } from "@/lib/gitea-url";
|
||||
|
||||
describe("getGiteaWebBaseUrl", () => {
|
||||
it("prefers externalUrl when both urls are present", () => {
|
||||
const baseUrl = getGiteaWebBaseUrl({
|
||||
url: "http://gitea:3000",
|
||||
externalUrl: "https://git.example.com",
|
||||
});
|
||||
|
||||
expect(baseUrl).toBe("https://git.example.com");
|
||||
});
|
||||
|
||||
it("falls back to url when externalUrl is missing", () => {
|
||||
const baseUrl = getGiteaWebBaseUrl({
|
||||
url: "http://gitea:3000",
|
||||
});
|
||||
|
||||
expect(baseUrl).toBe("http://gitea:3000");
|
||||
});
|
||||
|
||||
it("trims a trailing slash", () => {
|
||||
const baseUrl = getGiteaWebBaseUrl({
|
||||
externalUrl: "https://git.example.com/",
|
||||
});
|
||||
|
||||
expect(baseUrl).toBe("https://git.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGiteaWebUrl", () => {
|
||||
it("builds a full repository url and removes leading path slashes", () => {
|
||||
const url = buildGiteaWebUrl(
|
||||
{ externalUrl: "https://git.example.com/" },
|
||||
"/org/repo"
|
||||
);
|
||||
|
||||
expect(url).toBe("https://git.example.com/org/repo");
|
||||
});
|
||||
|
||||
it("returns null when no gitea url is configured", () => {
|
||||
const url = buildGiteaWebUrl({}, "org/repo");
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
interface GiteaUrlConfig {
|
||||
url?: string | null;
|
||||
externalUrl?: string | null;
|
||||
}
|
||||
|
||||
export function getGiteaWebBaseUrl(
|
||||
config?: GiteaUrlConfig | null
|
||||
): string | null {
|
||||
const rawBaseUrl = config?.externalUrl || config?.url;
|
||||
if (!rawBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rawBaseUrl.endsWith("/") ? rawBaseUrl.slice(0, -1) : rawBaseUrl;
|
||||
}
|
||||
|
||||
export function buildGiteaWebUrl(
|
||||
config: GiteaUrlConfig | null | undefined,
|
||||
path: string
|
||||
): string | null {
|
||||
const baseUrl = getGiteaWebBaseUrl(config);
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = path.replace(/^\/+/, "");
|
||||
return normalizedPath ? `${baseUrl}/${normalizedPath}` : baseUrl;
|
||||
}
|
||||
+779
-356
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, test, mock } from "bun:test";
|
||||
import { getGithubRepositories } from "@/lib/github";
|
||||
|
||||
function makeRepo(overrides: Partial<{
|
||||
name: string;
|
||||
full_name: string;
|
||||
ownerLogin: string;
|
||||
ownerType: string;
|
||||
fork: boolean;
|
||||
}> = {}) {
|
||||
const ownerLogin = overrides.ownerLogin ?? "octo";
|
||||
const ownerType = overrides.ownerType ?? "User";
|
||||
return {
|
||||
name: overrides.name ?? "demo",
|
||||
full_name: overrides.full_name ?? `${ownerLogin}/${overrides.name ?? "demo"}`,
|
||||
html_url: `https://github.com/${ownerLogin}/${overrides.name ?? "demo"}`,
|
||||
clone_url: `https://github.com/${ownerLogin}/${overrides.name ?? "demo"}.git`,
|
||||
owner: { login: ownerLogin, type: ownerType },
|
||||
private: false,
|
||||
fork: overrides.fork ?? false,
|
||||
has_issues: true,
|
||||
archived: false,
|
||||
size: 1,
|
||||
language: "TypeScript",
|
||||
description: "",
|
||||
default_branch: "main",
|
||||
visibility: "public",
|
||||
disabled: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeOctokit(reposToReturn?: ReturnType<typeof makeRepo>[]) {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
const paginate = mock(async (_method: unknown, options?: Record<string, unknown>) => {
|
||||
captured = options ?? null;
|
||||
return reposToReturn ?? [makeRepo()];
|
||||
});
|
||||
return {
|
||||
octokit: {
|
||||
paginate,
|
||||
repos: { listForAuthenticatedUser: () => {} },
|
||||
} as any,
|
||||
getCaptured: () => captured,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getGithubRepositories - affiliation", () => {
|
||||
test("defaults to owner+collaborator+organization_member when field is unset (backward compat)", async () => {
|
||||
const { octokit, getCaptured } = makeOctokit();
|
||||
await getGithubRepositories({ octokit, config: { githubConfig: { owner: "octo" } as any } });
|
||||
expect(getCaptured()?.affiliation).toBe("owner,collaborator,organization_member");
|
||||
});
|
||||
|
||||
test("uses owner+organization_member when includeCollaboratorRepos is false", async () => {
|
||||
const { octokit, getCaptured } = makeOctokit();
|
||||
await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeCollaboratorRepos: false } as any },
|
||||
});
|
||||
expect(getCaptured()?.affiliation).toBe("owner,organization_member");
|
||||
});
|
||||
|
||||
test("uses owner+collaborator+organization_member when includeCollaboratorRepos is true", async () => {
|
||||
const { octokit, getCaptured } = makeOctokit();
|
||||
await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeCollaboratorRepos: true } as any },
|
||||
});
|
||||
expect(getCaptured()?.affiliation).toBe("owner,collaborator,organization_member");
|
||||
});
|
||||
|
||||
test("override forces owner+collaborator+organization_member regardless of config (used by cleanup)", async () => {
|
||||
const { octokit, getCaptured } = makeOctokit();
|
||||
await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeCollaboratorRepos: false } as any },
|
||||
includeCollaboratorReposOverride: true,
|
||||
});
|
||||
expect(getCaptured()?.affiliation).toBe("owner,collaborator,organization_member");
|
||||
});
|
||||
|
||||
test("always includes organization_member (regression guard for org-repo invisibility)", async () => {
|
||||
const cases: Array<{ includeCollab?: boolean; override?: boolean }> = [
|
||||
{},
|
||||
{ includeCollab: true },
|
||||
{ includeCollab: false },
|
||||
{ override: true },
|
||||
{ includeCollab: false, override: true },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const { octokit, getCaptured } = makeOctokit();
|
||||
await getGithubRepositories({
|
||||
octokit,
|
||||
config: {
|
||||
githubConfig: {
|
||||
owner: "octo",
|
||||
...(c.includeCollab !== undefined && { includeCollaboratorRepos: c.includeCollab }),
|
||||
} as any,
|
||||
},
|
||||
...(c.override !== undefined && { includeCollaboratorReposOverride: c.override }),
|
||||
});
|
||||
const aff = String(getCaptured()?.affiliation ?? "");
|
||||
expect(aff.split(",")).toContain("organization_member");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGithubRepositories - skipPersonalRepos", () => {
|
||||
const personalRepo = makeRepo({ name: "my-lib", ownerLogin: "octo", ownerType: "User" });
|
||||
const orgRepo = makeRepo({ name: "org-lib", ownerLogin: "my-org", ownerType: "Organization" });
|
||||
const otherUserRepo = makeRepo({ name: "collab-lib", ownerLogin: "other-user", ownerType: "User" });
|
||||
|
||||
test("default false — keeps all repos including personal", async () => {
|
||||
const { octokit } = makeOctokit([personalRepo, orgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", skipPersonalRepos: false } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).toContain("my-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("org-lib");
|
||||
});
|
||||
|
||||
test("skipPersonalRepos=true — drops repos owned by authenticated user", async () => {
|
||||
const { octokit } = makeOctokit([personalRepo, orgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", skipPersonalRepos: true } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).not.toContain("my-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("org-lib");
|
||||
});
|
||||
|
||||
test("skipPersonalRepos=true — keeps repos owned by other users (collaborator repos)", async () => {
|
||||
const { octokit } = makeOctokit([personalRepo, orgRepo, otherUserRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", skipPersonalRepos: true } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).not.toContain("my-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("org-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("collab-lib");
|
||||
});
|
||||
|
||||
test("skipPersonalRepos=true with no owner configured — keeps all repos (safe fallback)", async () => {
|
||||
const { octokit } = makeOctokit([personalRepo, orgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "", skipPersonalRepos: true } as any },
|
||||
});
|
||||
// Empty owner means we can't identify the user, so nothing should be dropped
|
||||
expect(repos.map((r) => r.name)).toContain("my-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("org-lib");
|
||||
});
|
||||
|
||||
test("skipPersonalRepos=true — unset (undefined) behaves like false", async () => {
|
||||
const { octokit } = makeOctokit([personalRepo, orgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo" } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).toContain("my-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("org-lib");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+319
-3
@@ -235,21 +235,49 @@ export async function getGithubRepoCloneUrl({
|
||||
export async function getGithubRepositories({
|
||||
octokit,
|
||||
config,
|
||||
includeCollaboratorReposOverride,
|
||||
}: {
|
||||
octokit: Octokit;
|
||||
config: Partial<Config>;
|
||||
// Force-include collaborator repos regardless of user setting. Used by the
|
||||
// cleanup service so we never mark a collab repo as orphaned just because
|
||||
// the import filter is currently off.
|
||||
includeCollaboratorReposOverride?: boolean;
|
||||
}): Promise<GitRepo[]> {
|
||||
try {
|
||||
const includeCollab =
|
||||
includeCollaboratorReposOverride ??
|
||||
config.githubConfig?.includeCollaboratorRepos ??
|
||||
true;
|
||||
// Always include organization_member so repos owned by orgs the user
|
||||
// belongs to are returned. Omitting it caused org repos to be invisible
|
||||
// to the main sync, the scheduler, and the cleanup service (which then
|
||||
// archived them on restart as if they had been deleted on GitHub).
|
||||
const affiliation = includeCollab
|
||||
? "owner,collaborator,organization_member"
|
||||
: "owner,organization_member";
|
||||
|
||||
const repos = await octokit.paginate(
|
||||
octokit.repos.listForAuthenticatedUser,
|
||||
{ per_page: 100 },
|
||||
{ per_page: 100, affiliation },
|
||||
);
|
||||
|
||||
const skipForks = config.githubConfig?.skipForks ?? false;
|
||||
const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false;
|
||||
// The authenticated user's login — used to identify personally-owned repos
|
||||
const authenticatedUserLogin = config.githubConfig?.owner ?? "";
|
||||
|
||||
const filteredRepos = repos.filter((repo) => {
|
||||
const isForkAllowed = !skipForks || !repo.fork;
|
||||
return isForkAllowed;
|
||||
// When skipPersonalRepos is true, drop repos owned by the authenticated user
|
||||
// (owner.type === "User" and owner.login matches the configured GitHub username).
|
||||
// Org repos have owner.type === "Organization" so they are always kept.
|
||||
const isPersonalRepo =
|
||||
skipPersonalRepos &&
|
||||
authenticatedUserLogin.length > 0 &&
|
||||
repo.owner.login === authenticatedUserLogin &&
|
||||
repo.owner.type === "User";
|
||||
return isForkAllowed && !isPersonalRepo;
|
||||
});
|
||||
|
||||
return filteredRepos.map((repo) => ({
|
||||
@@ -300,6 +328,239 @@ export async function getGithubRepositories({
|
||||
}
|
||||
}
|
||||
|
||||
function getStarredListMatchKey(rawValue: string): string {
|
||||
const normalized = rawValue.normalize("NFKC").trim().toLowerCase();
|
||||
const tokens = normalized.match(/[\p{L}\p{N}]+/gu);
|
||||
return tokens ? tokens.join("") : "";
|
||||
}
|
||||
|
||||
function normalizeStarredListNames(rawLists: unknown): string[] {
|
||||
if (!Array.isArray(rawLists)) return [];
|
||||
|
||||
const deduped = new Map<string, string>();
|
||||
for (const value of rawLists) {
|
||||
if (typeof value !== "string") continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const matchKey = getStarredListMatchKey(trimmed);
|
||||
if (!matchKey || deduped.has(matchKey)) continue;
|
||||
deduped.set(matchKey, trimmed);
|
||||
}
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function toHttpsCloneUrl(repoUrl: string): string {
|
||||
return repoUrl.endsWith(".git") ? repoUrl : `${repoUrl}.git`;
|
||||
}
|
||||
|
||||
interface GitHubStarListNode {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GitHubRepositoryListItem {
|
||||
__typename: "Repository";
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
url: string;
|
||||
sshUrl: string;
|
||||
isPrivate: boolean;
|
||||
isFork: boolean;
|
||||
isArchived: boolean;
|
||||
isDisabled: boolean;
|
||||
hasIssuesEnabled: boolean;
|
||||
diskUsage: number;
|
||||
description: string | null;
|
||||
defaultBranchRef: { name: string } | null;
|
||||
visibility: "PUBLIC" | "PRIVATE" | "INTERNAL";
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
owner: {
|
||||
__typename: "Organization" | "User" | string;
|
||||
login: string;
|
||||
};
|
||||
primaryLanguage: {
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
async function getGithubStarLists(octokit: Octokit): Promise<GitHubStarListNode[]> {
|
||||
const allLists: GitHubStarListNode[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const result = await octokit.graphql<{
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: Array<GitHubStarListNode | null> | null;
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>(
|
||||
`
|
||||
query($after: String) {
|
||||
viewer {
|
||||
lists(first: 50, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ after: cursor },
|
||||
);
|
||||
|
||||
const lists = (result.viewer.lists.nodes ?? []).filter(
|
||||
(list): list is GitHubStarListNode =>
|
||||
!!list &&
|
||||
typeof list.id === "string" &&
|
||||
typeof list.name === "string",
|
||||
);
|
||||
allLists.push(...lists);
|
||||
|
||||
if (!result.viewer.lists.pageInfo.hasNextPage) break;
|
||||
cursor = result.viewer.lists.pageInfo.endCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allLists;
|
||||
}
|
||||
|
||||
async function getGithubRepositoriesForStarList(
|
||||
octokit: Octokit,
|
||||
listId: string,
|
||||
): Promise<GitHubRepositoryListItem[]> {
|
||||
const repositories: GitHubRepositoryListItem[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const result = await octokit.graphql<{
|
||||
node: {
|
||||
items: {
|
||||
nodes: Array<GitHubRepositoryListItem | null> | null;
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
}>(
|
||||
`
|
||||
query($listId: ID!, $after: String) {
|
||||
node(id: $listId) {
|
||||
... on UserList {
|
||||
items(first: 100, after: $after) {
|
||||
nodes {
|
||||
__typename
|
||||
... on Repository {
|
||||
name
|
||||
nameWithOwner
|
||||
url
|
||||
sshUrl
|
||||
isPrivate
|
||||
isFork
|
||||
isArchived
|
||||
isDisabled
|
||||
hasIssuesEnabled
|
||||
diskUsage
|
||||
description
|
||||
defaultBranchRef {
|
||||
name
|
||||
}
|
||||
visibility
|
||||
updatedAt
|
||||
createdAt
|
||||
owner {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
primaryLanguage {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ listId, after: cursor },
|
||||
);
|
||||
|
||||
const listNode = result.node;
|
||||
if (!listNode) break;
|
||||
|
||||
const nodes = listNode.items.nodes ?? [];
|
||||
for (const node of nodes) {
|
||||
if (node?.__typename === "Repository") {
|
||||
repositories.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (!listNode.items.pageInfo.hasNextPage) break;
|
||||
cursor = listNode.items.pageInfo.endCursor;
|
||||
} while (cursor);
|
||||
|
||||
return repositories;
|
||||
}
|
||||
|
||||
function mapGraphqlRepoToGitRepo(repo: GitHubRepositoryListItem): GitRepo {
|
||||
const visibility = (repo.visibility ?? "PUBLIC").toLowerCase() as GitRepo["visibility"];
|
||||
const createdAt = repo.createdAt ? new Date(repo.createdAt) : new Date();
|
||||
const updatedAt = repo.updatedAt ? new Date(repo.updatedAt) : new Date();
|
||||
|
||||
return {
|
||||
name: repo.name,
|
||||
fullName: repo.nameWithOwner,
|
||||
url: repo.url,
|
||||
cloneUrl: toHttpsCloneUrl(repo.url),
|
||||
|
||||
owner: repo.owner.login,
|
||||
organization: repo.owner.__typename === "Organization" ? repo.owner.login : undefined,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.isPrivate,
|
||||
isForked: repo.isFork,
|
||||
forkedFrom: undefined,
|
||||
|
||||
hasIssues: repo.hasIssuesEnabled,
|
||||
isStarred: true,
|
||||
isArchived: repo.isArchived,
|
||||
|
||||
size: repo.diskUsage ?? 0,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
|
||||
language: repo.primaryLanguage?.name ?? null,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.defaultBranchRef?.name || "main",
|
||||
visibility,
|
||||
|
||||
status: "imported",
|
||||
isDisabled: repo.isDisabled,
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
|
||||
importedAt: new Date(),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGithubStarredRepositories({
|
||||
octokit,
|
||||
config,
|
||||
@@ -308,6 +569,46 @@ export async function getGithubStarredRepositories({
|
||||
config: Partial<Config>;
|
||||
}): Promise<GitRepo[]> {
|
||||
try {
|
||||
const configuredLists = normalizeStarredListNames(
|
||||
config.githubConfig?.starredLists,
|
||||
);
|
||||
|
||||
if (configuredLists.length > 0) {
|
||||
const allLists = await getGithubStarLists(octokit);
|
||||
const configuredMatchKeySet = new Set(
|
||||
configuredLists.map((list) => getStarredListMatchKey(list)),
|
||||
);
|
||||
|
||||
const matchedLists = allLists.filter((list) =>
|
||||
configuredMatchKeySet.has(getStarredListMatchKey(list.name)),
|
||||
);
|
||||
|
||||
if (matchedLists.length === 0) {
|
||||
const availableListNames = normalizeStarredListNames(
|
||||
allLists.map((list) => list.name),
|
||||
);
|
||||
const preview = availableListNames.slice(0, 20).join(", ");
|
||||
const availableSuffix = preview
|
||||
? `. Available lists: ${preview}${availableListNames.length > 20 ? ", ..." : ""}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`Configured GitHub star lists not found: ${configuredLists.join(", ")}${availableSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
const deduped = new Map<string, GitRepo>();
|
||||
for (const list of matchedLists) {
|
||||
const repos = await getGithubRepositoriesForStarList(octokit, list.id);
|
||||
for (const repo of repos) {
|
||||
const key = repo.nameWithOwner.toLowerCase();
|
||||
if (deduped.has(key)) continue;
|
||||
deduped.set(key, mapGraphqlRepoToGitRepo(repo));
|
||||
}
|
||||
}
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
const starredRepos = await octokit.paginate(
|
||||
octokit.activity.listReposStarredByAuthenticatedUser,
|
||||
{
|
||||
@@ -362,15 +663,26 @@ 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
|
||||
*/
|
||||
export async function getGithubOrganizations({
|
||||
octokit,
|
||||
config,
|
||||
skipOrgNames,
|
||||
}: {
|
||||
octokit: Octokit;
|
||||
config: Partial<Config>;
|
||||
skipOrgNames?: Set<string>;
|
||||
}): Promise<{ organizations: GitOrg[]; failedOrgs: { name: string; avatarUrl: string; reason: string }[] }> {
|
||||
try {
|
||||
const { data: orgs } = await octokit.orgs.listForAuthenticatedUser({
|
||||
@@ -383,7 +695,7 @@ export async function getGithubOrganizations({
|
||||
? excludedOrgsEnv.split(",").map((org) => org.trim().toLowerCase())
|
||||
: [];
|
||||
|
||||
// Filter out excluded organizations
|
||||
// Filter out excluded and user-ignored organizations
|
||||
const filteredOrgs = orgs.filter((org) => {
|
||||
if (excludedOrgs.includes(org.login.toLowerCase())) {
|
||||
console.log(
|
||||
@@ -391,6 +703,10 @@ export async function getGithubOrganizations({
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (skipOrgNames?.has(org.login.toLowerCase())) {
|
||||
console.log(`Skipping organization ${org.login} - ignored by user`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
+30
-5
@@ -3,6 +3,7 @@ import { db, mirrorJobs } from "./db";
|
||||
import { eq, and, or, lt, isNull } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { publishEvent } from "./events";
|
||||
import { triggerJobNotification } from "./notification-service";
|
||||
|
||||
export async function createMirrorJob({
|
||||
userId,
|
||||
@@ -19,6 +20,7 @@ export async function createMirrorJob({
|
||||
itemIds,
|
||||
inProgress,
|
||||
skipDuplicateEvent,
|
||||
skipNotification,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
@@ -34,6 +36,7 @@ export async function createMirrorJob({
|
||||
itemIds?: string[];
|
||||
inProgress?: boolean;
|
||||
skipDuplicateEvent?: boolean; // Option to skip event publishing for internal operations
|
||||
skipNotification?: boolean; // Option to skip push notifications for specific internal operations
|
||||
}) {
|
||||
const jobId = uuidv4();
|
||||
const currentTimestamp = new Date();
|
||||
@@ -67,7 +70,7 @@ export async function createMirrorJob({
|
||||
// Insert the job into the database
|
||||
await db.insert(mirrorJobs).values(job);
|
||||
|
||||
// Publish the event using SQLite instead of Redis (unless skipped)
|
||||
// Publish realtime status events unless explicitly skipped
|
||||
if (!skipDuplicateEvent) {
|
||||
const channel = `mirror-status:${userId}`;
|
||||
|
||||
@@ -89,6 +92,15 @@ export async function createMirrorJob({
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger push notifications for terminal statuses (never blocks the mirror flow).
|
||||
// Keep this independent from skipDuplicateEvent so event-stream suppression does not
|
||||
// silently disable user-facing notifications.
|
||||
if (!skipNotification && (status === "failed" || status === "mirrored" || status === "synced")) {
|
||||
triggerJobNotification({ userId, status, repositoryName, organizationName, message, details }).catch(err => {
|
||||
console.error("[NotificationService] Background notification failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
return jobId;
|
||||
} catch (error) {
|
||||
console.error("Error creating mirror job:", error);
|
||||
@@ -204,9 +216,21 @@ export async function updateMirrorJobProgress({
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds interrupted jobs that need to be resumed with enhanced criteria
|
||||
* Finds interrupted jobs that need to be resumed with enhanced criteria.
|
||||
*
|
||||
* `logFound` defaults to false because this function is polled from
|
||||
* passive callers (`hasJobsNeedingRecovery` from the health endpoint
|
||||
* and middleware checks). Logging on every poll produces log spam at
|
||||
* one-line-per-poll-per-stuck-job for as long as a job stays stuck.
|
||||
*
|
||||
* Callers that intend to act on the result (i.e. immediately resume
|
||||
* the returned jobs) should pass `logFound: true` so the surfacing
|
||||
* still happens in the recovery flow.
|
||||
*/
|
||||
export async function findInterruptedJobs() {
|
||||
export async function findInterruptedJobs(
|
||||
options: { logFound?: boolean } = {}
|
||||
) {
|
||||
const { logFound = false } = options;
|
||||
try {
|
||||
// Find jobs that are marked as in-progress but haven't been updated recently
|
||||
const cutoffTime = new Date();
|
||||
@@ -231,8 +255,9 @@ export async function findInterruptedJobs() {
|
||||
)
|
||||
);
|
||||
|
||||
// Log details about found jobs for debugging
|
||||
if (interruptedJobs.length > 0) {
|
||||
// Log details about found jobs for debugging — opt-in to avoid
|
||||
// spamming the log when called from periodic passive checks.
|
||||
if (logFound && interruptedJobs.length > 0) {
|
||||
console.log(`Found ${interruptedJobs.length} interrupted jobs:`);
|
||||
interruptedJobs.forEach(job => {
|
||||
const lastCheckpoint = job.lastCheckpoint ? new Date(job.lastCheckpoint).toISOString() : 'never';
|
||||
|
||||
@@ -6,9 +6,22 @@ interface MetadataComponentsState {
|
||||
milestones: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot record of a deleted-branch backup we already took, so the
|
||||
* force-push detector knows to skip the same (branch, giteaSha) pair
|
||||
* next sync. Without this, deleted-on-GitHub branches that linger in
|
||||
* the Gitea mirror trip the detector every cycle and create a new
|
||||
* "Snapshot created" job row forever.
|
||||
*/
|
||||
export interface AcknowledgedDeletion {
|
||||
branch: string;
|
||||
giteaSha: string;
|
||||
}
|
||||
|
||||
export interface RepositoryMetadataState {
|
||||
components: MetadataComponentsState;
|
||||
lastSyncedAt?: string;
|
||||
acknowledgedDeletions: AcknowledgedDeletion[];
|
||||
}
|
||||
|
||||
const defaultComponents: MetadataComponentsState = {
|
||||
@@ -22,6 +35,7 @@ const defaultComponents: MetadataComponentsState = {
|
||||
export function createDefaultMetadataState(): RepositoryMetadataState {
|
||||
return {
|
||||
components: { ...defaultComponents },
|
||||
acknowledgedDeletions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +79,20 @@ export function parseRepositoryMetadataState(
|
||||
base.lastSyncedAt = parsed.lastMetadataSync;
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.acknowledgedDeletions)) {
|
||||
base.acknowledgedDeletions = parsed.acknowledgedDeletions.flatMap(
|
||||
(entry: unknown): AcknowledgedDeletion[] => {
|
||||
if (!entry || typeof entry !== "object") return [];
|
||||
const branch = (entry as { branch?: unknown }).branch;
|
||||
const giteaSha = (entry as { giteaSha?: unknown }).giteaSha;
|
||||
if (typeof branch !== "string" || typeof giteaSha !== "string") {
|
||||
return [];
|
||||
}
|
||||
return [{ branch, giteaSha }];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// Mock fetch globally before importing the module
|
||||
let mockFetch: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = mock(() =>
|
||||
Promise.resolve(new Response("ok", { status: 200 }))
|
||||
);
|
||||
globalThis.fetch = mockFetch as any;
|
||||
});
|
||||
|
||||
// Mock encryption module
|
||||
mock.module("@/lib/utils/encryption", () => ({
|
||||
encrypt: (val: string) => val,
|
||||
decrypt: (val: string) => val,
|
||||
isEncrypted: () => false,
|
||||
}));
|
||||
|
||||
// Import after mocks are set up — db is already mocked via setup.bun.ts
|
||||
import { sendNotification, testNotification } from "./notification-service";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
|
||||
describe("sendNotification", () => {
|
||||
test("sends ntfy notification when provider is ntfy", async () => {
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
await sendNotification(config, {
|
||||
title: "Test",
|
||||
message: "Test message",
|
||||
type: "sync_success",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("https://ntfy.sh/test-topic");
|
||||
});
|
||||
|
||||
test("sends apprise notification when provider is apprise", async () => {
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "apprise",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
apprise: {
|
||||
url: "http://apprise:8000",
|
||||
token: "my-token",
|
||||
},
|
||||
};
|
||||
|
||||
await sendNotification(config, {
|
||||
title: "Test",
|
||||
message: "Test message",
|
||||
type: "sync_success",
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("http://apprise:8000/notify/my-token");
|
||||
});
|
||||
|
||||
test("does not throw when fetch fails", async () => {
|
||||
mockFetch = mock(() => Promise.reject(new Error("Network error")));
|
||||
globalThis.fetch = mockFetch as any;
|
||||
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await sendNotification(config, {
|
||||
title: "Test",
|
||||
message: "Test message",
|
||||
type: "sync_success",
|
||||
});
|
||||
});
|
||||
|
||||
test("skips notification when ntfy topic is missing", async () => {
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
await sendNotification(config, {
|
||||
title: "Test",
|
||||
message: "Test message",
|
||||
type: "sync_success",
|
||||
});
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips notification when apprise URL is missing", async () => {
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "apprise",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
apprise: {
|
||||
url: "",
|
||||
token: "my-token",
|
||||
},
|
||||
};
|
||||
|
||||
await sendNotification(config, {
|
||||
title: "Test",
|
||||
message: "Test message",
|
||||
type: "sync_success",
|
||||
});
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("testNotification", () => {
|
||||
test("returns success when notification is sent", async () => {
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await testNotification(config);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns error when topic is missing", async () => {
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await testNotification(config);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("topic");
|
||||
});
|
||||
|
||||
test("returns error when fetch fails", async () => {
|
||||
mockFetch = mock(() =>
|
||||
Promise.resolve(new Response("bad request", { status: 400 }))
|
||||
);
|
||||
globalThis.fetch = mockFetch as any;
|
||||
|
||||
const config: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await testNotification(config);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
test("returns error for unknown provider", async () => {
|
||||
const config = {
|
||||
enabled: true,
|
||||
provider: "unknown" as any,
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
};
|
||||
|
||||
const result = await testNotification(config);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Unknown provider");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
import type { NotificationEvent } from "./providers/ntfy";
|
||||
import { sendNtfyNotification } from "./providers/ntfy";
|
||||
import { sendAppriseNotification } from "./providers/apprise";
|
||||
import { db, configs } from "@/lib/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
|
||||
function sanitizeTestNotificationError(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return "Failed to send test notification";
|
||||
}
|
||||
|
||||
const safeErrorPatterns = [
|
||||
/topic is required/i,
|
||||
/url and token are required/i,
|
||||
/unknown provider/i,
|
||||
/bad request/i,
|
||||
/unauthorized/i,
|
||||
/forbidden/i,
|
||||
/not found/i,
|
||||
/timeout/i,
|
||||
/network error/i,
|
||||
/invalid/i,
|
||||
];
|
||||
|
||||
if (safeErrorPatterns.some((pattern) => pattern.test(error.message))) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return "Failed to send test notification";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification using the configured provider.
|
||||
* NEVER throws -- all errors are caught and logged.
|
||||
*/
|
||||
export async function sendNotification(
|
||||
config: NotificationConfig,
|
||||
event: NotificationEvent,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (config.provider === "ntfy") {
|
||||
if (!config.ntfy?.topic) {
|
||||
console.warn("[NotificationService] Ntfy topic is not configured, skipping notification");
|
||||
return;
|
||||
}
|
||||
await sendNtfyNotification(config.ntfy, event);
|
||||
} else if (config.provider === "apprise") {
|
||||
if (!config.apprise?.url || !config.apprise?.token) {
|
||||
console.warn("[NotificationService] Apprise URL or token is not configured, skipping notification");
|
||||
return;
|
||||
}
|
||||
await sendAppriseNotification(config.apprise, event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[NotificationService] Failed to send notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a test notification and returns the result.
|
||||
* Unlike sendNotification, this propagates the success/error status
|
||||
* so the UI can display the outcome.
|
||||
*/
|
||||
export async function testNotification(
|
||||
notificationConfig: NotificationConfig,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const event: NotificationEvent = {
|
||||
title: "Gitea Mirror - Test Notification",
|
||||
message: "This is a test notification from Gitea Mirror. If you see this, notifications are working correctly!",
|
||||
type: "sync_success",
|
||||
};
|
||||
|
||||
try {
|
||||
if (notificationConfig.provider === "ntfy") {
|
||||
if (!notificationConfig.ntfy?.topic) {
|
||||
return { success: false, error: "Ntfy topic is required" };
|
||||
}
|
||||
await sendNtfyNotification(notificationConfig.ntfy, event);
|
||||
} else if (notificationConfig.provider === "apprise") {
|
||||
if (!notificationConfig.apprise?.url || !notificationConfig.apprise?.token) {
|
||||
return { success: false, error: "Apprise URL and token are required" };
|
||||
}
|
||||
await sendAppriseNotification(notificationConfig.apprise, event);
|
||||
} else {
|
||||
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: sanitizeTestNotificationError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the user's notification config from the database and triggers
|
||||
* a notification if the event type matches the user's preferences.
|
||||
*
|
||||
* NEVER throws -- all errors are caught and logged. This function is
|
||||
* designed to be called fire-and-forget from the mirror job system.
|
||||
*/
|
||||
export async function triggerJobNotification({
|
||||
userId,
|
||||
status,
|
||||
repositoryName,
|
||||
organizationName,
|
||||
message,
|
||||
details,
|
||||
}: {
|
||||
userId: string;
|
||||
status: string;
|
||||
repositoryName?: string | null;
|
||||
organizationName?: string | null;
|
||||
message?: string;
|
||||
details?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
// Only trigger for terminal statuses
|
||||
if (status !== "failed" && status !== "mirrored" && status !== "synced") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch user's config from database — prefer active and most-recently-updated
|
||||
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResults = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (configResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userConfig = configResults[0];
|
||||
const notificationConfig = userConfig.notificationConfig as NotificationConfig | undefined;
|
||||
|
||||
if (!notificationConfig?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check event type against user preferences
|
||||
const isError = status === "failed";
|
||||
const isSuccess = status === "mirrored" || status === "synced";
|
||||
|
||||
if (isError && !notificationConfig.notifyOnSyncError) {
|
||||
return;
|
||||
}
|
||||
if (isSuccess && !notificationConfig.notifyOnSyncSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only decrypt the active provider's token to avoid failures from stale
|
||||
// credentials on the inactive provider dropping the entire notification
|
||||
const decryptedConfig = { ...notificationConfig };
|
||||
if (decryptedConfig.provider === "ntfy" && decryptedConfig.ntfy?.token) {
|
||||
decryptedConfig.ntfy = {
|
||||
...decryptedConfig.ntfy,
|
||||
token: decrypt(decryptedConfig.ntfy.token),
|
||||
};
|
||||
}
|
||||
if (decryptedConfig.provider === "apprise" && decryptedConfig.apprise?.token) {
|
||||
decryptedConfig.apprise = {
|
||||
...decryptedConfig.apprise,
|
||||
token: decrypt(decryptedConfig.apprise.token),
|
||||
};
|
||||
}
|
||||
|
||||
// Build event
|
||||
const repoLabel = repositoryName || organizationName || "Unknown";
|
||||
const eventType: NotificationEvent["type"] = isError ? "sync_error" : "sync_success";
|
||||
|
||||
const event: NotificationEvent = {
|
||||
title: isError
|
||||
? `Mirror Failed: ${repoLabel}`
|
||||
: `Mirror Success: ${repoLabel}`,
|
||||
message: [
|
||||
message || `Repository ${repoLabel} ${isError ? "failed to mirror" : "mirrored successfully"}`,
|
||||
details ? `\nDetails: ${details}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(""),
|
||||
type: eventType,
|
||||
};
|
||||
|
||||
await sendNotification(decryptedConfig, event);
|
||||
} catch (error) {
|
||||
console.error("[NotificationService] Background notification failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Regression test for the "interrupted jobs never resume after
|
||||
* startup" orchestration bug.
|
||||
*
|
||||
* Symptom (before this fix):
|
||||
* - Server starts cleanly. Middleware runs initial recovery pass,
|
||||
* finds no interrupted jobs, sets `recoveryAttempted = true` and
|
||||
* `recoveryInitialized = true`.
|
||||
* - User triggers a sync at T=N (well after startup). The sync
|
||||
* creates a `mirrorJobs` row with `inProgress=true`.
|
||||
* - The sync fails mid-flight (deadlock retry, network blip,
|
||||
* container restart of an upstream service, etc.) and never
|
||||
* reaches the resume codepath, so the row stays
|
||||
* `inProgress=true` with no checkpoint.
|
||||
* - `findInterruptedJobs` (called periodically from the health
|
||||
* endpoint via `hasJobsNeedingRecovery`) detects it and logs
|
||||
* `Found 1 interrupted jobs:` on every poll.
|
||||
* - But the resumer (`resumeInterruptedJob`) is only invoked from
|
||||
* `initializeRecovery`, which is gated behind the
|
||||
* once-per-process `!recoveryAttempted` check in
|
||||
* `src/middleware.ts`. That check is false after startup, so the
|
||||
* resumer NEVER fires again. The job is stuck forever.
|
||||
*
|
||||
* Root cause: the middleware gate was symmetric — "skip recovery if
|
||||
* we've ever attempted it" — but it should have been "always
|
||||
* re-evaluate; only the recovery routine's own 5-minute throttle
|
||||
* (`skipIfRecentAttempt` inside `initializeRecovery`) prevents
|
||||
* thrashing".
|
||||
*
|
||||
* Secondary issue: `findInterruptedJobs` logged unconditionally on
|
||||
* every call, even from passive checks like `hasJobsNeedingRecovery`,
|
||||
* producing log spam at one line per poll per stuck job.
|
||||
*
|
||||
* This test asserts on the *structure* of the source rather than
|
||||
* invoking the middleware, because exercising the middleware path
|
||||
* requires a full Astro request pipeline with heavy mocks. See
|
||||
* `gitea-mirror-failure-recovery.test.ts` and
|
||||
* `gitea-issue-dedup-on-retry.test.ts` for the same convention.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MIDDLEWARE_SRC = readFileSync(
|
||||
join(import.meta.dir, "../middleware.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const HELPERS_SRC = readFileSync(
|
||||
join(import.meta.dir, "helpers.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const RECOVERY_SRC = readFileSync(
|
||||
join(import.meta.dir, "recovery.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("orchestrator: resume interrupted jobs after startup", () => {
|
||||
test("middleware no longer gates recovery behind once-per-process `recoveryAttempted`", () => {
|
||||
// The old gate looked like:
|
||||
// if (!recoveryInitialized && !recoveryAttempted) {
|
||||
// recoveryAttempted = true;
|
||||
// ...
|
||||
// }
|
||||
// Once both flags flipped on the first request, recovery never
|
||||
// ran again — even if jobs got stuck mid-runtime.
|
||||
expect(
|
||||
/\brecoveryAttempted\b/.test(MIDDLEWARE_SRC),
|
||||
"the `recoveryAttempted` once-per-process flag must be removed " +
|
||||
"from middleware.ts so post-startup interruptions can recover"
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("middleware uses an in-flight latch (not a one-shot gate) for runtime safety", () => {
|
||||
// The replacement uses `recoveryInFlight` as a per-request
|
||||
// mutex — set true at the start, set false in `finally`. The
|
||||
// actual throttle (5-minute "recent attempt") lives inside
|
||||
// `initializeRecovery()` in recovery.ts, which is the right
|
||||
// place for it.
|
||||
expect(
|
||||
/\brecoveryInFlight\b/.test(MIDDLEWARE_SRC),
|
||||
"middleware should use `recoveryInFlight` as the in-flight latch"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/recoveryInFlight\s*=\s*false/.test(MIDDLEWARE_SRC) &&
|
||||
/\bfinally\s*\{[\s\S]*?recoveryInFlight\s*=\s*false[\s\S]*?\}/.test(
|
||||
MIDDLEWARE_SRC
|
||||
),
|
||||
"the in-flight latch must be released in a `finally` block " +
|
||||
"so an exception during recovery doesn't permanently jam the latch"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("findInterruptedJobs logging is opt-in (default off) to stop poll spam", () => {
|
||||
// Active recovery callers (initializeRecovery) opt in by passing
|
||||
// { logFound: true }; passive checks (hasJobsNeedingRecovery,
|
||||
// health endpoint, etc.) default to silent.
|
||||
expect(
|
||||
/export async function findInterruptedJobs\(\s*options[^)]*\)/.test(
|
||||
HELPERS_SRC
|
||||
),
|
||||
"findInterruptedJobs should accept an options object"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/logFound\s*=\s*false/.test(HELPERS_SRC),
|
||||
"the `logFound` option should default to false " +
|
||||
"so periodic passive checks (e.g. hasJobsNeedingRecovery) " +
|
||||
"don't spam the log on every poll"
|
||||
).toBe(true);
|
||||
expect(
|
||||
/if\s*\(\s*logFound\s*&&\s*interruptedJobs\.length\s*>\s*0\s*\)/.test(
|
||||
HELPERS_SRC
|
||||
),
|
||||
"the `Found N interrupted jobs` log must be gated by `logFound`"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("active recovery path opts in to per-job logging", () => {
|
||||
// Without this, the actual recovery cycle would also be silent
|
||||
// — operators need to see which jobs are being resumed.
|
||||
expect(
|
||||
/findInterruptedJobs\(\s*\{\s*logFound:\s*true\s*\}\s*\)/.test(
|
||||
RECOVERY_SRC
|
||||
),
|
||||
"initializeRecovery() must pass { logFound: true } to findInterruptedJobs " +
|
||||
"so the active recovery cycle still logs which jobs it's working on"
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user