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:
ARUNAVO RAY
2026-03-18 18:36:51 +05:30
committed by GitHub
parent 0000a03ad6
commit 5d2462e5a0
20 changed files with 3497 additions and 20 deletions

View File

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

View File

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

View File

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