mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 03:56:46 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b78a6a4a8 | ||
|
|
c2f6e73054 | ||
|
|
c4b353aae8 |
24
README.md
24
README.md
@@ -216,28 +216,32 @@ Gitea Mirror provides powerful automatic synchronization features:
|
||||
- **Repository cleanup**: Removes repositories that no longer exist in GitHub
|
||||
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea'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
|
||||
- **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!
|
||||
|
||||
#### Configuration via Web Interface (Recommended)
|
||||
Navigate to the Configuration page and enable "Automatic Syncing" with your preferred interval.
|
||||
|
||||
#### Configuration via Environment Variables
|
||||
|
||||
**Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
|
||||
1. Import all your GitHub repositories on startup
|
||||
2. Mirror them to Gitea 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
|
||||
**🚀 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
|
||||
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
|
||||
|
||||
```bash
|
||||
# Enable automatic scheduling (required for auto features)
|
||||
# Option 1: Enable automatic scheduling (triggers auto-start)
|
||||
SCHEDULE_ENABLED=true
|
||||
SCHEDULE_INTERVAL=3600 # Check every hour (or use cron: "0 * * * *")
|
||||
|
||||
# Mirror interval (how often to sync)
|
||||
GITEA_MIRROR_INTERVAL=8h # Every 8 hours (default)
|
||||
# Option 2: Set mirror interval (also triggers auto-start)
|
||||
GITEA_MIRROR_INTERVAL=8h # Every 8 hours
|
||||
# Other examples: 5m, 30m, 1h, 24h, 1d, 7d
|
||||
|
||||
# Advanced: Use cron expressions for specific times
|
||||
SCHEDULE_INTERVAL="0 2 * * *" # Daily at 2 AM (optimize bandwidth usage)
|
||||
|
||||
# Auto-import new repositories (default: true)
|
||||
AUTO_IMPORT_REPOS=true
|
||||
|
||||
|
||||
8
bun.lock
8
bun.lock
@@ -8,7 +8,7 @@
|
||||
"@astrojs/mdx": "4.3.4",
|
||||
"@astrojs/node": "9.4.3",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@better-auth/sso": "^1.3.7",
|
||||
"@better-auth/sso": "^1.3.8",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-auth": "^1.3.7",
|
||||
"better-auth": "^1.3.8",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@better-auth/sso": ["@better-auth/sso@1.3.7", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "better-auth": "^1.3.7", "fast-xml-parser": "^5.2.5", "jose": "^5.9.6", "oauth2-mock-server": "^7.2.0", "samlify": "^2.10.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-MTwBiNash7HN0nLtQiL1tvYgWBn6GjYj6EYvtrQeb0/+UW0tjBDgsl39ojiFFSWGuT0gxPv+ij8tQNaFmQ1+2g=="],
|
||||
"@better-auth/sso": ["@better-auth/sso@1.3.8", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "fast-xml-parser": "^5.2.5", "jose": "^5.10.0", "oauth2-mock-server": "^7.2.1", "samlify": "^2.10.1", "zod": "^4.1.5" }, "peerDependencies": { "better-auth": "1.3.8" } }, "sha512-ohJl4uTRwVACu8840A5Ys/z2jus/vEsCrWvOj/RannsZ6CxQAjr8utYYXXs6lVn08ynOcuT4m0OsYRbrw7a42g=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.2.6", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA=="],
|
||||
|
||||
@@ -683,7 +683,7 @@
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"better-auth": ["better-auth@1.3.7", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "^1.0.13", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-/1fEyx2SGgJQM5ujozDCh9eJksnVkNU/J7Fk/tG5Y390l8nKbrPvqiFlCjlMM+scR+UABJbQzA6An7HT50LHyQ=="],
|
||||
"better-auth": ["better-auth@1.3.8", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.16", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4", "zod": "^4.1.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-uRFzHbWkhr8eWNy+BJwyMnrZPOvQjwrcLND3nc6jusRteYA9cjeRGElgCPTWTIyWUfzaQ708Lb5Mdq9Gv41Qpw=="],
|
||||
|
||||
"better-call": ["better-call@1.0.16", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA=="],
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for Authentik
|
||||
authentik-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: authentik-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: authentik-db-password
|
||||
POSTGRES_DB: authentik
|
||||
volumes:
|
||||
- authentik-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- authentik-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis cache for Authentik
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: authentik-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik-redis-data:/data
|
||||
networks:
|
||||
- authentik-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Authentik Server
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:2024.2
|
||||
container_name: authentik-server
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
# Core Settings
|
||||
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: false
|
||||
|
||||
# Database
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
|
||||
|
||||
# Redis
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
|
||||
# Email (optional - for testing, uses console backend)
|
||||
AUTHENTIK_EMAIL__HOST: localhost
|
||||
AUTHENTIK_EMAIL__PORT: 25
|
||||
AUTHENTIK_EMAIL__USE_TLS: false
|
||||
AUTHENTIK_EMAIL__USE_SSL: false
|
||||
AUTHENTIK_EMAIL__TIMEOUT: 10
|
||||
AUTHENTIK_EMAIL__FROM: authentik@localhost
|
||||
|
||||
# Log Level
|
||||
AUTHENTIK_LOG_LEVEL: info
|
||||
|
||||
# Disable analytics
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: true
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
|
||||
|
||||
# Default admin user (only created on first run)
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: admin-password
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: initial-admin-token
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: admin@example.com
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
ports:
|
||||
- "9000:9000" # HTTP
|
||||
- "9443:9443" # HTTPS (if configured)
|
||||
networks:
|
||||
- authentik-net
|
||||
- gitea-mirror-net
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Authentik Worker (background tasks)
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:2024.2
|
||||
container_name: authentik-worker
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
# Same environment as server
|
||||
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: false
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_EMAIL__HOST: localhost
|
||||
AUTHENTIK_EMAIL__PORT: 25
|
||||
AUTHENTIK_EMAIL__USE_TLS: false
|
||||
AUTHENTIK_EMAIL__USE_SSL: false
|
||||
AUTHENTIK_EMAIL__TIMEOUT: 10
|
||||
AUTHENTIK_EMAIL__FROM: authentik@localhost
|
||||
AUTHENTIK_LOG_LEVEL: info
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: true
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
networks:
|
||||
- authentik-net
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Gitea Mirror Application (uncomment to run together)
|
||||
# gitea-mirror:
|
||||
# build: .
|
||||
# # OR use pre-built image:
|
||||
# # image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
# container_name: gitea-mirror
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# # Core Settings
|
||||
# BETTER_AUTH_URL: http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:9000
|
||||
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
|
||||
#
|
||||
# # GitHub Settings (configure as needed)
|
||||
# GITHUB_USERNAME: ${GITHUB_USERNAME}
|
||||
# GITHUB_TOKEN: ${GITHUB_TOKEN}
|
||||
#
|
||||
# # Gitea Settings (configure as needed)
|
||||
# GITEA_URL: ${GITEA_URL}
|
||||
# GITEA_USERNAME: ${GITEA_USERNAME}
|
||||
# GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
# ports:
|
||||
# - "4321:4321"
|
||||
# networks:
|
||||
# - gitea-mirror-net
|
||||
# depends_on:
|
||||
# - authentik-server
|
||||
|
||||
volumes:
|
||||
authentik-db-data:
|
||||
name: authentik-db-data
|
||||
authentik-redis-data:
|
||||
name: authentik-redis-data
|
||||
authentik-media:
|
||||
name: authentik-media
|
||||
authentik-templates:
|
||||
name: authentik-templates
|
||||
|
||||
networks:
|
||||
authentik-net:
|
||||
name: authentik-net
|
||||
driver: bridge
|
||||
gitea-mirror-net:
|
||||
name: gitea-mirror-net
|
||||
driver: bridge
|
||||
@@ -1,130 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for Keycloak
|
||||
keycloak-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: keycloak-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: keycloak-db-password
|
||||
volumes:
|
||||
- keycloak-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- keycloak-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keycloak"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Keycloak Identity Provider
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:23.0
|
||||
container_name: keycloak
|
||||
restart: unless-stopped
|
||||
command: start-dev # Use 'start' for production with HTTPS
|
||||
environment:
|
||||
# Admin credentials
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin-password
|
||||
|
||||
# Database configuration
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: keycloak-db
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_USERNAME: keycloak
|
||||
KC_DB_PASSWORD: keycloak-db-password
|
||||
|
||||
# HTTP settings
|
||||
KC_HTTP_ENABLED: true
|
||||
KC_HTTP_PORT: 8080
|
||||
KC_HOSTNAME_STRICT: false
|
||||
KC_HOSTNAME_STRICT_HTTPS: false
|
||||
KC_PROXY: edge # If behind a proxy
|
||||
|
||||
# Development settings (remove for production)
|
||||
KC_HOSTNAME: localhost
|
||||
KC_HOSTNAME_PORT: 8080
|
||||
KC_HOSTNAME_ADMIN: localhost
|
||||
|
||||
# Features
|
||||
KC_FEATURES: token-exchange,admin-fine-grained-authz
|
||||
|
||||
# Health and metrics
|
||||
KC_HEALTH_ENABLED: true
|
||||
KC_METRICS_ENABLED: true
|
||||
|
||||
# Log level
|
||||
KC_LOG_LEVEL: INFO
|
||||
# Uncomment for debug logging
|
||||
# KC_LOG_LEVEL: DEBUG
|
||||
# QUARKUS_LOG_CATEGORY__ORG_KEYCLOAK_SERVICES: DEBUG
|
||||
ports:
|
||||
- "8080:8080" # HTTP
|
||||
- "8443:8443" # HTTPS (if configured)
|
||||
- "9000:9000" # Management
|
||||
networks:
|
||||
- keycloak-net
|
||||
- gitea-mirror-net
|
||||
depends_on:
|
||||
keycloak-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# For custom themes (optional)
|
||||
- keycloak-themes:/opt/keycloak/themes
|
||||
# For importing realm configurations
|
||||
- ./keycloak-realm-export.json:/opt/keycloak/data/import/realm.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
# Gitea Mirror Application (uncomment to run together)
|
||||
# gitea-mirror:
|
||||
# build: .
|
||||
# # OR use pre-built image:
|
||||
# # image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
# container_name: gitea-mirror
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# # Core Settings
|
||||
# BETTER_AUTH_URL: http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:8080
|
||||
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
|
||||
#
|
||||
# # GitHub Settings (configure as needed)
|
||||
# GITHUB_USERNAME: ${GITHUB_USERNAME}
|
||||
# GITHUB_TOKEN: ${GITHUB_TOKEN}
|
||||
#
|
||||
# # Gitea Settings (configure as needed)
|
||||
# GITEA_URL: ${GITEA_URL}
|
||||
# GITEA_USERNAME: ${GITEA_USERNAME}
|
||||
# GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
# ports:
|
||||
# - "4321:4321"
|
||||
# networks:
|
||||
# - gitea-mirror-net
|
||||
# depends_on:
|
||||
# keycloak:
|
||||
# condition: service_healthy
|
||||
|
||||
volumes:
|
||||
keycloak-db-data:
|
||||
name: keycloak-db-data
|
||||
keycloak-themes:
|
||||
name: keycloak-themes
|
||||
|
||||
networks:
|
||||
keycloak-net:
|
||||
name: keycloak-net
|
||||
driver: bridge
|
||||
gitea-mirror-net:
|
||||
name: gitea-mirror-net
|
||||
driver: bridge
|
||||
@@ -151,15 +151,27 @@ Configure automatic scheduled mirroring.
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_ENABLED` | Enable automatic mirroring. **When set to `true`, automatically imports and mirrors all repositories on startup** (v3.5.3+) | `false` | `true`, `false` |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression | `3600` | Number or cron string (e.g., `"0 2 * * *"`) |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression. **Supports cron syntax for scheduled runs** (e.g., `"0 2 * * *"` for 2 AM daily) | `3600` | Number (seconds) or cron string |
|
||||
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
|
||||
|
||||
> **Note**: Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
|
||||
> 1. Import all GitHub repositories on startup
|
||||
> 2. Mirror them to Gitea immediately
|
||||
> 3. Continue syncing at the configured interval
|
||||
> 4. Auto-discover new repositories
|
||||
> 5. Clean up deleted repositories (if configured)
|
||||
> **🚀 Auto-Start Feature (v3.5.3+)**
|
||||
> Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
|
||||
> 1. **Import** all GitHub repositories on startup
|
||||
> 2. **Mirror** them to Gitea immediately
|
||||
> 3. **Continue syncing** at the configured interval
|
||||
> 4. **Auto-discover** new repositories
|
||||
> 5. **Clean up** deleted repositories (if configured)
|
||||
>
|
||||
> This eliminates the need for manual button clicks - perfect for Docker/Kubernetes deployments!
|
||||
|
||||
> **⏰ Scheduling with Cron Expressions**
|
||||
> Use cron expressions in `SCHEDULE_INTERVAL` to run at specific times:
|
||||
> - `"0 2 * * *"` - Daily at 2 AM
|
||||
> - `"0 */6 * * *"` - Every 6 hours
|
||||
> - `"0 0 * * 0"` - Weekly on Sunday at midnight
|
||||
> - `"0 3 * * 1-5"` - Weekdays at 3 AM (Monday-Friday)
|
||||
>
|
||||
> This is useful for optimizing bandwidth usage during low-activity periods.
|
||||
|
||||
### Execution Settings
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.4",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -46,7 +46,7 @@
|
||||
"@astrojs/mdx": "4.3.4",
|
||||
"@astrojs/node": "9.4.3",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@better-auth/sso": "^1.3.7",
|
||||
"@better-auth/sso": "^1.3.8",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -72,7 +72,7 @@
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-auth": "^1.3.7",
|
||||
"better-auth": "^1.3.8",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -7,15 +7,30 @@ export const authClient = createAuthClient({
|
||||
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
|
||||
// This allows the client to connect to the auth server even when accessed from different origins
|
||||
baseURL: (() => {
|
||||
let url: string | undefined;
|
||||
|
||||
// Check for public environment variable first (for client-side access)
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) {
|
||||
return import.meta.env.PUBLIC_BETTER_AUTH_URL;
|
||||
url = import.meta.env.PUBLIC_BETTER_AUTH_URL;
|
||||
}
|
||||
|
||||
// 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
|
||||
} catch (e) {
|
||||
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to current origin if running in browser
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Default for SSR
|
||||
|
||||
// Default for SSR - always return a valid URL
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
|
||||
@@ -19,42 +19,71 @@ export const auth = betterAuth({
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
const defaultUrl = "http://localhost:4321";
|
||||
|
||||
// Check if URL is provided and not empty
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
console.info('BETTER_AUTH_URL not set, using default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate URL format
|
||||
new URL(url);
|
||||
return url;
|
||||
} catch {
|
||||
console.warn(`Invalid BETTER_AUTH_URL: ${url}, falling back to localhost`);
|
||||
return "http://localhost:4321";
|
||||
// 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
|
||||
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
||||
return cleanUrl;
|
||||
} catch (e) {
|
||||
console.error(`Invalid BETTER_AUTH_URL format: "${url}"`);
|
||||
console.error('Error:', e);
|
||||
console.info('Falling back to default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins = [
|
||||
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 || "http://localhost:4321";
|
||||
try {
|
||||
new URL(primaryUrl);
|
||||
origins.push(primaryUrl);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
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) {
|
||||
origins.push(...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map(o => o.trim()));
|
||||
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 return
|
||||
return [...new Set(origins.filter(Boolean))];
|
||||
// Remove duplicates and empty strings, then return
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
console.info('Trusted origins:', uniqueOrigins);
|
||||
return uniqueOrigins;
|
||||
})(),
|
||||
|
||||
// Authentication methods
|
||||
|
||||
@@ -25,9 +25,34 @@ export async function POST(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate issuer URL format
|
||||
let validatedIssuer = issuer;
|
||||
if (issuer && typeof issuer === 'string' && issuer.trim() !== '') {
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
validatedIssuer = issuerUrl.toString().replace(/\/$/, ''); // Remove trailing slash
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Issuer URL cannot be empty" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let registrationBody: any = {
|
||||
providerId,
|
||||
issuer,
|
||||
issuer: validatedIssuer,
|
||||
domain,
|
||||
organizationId,
|
||||
};
|
||||
@@ -91,14 +116,27 @@ export async function POST(context: APIContext) {
|
||||
// Use provided scopes or default if not specified
|
||||
const finalScopes = scopes || ["openid", "email", "profile"];
|
||||
|
||||
// Validate endpoint URLs if provided
|
||||
const validateUrl = (url: string | undefined, name: string): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
if (typeof url !== 'string' || url.trim() === '') return undefined;
|
||||
try {
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.toString();
|
||||
} catch (e) {
|
||||
console.warn(`Invalid ${name} URL: ${url}, skipping`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
registrationBody.oidcConfig = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
discoveryEndpoint,
|
||||
userInfoEndpoint,
|
||||
clientId: clientId || undefined,
|
||||
clientSecret: clientSecret || undefined,
|
||||
authorizationEndpoint: validateUrl(authorizationEndpoint, 'authorization endpoint'),
|
||||
tokenEndpoint: validateUrl(tokenEndpoint, 'token endpoint'),
|
||||
jwksEndpoint: validateUrl(jwksEndpoint, 'JWKS endpoint'),
|
||||
discoveryEndpoint: validateUrl(discoveryEndpoint, 'discovery endpoint'),
|
||||
userInfoEndpoint: validateUrl(userInfoEndpoint, 'userinfo endpoint'),
|
||||
scopes: finalScopes,
|
||||
pkce,
|
||||
};
|
||||
|
||||
@@ -10,26 +10,71 @@ export async function POST(context: APIContext) {
|
||||
|
||||
const { issuer } = await context.request.json();
|
||||
|
||||
if (!issuer) {
|
||||
return new Response(JSON.stringify({ error: "Issuer URL is required" }), {
|
||||
if (!issuer || typeof issuer !== 'string' || issuer.trim() === '') {
|
||||
return new Response(JSON.stringify({ error: "Issuer URL is required and must be a valid string" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure issuer URL ends without trailing slash for well-known discovery
|
||||
const cleanIssuer = issuer.replace(/\/$/, "");
|
||||
// Validate issuer URL format
|
||||
let cleanIssuer: string;
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); // Remove trailing slash
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid issuer URL format",
|
||||
details: `The provided URL "${issuer}" is not a valid URL. For Authentik, use format: https://your-authentik-domain/application/o/<app-slug>/`
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
// Fetch OIDC discovery document
|
||||
const response = await fetch(discoveryUrl);
|
||||
// Fetch OIDC discovery document with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(discoveryUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
throw new Error(`Request timeout: The OIDC provider at ${cleanIssuer} did not respond within 10 seconds`);
|
||||
}
|
||||
throw new Error(`Network error: Could not connect to ${cleanIssuer}. Please verify the URL is correct and accessible.`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch discovery document: ${response.status}`);
|
||||
if (response.status === 404) {
|
||||
throw new Error(`OIDC discovery document not found at ${discoveryUrl}. For Authentik, ensure you're using the correct application slug in the URL.`);
|
||||
} else if (response.status >= 500) {
|
||||
throw new Error(`OIDC provider error (${response.status}): The server at ${cleanIssuer} returned an error.`);
|
||||
} else {
|
||||
throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
let config: any;
|
||||
try {
|
||||
config = await response.json();
|
||||
} catch (parseError) {
|
||||
throw new Error(`Invalid response: The discovery document from ${cleanIssuer} is not valid JSON.`);
|
||||
}
|
||||
|
||||
// Extract the essential endpoints
|
||||
const discoveredConfig = {
|
||||
|
||||
Reference in New Issue
Block a user