mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-25 07:07:41 +03:00
feat: add notification system with Ntfy.sh and Apprise support (#238)
* feat: add notification system with Ntfy.sh and Apprise providers (#231) Add push notification support for mirror job events with two providers: - Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support - Apprise API: aggregator gateway supporting 100+ notification services Includes database migration (0010), settings UI tab, test endpoint, auto-save integration, token encryption, and comprehensive tests. Notifications are fire-and-forget and never block the mirror flow. * fix: address review findings for notification system - Fix silent catch in GET handler that returned ciphertext to UI, causing double-encryption on next save. Now clears token to "" on decryption failure instead. - Add Zod schema validation to test notification endpoint, following project API route pattern guidelines. - Mark notifyOnNewRepo toggle as "coming soon" with disabled state, since the backend doesn't yet emit new_repo events. The schema and type support is in place for when it's implemented. * fix notification gating and config validation * trim sync notification details
This commit is contained in:
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
|
||||
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
@@ -78,6 +78,13 @@
|
||||
"when": 1774054800000,
|
||||
"tag": "0010_mirrored_location_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1774058400000,
|
||||
"tag": "0011_notification_config",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +149,6 @@ function seedPre0010Database(db: any) {
|
||||
}
|
||||
|
||||
function verify0010Migration(db: any) {
|
||||
// Verify the unique partial index exists by checking that two repos
|
||||
// with the same non-empty mirroredLocation would conflict
|
||||
const indexes = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'"
|
||||
).all();
|
||||
@@ -166,6 +164,30 @@ function verify0010Migration(db: any) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -175,6 +197,10 @@ const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
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() {
|
||||
@@ -86,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);
|
||||
@@ -95,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;
|
||||
@@ -460,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 () => {
|
||||
@@ -475,6 +536,9 @@ export function ConfigTabs() {
|
||||
if (autoSaveGiteaTimeoutRef.current) {
|
||||
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||
}
|
||||
if (autoSaveNotificationTimeoutRef.current) {
|
||||
clearTimeout(autoSaveNotificationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -506,6 +570,8 @@ export function ConfigTabs() {
|
||||
},
|
||||
advancedOptions:
|
||||
response.advancedOptions || config.advancedOptions,
|
||||
notificationConfig:
|
||||
(response as any).notificationConfig || config.notificationConfig,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -635,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>
|
||||
|
||||
@@ -725,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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -122,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(),
|
||||
@@ -337,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())`),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -85,6 +85,7 @@ export interface SaveConfigApiRequest {
|
||||
giteaConfig: GiteaConfig;
|
||||
scheduleConfig: ScheduleConfig;
|
||||
cleanupConfig: DatabaseCleanupConfig;
|
||||
notificationConfig?: NotificationConfig;
|
||||
mirrorOptions?: MirrorOptions;
|
||||
advancedOptions?: AdvancedOptions;
|
||||
}
|
||||
@@ -94,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 {
|
||||
@@ -109,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