mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-04-12 05:58:53 +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:
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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user