Compare commits

...

3 Commits

Author SHA1 Message Date
云与原 204922570d feat: add Gotify as a notification provider (#337)
Adds Gotify alongside ntfy and Apprise: new provider module posting to {url}/message with X-Gotify-Key auth, configurable default priority (errors always send at priority 8), token encrypted at rest like the other providers, settings UI section, and provider + service tests. No database migration needed.
2026-07-16 22:20:15 +05:30
Arunavo Ray 97d98b82c6 chore: bump version to 3.21.0 2026-07-16 21:52:01 +05:30
ARUNAVO RAY 8b81ffa975 chore(deps): security fixes and dependency updates across app and www (#348)
better-auth 1.6.23 (fixes GHSA high stored XSS via javascript: redirect_uri), esbuild >=0.28.1 override for www (GHSA low), Astro 7 / @astrojs/node 11 / @astrojs/react 6 / @astrojs/mdx 7 / lucide-react 1.x (inline GitHub icon replaces removed brand icon) and all in-range updates on both the app and the website. The remaining @better-auth/oauth-provider medium advisory is patched upstream only in 1.7-rc and will be picked up when 1.7 stable lands.
2026-07-16 21:51:41 +05:30
15 changed files with 1961 additions and 994 deletions
+463 -255
View File
File diff suppressed because it is too large Load Diff
+41 -41
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.20.4",
"version": "3.21.0",
"engines": {
"bun": ">=1.2.9"
},
@@ -55,40 +55,40 @@
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "5.0.0",
"@astrojs/node": "10.1.4",
"@astrojs/react": "^5.0.7",
"@better-auth/oauth-provider": "1.6.11",
"@better-auth/sso": "1.6.11",
"@astrojs/mdx": "^7.0.3",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@better-auth/oauth-provider": "1.6.23",
"@better-auth/sso": "1.6.23",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.1",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-collapsible": "^1.1.13",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-hover-card": "^1.1.16",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-radio-group": "^1.4.0",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.3.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-auth": "1.6.11",
"better-auth": "1.6.23",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -96,37 +96,37 @@
"cmdk": "^1.1.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fuse.js": "^7.4.2",
"fuse.js": "^7.5.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.11",
"lucide-react": "^1.24.0",
"nanoid": "^6.0.0",
"next-themes": "^0.4.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.6.0",
"react-icons": "^5.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"uuid": "^13.0.2",
"typescript": "^7.0.2",
"uuid": "^14.0.1",
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.9.3",
"@types/node": "^26.1.1",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.3",
"drizzle-kit": "^0.31.10",
"jsdom": "^28.1.0",
"tsx": "^4.22.4",
"vitest": "^4.1.8"
"jsdom": "^29.1.1",
"tsx": "^4.23.1",
"vitest": "^4.1.10"
},
"packageManager": "bun@1.3.10"
}
+88 -1
View File
@@ -93,7 +93,7 @@ export function NotificationSettings({
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onValueChange={(value: "ntfy" | "apprise" | "gotify") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
@@ -103,6 +103,7 @@ export function NotificationSettings({
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
</SelectContent>
</Select>
</div>
@@ -307,6 +308,92 @@ export function NotificationSettings({
</div>
)}
{/* Gotify configuration */}
{notificationConfig.provider === "gotify" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Gotify Settings</h3>
<div className="space-y-2">
<Label htmlFor="gotify-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-url"
type="url"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Gotify server
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-token" className="text-sm">
Application token <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-token"
type="password"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Create an application in Gotify and paste its token here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-priority" className="text-sm">
Default priority (0-10)
</Label>
<Input
id="gotify-priority"
type="number"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: notificationConfig.gotify?.token || "",
priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
<p className="text-xs text-muted-foreground">
Error notifications always use priority 8 regardless of this setting
</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>
+8 -1
View File
@@ -138,14 +138,21 @@ export const appriseConfigSchema = z.object({
tag: z.string().optional(),
});
export const gotifyConfigSchema = z.object({
url: z.string().default(""),
token: z.string().default(""),
priority: z.number().int().min(0).max(10).default(5),
});
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
provider: z.enum(["ntfy", "apprise", "gotify"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
gotify: gotifyConfigSchema.optional(),
});
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
+49
View File
@@ -71,6 +71,32 @@ describe("sendNotification", () => {
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("sends gotify notification when provider is gotify", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "my-app-token",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
@@ -140,6 +166,29 @@ describe("sendNotification", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when gotify token is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
+18
View File
@@ -2,6 +2,7 @@ import type { NotificationConfig } from "@/types/config";
import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { sendGotifyNotification } from "./providers/gotify";
import { db, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
@@ -52,6 +53,12 @@ export async function sendNotification(
return;
}
await sendAppriseNotification(config.apprise, event);
} else if (config.provider === "gotify") {
if (!config.gotify?.url || !config.gotify?.token) {
console.warn("[NotificationService] Gotify URL or token is not configured, skipping notification");
return;
}
await sendGotifyNotification(config.gotify, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
@@ -83,6 +90,11 @@ export async function testNotification(
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
} else if (notificationConfig.provider === "gotify") {
if (!notificationConfig.gotify?.url || !notificationConfig.gotify?.token) {
return { success: false, error: "Gotify URL and token are required" };
}
await sendGotifyNotification(notificationConfig.gotify, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
@@ -166,6 +178,12 @@ export async function triggerJobNotification({
token: decrypt(decryptedConfig.apprise.token),
};
}
if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
decryptedConfig.gotify = {
...decryptedConfig.gotify,
token: decrypt(decryptedConfig.gotify.token),
};
}
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
+106
View File
@@ -0,0 +1,106 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendGotifyNotification } from "./gotify";
import type { NotificationEvent } from "./ntfy";
import type { GotifyConfig } from "@/types/config";
describe("sendGotifyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: GotifyConfig = {
url: "https://gotify.example.com",
token: "AbCdEf123456",
priority: 5,
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("strips trailing slash from URL", async () => {
await sendGotifyNotification(
{ ...baseConfig, url: "https://gotify.example.com/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("sends token via X-Gotify-Key header", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["X-Gotify-Key"]).toBe("AbCdEf123456");
});
test("sends title and message in JSON body", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.message).toBe("This is a test");
});
test("uses priority 8 for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendGotifyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(8);
});
test("uses config priority for non-error events", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: 2 },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(2);
});
test("defaults to priority 5 when not configured", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: undefined as any },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(5);
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("unauthorized", { status: 401 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendGotifyNotification(baseConfig, baseEvent)
).rejects.toThrow("Gotify error: 401");
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { GotifyConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/message`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Gotify-Key": config.token,
};
const body = JSON.stringify({
title: event.title,
message: event.message,
priority: event.type === "sync_error" ? 8 : (config.priority ?? 5),
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Gotify error: ${resp.status} ${await resp.text()}`);
}
+14
View File
@@ -172,6 +172,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
token: encrypt(processedNotificationConfig.apprise.token),
};
}
// Encrypt gotify token if present
if (processedNotificationConfig.gotify?.token) {
processedNotificationConfig.gotify = {
...processedNotificationConfig.gotify,
token: encrypt(processedNotificationConfig.gotify.token),
};
}
}
if (existingConfig) {
@@ -354,6 +361,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
if (notificationConfig.gotify?.token) {
try {
notificationConfig.gotify = { ...notificationConfig.gotify, token: decrypt(notificationConfig.gotify.token) };
} catch {
notificationConfig.gotify = { ...notificationConfig.gotify, token: "" };
}
}
}
return new Response(JSON.stringify({
+8 -1
View File
@@ -119,14 +119,21 @@ export interface AppriseConfig {
tag?: string;
}
export interface GotifyConfig {
url: string;
token: string;
priority: number;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
provider: "ntfy" | "apprise" | "gotify";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
gotify?: GotifyConfig;
}
export interface Config extends ConfigType {}
+14 -9
View File
@@ -9,27 +9,32 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.6",
"@astrojs/react": "^5.0.7",
"@radix-ui/react-slot": "^1.2.5",
"@astrojs/mdx": "^7.0.3",
"@astrojs/react": "^6.0.1",
"@radix-ui/react-slot": "^1.3.0",
"@splinetool/react-spline": "^4.1.0",
"@splinetool/runtime": "^1.12.97",
"@tailwindcss/vite": "^4.3.1",
"@splinetool/runtime": "^1.12.98",
"@tailwindcss/vite": "^4.3.3",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.32.1"
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1"
}
}
}
+1113 -682
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,11 +1,12 @@
---
import { Github, Book, MessageSquare, Bug } from 'lucide-react';
import { Book, MessageSquare, Bug } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
const links = [
{
title: "Source Code",
href: "https://github.com/RayLabsHQ/gitea-mirror",
icon: Github
icon: GithubIcon
},
{
title: "Documentation",
+3 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Github, Star } from 'lucide-react';
import { Star } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
import { Button } from './ui/button';
export function GitHubButton() {
@@ -44,7 +45,7 @@ export function GitHubButton() {
asChild
>
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer" className="flex items-center">
<Github className="w-4 h-4 mr-2" />
<GithubIcon className="w-4 h-4 mr-2" />
<span>Star on GitHub</span>
{stars !== null && (
<>
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
// GitHub brand mark. Replaces lucide-react's `Github` icon, which was removed
// along with all brand icons in lucide-react v1.
export function GithubIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}