Compare commits

...

7 Commits

Author SHA1 Message Date
ARUNAVO RAY
5d2462e5a0 feat: add notification system with Ntfy.sh and Apprise support (#238)
* feat: add notification system with Ntfy.sh and Apprise providers (#231)

Add push notification support for mirror job events with two providers:

- Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support
- Apprise API: aggregator gateway supporting 100+ notification services

Includes database migration (0010), settings UI tab, test endpoint,
auto-save integration, token encryption, and comprehensive tests.
Notifications are fire-and-forget and never block the mirror flow.

* fix: address review findings for notification system

- Fix silent catch in GET handler that returned ciphertext to UI,
  causing double-encryption on next save. Now clears token to ""
  on decryption failure instead.
- Add Zod schema validation to test notification endpoint, following
  project API route pattern guidelines.
- Mark notifyOnNewRepo toggle as "coming soon" with disabled state,
  since the backend doesn't yet emit new_repo events. The schema
  and type support is in place for when it's implemented.

* fix notification gating and config validation

* trim sync notification details
2026-03-18 18:36:51 +05:30
ARUNAVO RAY
0000a03ad6 fix: improve reverse proxy support for subdomain deployments (#237)
* fix: improve reverse proxy support for subdomain deployments (#63)

- Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx
  from buffering the event stream
- Auto-detect trusted origin from Host/X-Forwarded-* request headers
  so the app works behind a proxy without manual env var configuration
- Add prominent reverse proxy documentation to advanced docs page
  explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and
  BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments
- Add reverse proxy env var comments and entries to both
  docker-compose.yml and docker-compose.alt.yml
- Add dedicated reverse proxy configuration section to .env.example

* fix: address review findings for reverse proxy origin detection

- Fix x-forwarded-proto multi-value handling: take first value only
  and validate it is "http" or "https" before using
- Update comment to accurately describe auto-detection scope: helps
  with per-request CSRF checks but not callback URL validation
- Restore startup logging of static trusted origins for debugging

* fix: handle multi-value x-forwarded-host in chained proxy setups

x-forwarded-host can be comma-separated (e.g. "proxy1.example.com,
proxy2.example.com") in chained proxy setups. Take only the first
value, matching the same handling already applied to x-forwarded-proto.

* test: add unit tests for reverse proxy origin detection

Extract resolveTrustedOrigins into a testable exported function and
add 11 tests covering:
- Default localhost origins
- BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars
- Invalid URL handling
- Auto-detection from x-forwarded-host + x-forwarded-proto
- Multi-value header handling (chained proxy setups)
- Invalid proto rejection (only http/https allowed)
- Deduplication
- Fallback to host header when x-forwarded-host absent
2026-03-18 15:47:15 +05:30
ARUNAVO RAY
d697cb2bc9 fix: prevent starred repo name collisions during concurrent mirroring (#236)
* fix: prevent starred repo name collisions during concurrent mirroring (#95)

When multiple starred repos share the same short name (e.g. alice/dotfiles
and bob/dotfiles), concurrent batch mirroring could cause 409 Conflict
errors because generateUniqueRepoName only checked Gitea via HTTP, missing
repos that were claimed in the local DB but not yet created remotely.

Three fixes:
- Add DB-level check in generateUniqueRepoName so it queries the local
  repositories table for existing mirroredLocation claims, preventing two
  concurrent jobs from picking the same target name.
- Clear mirroredLocation on failed mirror so a failed repo doesn't falsely
  hold a location that was never successfully created, which would block
  retries and confuse the uniqueness check.
- Extract isMirroredLocationClaimedInDb helper for the DB lookup, using
  ne() to exclude the current repo's own record from the collision check.

* fix: address review findings for starred repo name collision fix

- Make generateUniqueRepoName immediately claim name by writing
  mirroredLocation to DB, closing the TOCTOU race window between
  name selection and the later status="mirroring" DB update
- Add fullName validation guard (must contain "/")
- Make isMirroredLocationClaimedInDb fail-closed (return true on
  DB error) to be conservative about preventing collisions
- Scope mirroredLocation clear on failure to starred repos only,
  preserving it for non-starred repos that may have partially
  created in Gitea and need the location for recovery

* fix: address P1/P2 review findings for starred repo name collision

P1a: Remove early name claiming from generateUniqueRepoName to prevent
stale claims on early return paths. The function now only checks
availability — the actual claim happens at the status="mirroring" DB
write (after both idempotency checks), which is protected by a new
unique partial index.

P1b: Add unique partial index on (userId, mirroredLocation) WHERE
mirroredLocation != '' via migration 0010. This enforces atomicity at
the DB level: if two concurrent workers try to claim the same name,
the second gets a constraint violation rather than silently colliding.

P2: Only clear mirroredLocation on failure if the Gitea migrate call
itself failed (migrateSucceeded flag). If migrate succeeded but
metadata mirroring failed, preserve the location since the repo
physically exists in Gitea and we need it for recovery/retry.
2026-03-18 15:27:20 +05:30
ARUNAVO RAY
ddd071f7e5 fix: prevent excessive disk usage from repo backups (#235)
* fix: prevent excessive disk usage from repo backups (#234)

Legacy configs with backupBeforeSync: true but no explicit backupStrategy
silently resolved to "always", creating full git bundles on every sync
cycle. This caused repo-backups to grow to 17GB+ for users with many
repositories.

Changes:
- Fix resolveBackupStrategy to map backupBeforeSync: true → "on-force-push"
  instead of "always", so legacy configs only backup when force-push is detected
- Fix config mapper to always set backupStrategy explicitly ("on-force-push")
  preventing the backward-compat fallback from triggering
- Lower default backupRetentionCount from 20 to 5 bundles per repo
- Add time-based retention (backupRetentionDays, default 30 days) alongside
  count-based retention, with safety net to always keep at least 1 bundle
- Add "high disk usage" warning on "Always Backup" UI option
- Update docs and tests to reflect new defaults and behavior

* fix: preserve legacy backupBeforeSync:false on UI round-trip and expose retention days

P1: mapDbToUiConfig now checks backupBeforeSync === false before
defaulting backupStrategy, preventing legacy "disabled" configs from
silently becoming "on-force-push" after any auto-save round-trip.

P3: Added "Snapshot retention days" input field to the backup settings
UI, matching the documented setting in FORCE_PUSH_PROTECTION.md.
2026-03-18 15:05:00 +05:30
Arunavo Ray
4629ab4335 chore: bump version to 3.13.3 2026-03-18 05:20:21 +05:30
Arunavo Ray
0f303c4b79 nix: regenerate bun.nix 2026-03-18 04:47:16 +05:30
ARUNAVO RAY
7c7c259d0a fix repo links to use external gitea url (#233) 2026-03-18 04:36:14 +05:30
43 changed files with 5127 additions and 1424 deletions

View File

@@ -18,9 +18,26 @@ 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
# ===========================================
# 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
#
# 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=
# ===========================================
# DOCKER CONFIGURATION (Optional)
# ===========================================

2304
bun.nix

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,10 @@ 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
# === CORE SETTINGS ===
# These are technically required but have working defaults

View File

@@ -32,6 +32,13 @@ services:
- PORT=4321
- 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
- 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

View File

@@ -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) |

88
docs/NOTIFICATIONS.md Normal file
View File

@@ -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

View File

@@ -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` != '';

View File

@@ -0,0 +1 @@
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,20 @@
"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
}
]
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.13.2",
"version": "3.13.3",
"engines": {
"bun": ">=1.2.9"
},

View File

@@ -143,11 +143,64 @@ function verify0009Migration(db: Database) {
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'");
}
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,
},
};
function lintMigrations(selectedMigrations: Migration[]) {

View File

@@ -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';
@@ -30,6 +32,7 @@ type ConfigState = {
cleanupConfig: DatabaseCleanupConfig;
mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions;
notificationConfig: NotificationConfig;
};
export function ConfigTabs() {
@@ -51,7 +54,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 +89,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 +105,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;
@@ -459,6 +472,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('/api/config', {
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 +536,9 @@ export function ConfigTabs() {
if (autoSaveGiteaTimeoutRef.current) {
clearTimeout(autoSaveGiteaTimeoutRef.current);
}
if (autoSaveNotificationTimeoutRef.current) {
clearTimeout(autoSaveNotificationTimeoutRef.current);
}
};
}, []);
@@ -505,6 +570,8 @@ export function ConfigTabs() {
},
advancedOptions:
response.advancedOptions || config.advancedOptions,
notificationConfig:
(response as any).notificationConfig || config.notificationConfig,
});
}
@@ -634,9 +701,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 +792,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>

View File

@@ -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

View File

@@ -0,0 +1,394 @@
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";
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("/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>
);
}

View File

@@ -4,6 +4,7 @@ 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";
interface RepositoryListProps {
@@ -15,11 +16,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 +34,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 (

View File

@@ -9,6 +9,7 @@ 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 {
@@ -67,11 +68,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,12 +80,7 @@ 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) => {

View File

@@ -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";
@@ -124,10 +125,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 +141,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 = [

View File

@@ -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");
});
});

View File

@@ -6,6 +6,72 @@ import { db, users } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
/**
* 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;
}
export const auth = betterAuth({
// Database configuration
database: drizzleAdapter(db, {
@@ -43,48 +109,11 @@ export const auth = betterAuth({
})(),
basePath: "/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: {

View File

@@ -75,7 +75,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 +122,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(),
@@ -336,6 +362,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 +448,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", {

View File

@@ -575,7 +575,7 @@ describe("Enhanced Gitea Operations", () => {
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
backupBeforeSync: true,
backupStrategy: "always",
blockSyncOnBackupFailure: true,
},
};

View File

@@ -720,7 +720,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 ${repository.name}.`,
status: "synced",
});

45
src/lib/gitea-url.test.ts Normal file
View File

@@ -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();
});
});

28
src/lib/gitea-url.ts Normal file
View File

@@ -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;
}

View File

@@ -10,7 +10,7 @@ import type { Organization, Repository } from "./db/schema";
import { httpPost, httpGet, httpDelete, httpPut, httpPatch } from "./http-client";
import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import { decryptConfigTokens } from "./utils/config-encryption";
import { formatDateShort } from "./utils";
import {
@@ -586,6 +586,7 @@ export const mirrorGithubRepoToGitea = async ({
orgName: repoOwner,
baseName: repository.name,
githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy,
});
@@ -825,6 +826,10 @@ export const mirrorGithubRepoToGitea = async ({
migratePayload.auth_token = decryptedConfig.githubConfig.token;
}
// Track whether the Gitea migrate call succeeded so the catch block
// knows whether to clear mirroredLocation (only safe before migrate succeeds)
let migrateSucceeded = false;
const response = await httpPost(
apiUrl,
migratePayload,
@@ -833,6 +838,8 @@ export const mirrorGithubRepoToGitea = async ({
}
);
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({
config,
octokit,
@@ -1075,14 +1082,21 @@ export const mirrorGithubRepoToGitea = async ({
}`
);
// Mark repos as "failed" in DB
// Mark repos as "failed" in DB. Only clear mirroredLocation if the Gitea
// migrate call itself failed (repo doesn't exist in Gitea). If migrate
// succeeded but metadata mirroring failed, preserve the location since
// the repo physically exists and we need the location for recovery/retry.
const failureUpdate: Record<string, any> = {
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
};
if (!migrateSucceeded) {
failureUpdate.mirroredLocation = "";
}
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.set(failureUpdate)
.where(eq(repositories.id, repository.id!));
// Append log for failure
@@ -1133,29 +1147,103 @@ export async function getOrCreateGiteaOrg({
}
/**
* Generate a unique repository name for starred repos with duplicate names
* Check if a candidate mirroredLocation is already claimed by another repository
* in the local database. This prevents race conditions during concurrent batch
* mirroring where two repos could both claim the same name before either
* finishes creating in Gitea.
*/
async function isMirroredLocationClaimedInDb({
userId,
candidateLocation,
excludeFullName,
}: {
userId: string;
candidateLocation: string;
excludeFullName: string;
}): Promise<boolean> {
try {
const existing = await db
.select({ id: repositories.id })
.from(repositories)
.where(
and(
eq(repositories.userId, userId),
eq(repositories.mirroredLocation, candidateLocation),
ne(repositories.fullName, excludeFullName)
)
)
.limit(1);
return existing.length > 0;
} catch (error) {
console.error(
`Error checking DB for mirroredLocation "${candidateLocation}":`,
error
);
// Fail-closed: assume claimed to be conservative and prevent collisions
return true;
}
}
/**
* Generate a unique repository name for starred repos with duplicate names.
* Checks both the Gitea instance (HTTP) and the local DB (mirroredLocation)
* to reduce collisions during concurrent batch mirroring.
*
* NOTE: This function only checks availability — it does NOT claim the name.
* The actual claim happens later when mirroredLocation is written at the
* status="mirroring" DB update, which is protected by a unique partial index
* on (userId, mirroredLocation) WHERE mirroredLocation != ''.
*/
async function generateUniqueRepoName({
config,
orgName,
baseName,
githubOwner,
fullName,
strategy,
}: {
config: Partial<Config>;
orgName: string;
baseName: string;
githubOwner: string;
fullName: string;
strategy?: string;
}): Promise<string> {
if (!fullName?.includes("/")) {
throw new Error(
`Invalid fullName "${fullName}" for starred repo dedup — expected "owner/repo" format`
);
}
const duplicateStrategy = strategy || "suffix";
const userId = config.userId || "";
// Helper: check both Gitea and local DB for a candidate name
const isNameTaken = async (candidateName: string): Promise<boolean> => {
const existsInGitea = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: candidateName,
});
if (existsInGitea) return true;
// Also check local DB to catch concurrent batch operations
// where another repo claimed this location but hasn't created it in Gitea yet
if (userId) {
const claimedInDb = await isMirroredLocationClaimedInDb({
userId,
candidateLocation: `${orgName}/${candidateName}`,
excludeFullName: fullName,
});
if (claimedInDb) return true;
}
return false;
};
// First check if base name is available
const baseExists = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: baseName,
});
const baseExists = await isNameTaken(baseName);
if (!baseExists) {
return baseName;
@@ -1187,11 +1275,7 @@ async function generateUniqueRepoName({
break;
}
const exists = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: candidateName,
});
const exists = await isNameTaken(candidateName);
if (!exists) {
console.log(`Found unique name for duplicate starred repo: ${candidateName}`);
@@ -1254,6 +1338,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
orgName,
baseName: repository.name,
githubOwner,
fullName: repository.fullName,
strategy: config.githubConfig.starredDuplicateStrategy,
});
@@ -1421,6 +1506,8 @@ export async function mirrorGitHubRepoToGiteaOrg({
migratePayload.auth_token = decryptedConfig.githubConfig.token;
}
let migrateSucceeded = false;
const migrateRes = await httpPost(
apiUrl,
migratePayload,
@@ -1429,6 +1516,8 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
);
migrateSucceeded = true;
await syncRepositoryMetadataToGitea({
config,
octokit,
@@ -1676,14 +1765,23 @@ export async function mirrorGitHubRepoToGiteaOrg({
error instanceof Error ? error.message : String(error)
}`
);
// Mark repos as "failed" in DB
// Mark repos as "failed" in DB. For starred repos, clear mirroredLocation
// to release the name claim for retry. For non-starred repos, preserve it
// since the Gitea repo may partially exist and we need the location for recovery.
const failureUpdate2: Record<string, any> = {
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
};
// Only clear mirroredLocation if the Gitea migrate call itself failed.
// If migrate succeeded but metadata mirroring failed, preserve the
// location since the repo physically exists in Gitea.
if (!migrateSucceeded) {
failureUpdate2.mirroredLocation = "";
}
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.set(failureUpdate2)
.where(eq(repositories.id, repository.id!));
// Append log for failure

View File

@@ -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);

View File

@@ -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");
});
});

View File

@@ -0,0 +1,165 @@
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 } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
/**
* 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) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
/**
* 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
const configResults = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.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);
}
}

View File

@@ -0,0 +1,98 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendAppriseNotification } from "./apprise";
import type { NotificationEvent } from "./ntfy";
import type { AppriseConfig } from "@/types/config";
describe("sendAppriseNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: AppriseConfig = {
url: "http://apprise:8000",
token: "gitea-mirror",
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/gitea-mirror");
});
test("strips trailing slash from URL", async () => {
await sendAppriseNotification(
{ ...baseConfig, url: "http://apprise:8000/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("http://apprise:8000/notify/gitea-mirror");
});
test("sends correct JSON body format", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.body).toBe("This is a test");
expect(body.type).toBe("success");
});
test("maps sync_error to failure type", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendAppriseNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.type).toBe("failure");
});
test("includes tag when configured", async () => {
await sendAppriseNotification(
{ ...baseConfig, tag: "urgent" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.tag).toBe("urgent");
});
test("omits tag when not configured", async () => {
await sendAppriseNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.tag).toBeUndefined();
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("server error", { status: 500 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendAppriseNotification(baseConfig, baseEvent)
).rejects.toThrow("Apprise error: 500");
});
});

View File

@@ -0,0 +1,15 @@
import type { AppriseConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendAppriseNotification(config: AppriseConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/notify/${config.token}`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
const body = JSON.stringify({
title: event.title,
body: event.message,
type: event.type === "sync_error" ? "failure" : "success",
tag: config.tag || undefined,
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Apprise error: ${resp.status} ${await resp.text()}`);
}

View File

@@ -0,0 +1,95 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendNtfyNotification, type NotificationEvent } from "./ntfy";
import type { NtfyConfig } from "@/types/config";
describe("sendNtfyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: NtfyConfig = {
url: "https://ntfy.sh",
topic: "gitea-mirror",
priority: "default",
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendNtfyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/gitea-mirror");
});
test("strips trailing slash from URL", async () => {
await sendNtfyNotification(
{ ...baseConfig, url: "https://ntfy.sh/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://ntfy.sh/gitea-mirror");
});
test("includes Authorization header when token is present", async () => {
await sendNtfyNotification(
{ ...baseConfig, token: "tk_secret" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Authorization"]).toBe("Bearer tk_secret");
});
test("does not include Authorization header when no token", async () => {
await sendNtfyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Authorization"]).toBeUndefined();
});
test("uses high priority for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendNtfyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Priority"]).toBe("high");
expect(opts.headers["Tags"]).toBe("warning");
});
test("uses config priority for non-error events", async () => {
await sendNtfyNotification(
{ ...baseConfig, priority: "low" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["Priority"]).toBe("low");
expect(opts.headers["Tags"]).toBe("white_check_mark");
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("rate limited", { status: 429 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendNtfyNotification(baseConfig, baseEvent)
).rejects.toThrow("Ntfy error: 429");
});
});

21
src/lib/providers/ntfy.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NtfyConfig } from "@/types/config";
export interface NotificationEvent {
title: string;
message: string;
type: "sync_error" | "sync_success" | "new_repo";
}
export async function sendNtfyNotification(config: NtfyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/${config.topic}`;
const headers: Record<string, string> = {
"Title": event.title,
"Priority": event.type === "sync_error" ? "high" : (config.priority || "default"),
"Tags": event.type === "sync_error" ? "warning" : "white_check_mark",
};
if (config.token) {
headers["Authorization"] = `Bearer ${config.token}`;
}
const resp = await fetch(url, { method: "POST", body: event.message, headers });
if (!resp.ok) throw new Error(`Ntfy error: ${resp.status} ${await resp.text()}`);
}

View File

@@ -162,8 +162,8 @@ describe("resolveBackupStrategy", () => {
expect(resolveBackupStrategy(makeConfig({ backupStrategy: "block-on-force-push" }))).toBe("block-on-force-push");
});
test("maps backupBeforeSync: true → 'always' (backward compat)", () => {
expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("always");
test("maps backupBeforeSync: true → 'on-force-push' (backward compat, prevents silent always-backup)", () => {
expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("on-force-push");
});
test("maps backupBeforeSync: false → 'disabled' (backward compat)", () => {

View File

@@ -65,13 +65,17 @@ async function runGit(args: string[], tokenToMask: string): Promise<void> {
}
}
async function enforceRetention(repoBackupDir: string, keepCount: number): Promise<void> {
async function enforceRetention(
repoBackupDir: string,
keepCount: number,
retentionDays: number = 0,
): Promise<void> {
const entries = await readdir(repoBackupDir);
const bundleFiles = entries
.filter((name) => name.endsWith(".bundle"))
.map((name) => path.join(repoBackupDir, name));
if (bundleFiles.length <= keepCount) return;
if (bundleFiles.length === 0) return;
const filesWithMtime = await Promise.all(
bundleFiles.map(async (filePath) => ({
@@ -81,9 +85,33 @@ async function enforceRetention(repoBackupDir: string, keepCount: number): Promi
);
filesWithMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
const toDelete = filesWithMtime.slice(keepCount);
await Promise.all(toDelete.map((entry) => rm(entry.filePath, { force: true })));
const toDelete = new Set<string>();
// Count-based retention: keep only the N most recent
if (filesWithMtime.length > keepCount) {
for (const entry of filesWithMtime.slice(keepCount)) {
toDelete.add(entry.filePath);
}
}
// Time-based retention: delete bundles older than retentionDays
if (retentionDays > 0) {
const cutoffMs = Date.now() - retentionDays * 86_400_000;
for (const entry of filesWithMtime) {
if (entry.mtimeMs < cutoffMs) {
toDelete.add(entry.filePath);
}
}
// Always keep at least 1 bundle even if it's old
if (toDelete.size === filesWithMtime.length && filesWithMtime.length > 0) {
toDelete.delete(filesWithMtime[0].filePath);
}
}
if (toDelete.size > 0) {
await Promise.all([...toDelete].map((fp) => rm(fp, { force: true })));
}
}
export function isPreSyncBackupEnabled(): boolean {
@@ -126,9 +154,12 @@ export function resolveBackupStrategy(config: Partial<Config>): BackupStrategy {
}
// 2. Legacy backupBeforeSync boolean → map to strategy
// Note: backupBeforeSync: true now maps to "on-force-push" (not "always")
// because mappers default backupBeforeSync to true, causing every legacy config
// to silently resolve to "always" and create full git bundles on every sync.
const legacy = config.giteaConfig?.backupBeforeSync;
if (legacy !== undefined) {
return legacy ? "always" : "disabled";
return legacy ? "on-force-push" : "disabled";
}
// 3. Env var (new)
@@ -251,7 +282,13 @@ export async function createPreSyncBundleBackup({
1,
Number.isFinite(config.giteaConfig?.backupRetentionCount)
? Number(config.giteaConfig?.backupRetentionCount)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 20)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_KEEP_COUNT, 5)
);
const retentionDays = Math.max(
0,
Number.isFinite(config.giteaConfig?.backupRetentionDays)
? Number(config.giteaConfig?.backupRetentionDays)
: parsePositiveInt(process.env.PRE_SYNC_BACKUP_RETENTION_DAYS, 30)
);
await mkdir(repoBackupDir, { recursive: true });
@@ -268,7 +305,7 @@ export async function createPreSyncBundleBackup({
await runGit(["clone", "--mirror", authCloneUrl, mirrorClonePath], giteaToken);
await runGit(["-C", mirrorClonePath, "bundle", "create", bundlePath, "--all"], giteaToken);
await enforceRetention(repoBackupDir, retention);
await enforceRetention(repoBackupDir, retention, retentionDays);
return { bundlePath };
} finally {
await rm(tmpDir, { recursive: true, force: true });

View File

@@ -95,7 +95,8 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
pullRequestConcurrency: 5,
backupStrategy: "on-force-push",
backupBeforeSync: true, // Deprecated: kept for backward compat
backupRetentionCount: 20,
backupRetentionCount: 5,
backupRetentionDays: 30,
backupDirectory: "data/repo-backups",
blockSyncOnBackupFailure: true,
},

View File

@@ -101,9 +101,10 @@ export function mapUiToDbConfig(
mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
mirrorLabels: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.labels,
mirrorMilestones: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.milestones,
backupStrategy: giteaConfig.backupStrategy,
backupStrategy: giteaConfig.backupStrategy || "on-force-push",
backupBeforeSync: giteaConfig.backupBeforeSync ?? true,
backupRetentionCount: giteaConfig.backupRetentionCount ?? 20,
backupRetentionCount: giteaConfig.backupRetentionCount ?? 5,
backupRetentionDays: giteaConfig.backupRetentionDays ?? 30,
backupDirectory: giteaConfig.backupDirectory?.trim() || undefined,
blockSyncOnBackupFailure: giteaConfig.blockSyncOnBackupFailure ?? true,
};
@@ -146,9 +147,12 @@ export function mapDbToUiConfig(dbConfig: any): {
personalReposOrg: undefined, // Not stored in current schema
issueConcurrency: dbConfig.giteaConfig?.issueConcurrency ?? 3,
pullRequestConcurrency: dbConfig.giteaConfig?.pullRequestConcurrency ?? 5,
backupStrategy: dbConfig.giteaConfig?.backupStrategy || undefined,
backupStrategy: dbConfig.giteaConfig?.backupStrategy ||
// Respect legacy backupBeforeSync: false → "disabled" mapping on round-trip
(dbConfig.giteaConfig?.backupBeforeSync === false ? "disabled" : "on-force-push"),
backupBeforeSync: dbConfig.giteaConfig?.backupBeforeSync ?? true,
backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 20,
backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 5,
backupRetentionDays: dbConfig.giteaConfig?.backupRetentionDays ?? 30,
backupDirectory: dbConfig.giteaConfig?.backupDirectory || "data/repo-backups",
blockSyncOnBackupFailure: dbConfig.giteaConfig?.blockSyncOnBackupFailure ?? true,
};

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import { POST } from "./index";
describe("POST /api/config notification validation", () => {
test("returns 400 for invalid notificationConfig payload", async () => {
const request = new Request("http://localhost/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
githubConfig: { username: "octo", token: "ghp_x" },
giteaConfig: { url: "https://gitea.example.com", token: "gt_x", username: "octo" },
scheduleConfig: { enabled: true, interval: 3600 },
cleanupConfig: { enabled: false, retentionDays: 604800 },
mirrorOptions: {
mirrorReleases: false,
releaseLimit: 10,
mirrorLFS: false,
mirrorMetadata: false,
metadataComponents: {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
},
},
advancedOptions: {
skipForks: false,
starredCodeOnly: false,
autoMirrorStarred: false,
},
notificationConfig: {
enabled: true,
provider: "invalid-provider",
},
}),
});
const response = await POST({
request,
locals: {
session: { userId: "user-1" },
},
} as any);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.message).toContain("Invalid notificationConfig");
});
});

View File

@@ -14,6 +14,7 @@ import {
import { encrypt, decrypt } from "@/lib/utils/encryption";
import { createDefaultConfig } from "@/lib/utils/config-defaults";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
import { notificationConfigSchema } from "@/lib/db/schema";
export const POST: APIRoute = async ({ request, locals }) => {
try {
@@ -22,7 +23,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
const userId = authResult.userId;
const body = await request.json();
const { githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, advancedOptions } = body;
const {
githubConfig,
giteaConfig,
scheduleConfig,
cleanupConfig,
mirrorOptions,
advancedOptions,
notificationConfig,
} = body;
if (!githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) {
return new Response(
@@ -38,6 +47,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
let validatedNotificationConfig: any = undefined;
if (notificationConfig !== undefined) {
const parsed = notificationConfigSchema.safeParse(notificationConfig);
if (!parsed.success) {
return new Response(
JSON.stringify({
success: false,
message: `Invalid notificationConfig: ${parsed.error.message}`,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
validatedNotificationConfig = parsed.data;
}
// Validate Gitea URL format and protocol
if (giteaConfig.url) {
try {
@@ -115,17 +142,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
const processedCleanupConfig = mapUiCleanupToDb(cleanupConfig);
// Process notification config if provided
let processedNotificationConfig: any = undefined;
if (validatedNotificationConfig) {
processedNotificationConfig = { ...validatedNotificationConfig };
// Encrypt ntfy token if present
if (processedNotificationConfig.ntfy?.token) {
processedNotificationConfig.ntfy = {
...processedNotificationConfig.ntfy,
token: encrypt(processedNotificationConfig.ntfy.token),
};
}
// Encrypt apprise token if present
if (processedNotificationConfig.apprise?.token) {
processedNotificationConfig.apprise = {
...processedNotificationConfig.apprise,
token: encrypt(processedNotificationConfig.apprise.token),
};
}
}
if (existingConfig) {
// Update path
const updateFields: Record<string, any> = {
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
scheduleConfig: processedScheduleConfig,
cleanupConfig: processedCleanupConfig,
updatedAt: new Date(),
};
if (processedNotificationConfig) {
updateFields.notificationConfig = processedNotificationConfig;
}
await db
.update(configs)
.set({
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
scheduleConfig: processedScheduleConfig,
cleanupConfig: processedCleanupConfig,
updatedAt: new Date(),
})
.set(updateFields)
.where(eq(configs.id, existingConfig.id));
return new Response(
@@ -163,7 +214,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Create new config
const configId = uuidv4();
await db.insert(configs).values({
const insertValues: Record<string, any> = {
id: configId,
userId,
name: "Default Configuration",
@@ -176,7 +227,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
cleanupConfig: processedCleanupConfig,
createdAt: new Date(),
updatedAt: new Date(),
});
};
if (processedNotificationConfig) {
insertValues.notificationConfig = processedNotificationConfig;
}
await db.insert(configs).values(insertValues);
return new Response(
JSON.stringify({
@@ -258,13 +313,34 @@ export const GET: APIRoute = async ({ request, locals }) => {
githubConfig,
giteaConfig
};
const uiConfig = mapDbToUiConfig(decryptedConfig);
// Map schedule and cleanup configs to UI format
const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig);
const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig);
// Decrypt notification config tokens
let notificationConfig = dbConfig.notificationConfig;
if (notificationConfig) {
notificationConfig = { ...notificationConfig };
if (notificationConfig.ntfy?.token) {
try {
notificationConfig.ntfy = { ...notificationConfig.ntfy, token: decrypt(notificationConfig.ntfy.token) };
} catch {
// Clear token on decryption failure to prevent double-encryption on next save
notificationConfig.ntfy = { ...notificationConfig.ntfy, token: "" };
}
}
if (notificationConfig.apprise?.token) {
try {
notificationConfig.apprise = { ...notificationConfig.apprise, token: decrypt(notificationConfig.apprise.token) };
} catch {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
}
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
@@ -278,6 +354,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
lastRun: dbConfig.cleanupConfig.lastRun,
nextRun: dbConfig.cleanupConfig.nextRun,
},
notificationConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -288,7 +365,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
const uiConfig = mapDbToUiConfig(dbConfig);
const uiScheduleConfig = mapDbScheduleToUi(dbConfig.scheduleConfig);
const uiCleanupConfig = mapDbCleanupToUi(dbConfig.cleanupConfig);
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
@@ -302,6 +379,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
lastRun: dbConfig.cleanupConfig.lastRun,
nextRun: dbConfig.cleanupConfig.nextRun,
},
notificationConfig: dbConfig.notificationConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },

View File

@@ -0,0 +1,42 @@
import type { APIRoute } from "astro";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
import { testNotification } from "@/lib/notification-service";
import { notificationConfigSchema } from "@/lib/db/schema";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request, locals }) => {
try {
const authResult = await requireAuthenticatedUserId({ request, locals });
if ("response" in authResult) return authResult.response;
const body = await request.json();
const { notificationConfig } = body;
if (!notificationConfig) {
return new Response(
JSON.stringify({ success: false, error: "notificationConfig is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const parsed = notificationConfigSchema.safeParse(notificationConfig);
if (!parsed.success) {
return new Response(
JSON.stringify({ success: false, error: `Invalid config: ${parsed.error.message}` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const result = await testNotification(parsed.data);
return new Response(
JSON.stringify(result),
{
status: result.success ? 200 : 400,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
return createSecureErrorResponse(error, "notification test", 500);
}
};

View File

@@ -95,6 +95,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // Prevent Nginx from buffering SSE stream
},
});
};

View File

@@ -202,13 +202,55 @@ import MainLayout from '../../layouts/main.astro';
<!-- Reverse Proxy Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2>
<p class="text-muted-foreground mb-6">
For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.
</p>
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-red-600 dark:text-red-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<div>
<p class="font-semibold text-red-600 dark:text-red-500 mb-1">Required Environment Variables for Reverse Proxy</p>
<p class="text-sm mb-3">
When running Gitea Mirror behind a reverse proxy, you <strong>must</strong> set these environment variables to your external URL.
Without them, sign-in will fail with "invalid origin" errors and pages may appear blank.
</p>
<div class="bg-muted/30 rounded p-3">
<pre class="text-sm"><code>{`# All three MUST be set 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`}</code></pre>
</div>
<ul class="mt-3 space-y-1 text-sm">
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth base URL for callbacks and redirects</li>
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) URL for auth API calls</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
</ul>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Docker Compose Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- BETTER_AUTH_URL=https://gitea-mirror.example.com
- PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
# ... other settings ...`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Nginx Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`server {
listen 80;
@@ -242,13 +284,16 @@ server {
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Caddy Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror.example.com {
reverse_proxy localhost:4321

View File

@@ -22,6 +22,7 @@ export interface GiteaConfig {
backupStrategy?: BackupStrategy;
backupBeforeSync?: boolean; // Deprecated: kept for backward compat, use backupStrategy
backupRetentionCount?: number;
backupRetentionDays?: number;
backupDirectory?: string;
blockSyncOnBackupFailure?: boolean;
}
@@ -84,6 +85,7 @@ export interface SaveConfigApiRequest {
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
notificationConfig?: NotificationConfig;
mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions;
}
@@ -93,6 +95,29 @@ export interface SaveConfigApiResponse {
message: string;
}
export interface NtfyConfig {
url: string;
topic: string;
token?: string;
priority: "min" | "low" | "default" | "high" | "urgent";
}
export interface AppriseConfig {
url: string;
token: string;
tag?: string;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
}
export interface Config extends ConfigType {}
export interface ConfigApiRequest {
@@ -108,6 +133,7 @@ export interface ConfigApiResponse {
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
notificationConfig?: NotificationConfig;
mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions;
include: string[];