mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-30 01:28:08 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d2462e5a0 | ||
|
|
0000a03ad6 | ||
|
|
d697cb2bc9 | ||
|
|
ddd071f7e5 | ||
|
|
4629ab4335 | ||
|
|
0f303c4b79 | ||
|
|
7c7c259d0a |
19
.env.example
19
.env.example
@@ -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)
|
||||
# ===========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
88
docs/NOTIFICATIONS.md
Normal 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
|
||||
9
drizzle/0010_mirrored_location_index.sql
Normal file
9
drizzle/0010_mirrored_location_index.sql
Normal 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` != '';
|
||||
1
drizzle/0011_notification_config.sql
Normal file
1
drizzle/0011_notification_config.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `configs` ADD `notification_config` text DEFAULT '{"enabled":false,"provider":"ntfy","notifyOnSyncError":true,"notifyOnSyncSuccess":false,"notifyOnNewRepo":false}' NOT NULL;
|
||||
2030
drizzle/meta/0011_snapshot.json
Normal file
2030
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.13.2",
|
||||
"version": "3.13.3",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
394
src/components/config/NotificationSettings.tsx
Normal file
394
src/components/config/NotificationSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
119
src/lib/auth-origins.test.ts
Normal file
119
src/lib/auth-origins.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
113
src/lib/auth.ts
113
src/lib/auth.ts
@@ -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: {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -575,7 +575,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: false,
|
||||
backupBeforeSync: true,
|
||||
backupStrategy: "always",
|
||||
blockSyncOnBackupFailure: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
45
src/lib/gitea-url.test.ts
Normal 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
28
src/lib/gitea-url.ts
Normal 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;
|
||||
}
|
||||
146
src/lib/gitea.ts
146
src/lib/gitea.ts
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
221
src/lib/notification-service.test.ts
Normal file
221
src/lib/notification-service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
165
src/lib/notification-service.ts
Normal file
165
src/lib/notification-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
98
src/lib/providers/apprise.test.ts
Normal file
98
src/lib/providers/apprise.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
15
src/lib/providers/apprise.ts
Normal file
15
src/lib/providers/apprise.ts
Normal 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()}`);
|
||||
}
|
||||
95
src/lib/providers/ntfy.test.ts
Normal file
95
src/lib/providers/ntfy.test.ts
Normal 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
21
src/lib/providers/ntfy.ts
Normal 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()}`);
|
||||
}
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
51
src/pages/api/config/index.test.ts
Normal file
51
src/pages/api/config/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
42
src/pages/api/notifications/test.ts
Normal file
42
src/pages/api/notifications/test.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user