d0a40bde88
* ci: prevent duplicate test runs - tests only on PRs, inline tests for builds * docs: add testing and CI/CD documentation * security: fix CodeQL vulnerabilities (SSRF, XSS, rate limiting) - Add URL validation to prevent SSRF attacks on notification endpoints - Block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x) - Block localhost and internal hostnames - Only allow HTTP/HTTPS protocols - Add HTML escaping for medication names in email templates (XSS) - Add stricter rate limiting for auth routes (5 req/15min for login/register) - Add SSRF protection tests (405 tests total) * security: add rate limiting to remaining auth routes * chore: add CodeQL config to suppress rate-limit false positives Rate limiting IS implemented via @fastify/rate-limit plugin: - Global: 100 req/min (index.ts) - Auth routes: 5-10 req/min via config.rateLimit option CodeQL doesn't recognize Fastify's plugin-based rate limiting pattern. * ci: switch to CodeQL Advanced Setup - Add custom codeql.yml workflow - Configure to use codeql-config.yml - Exclude js/missing-rate-limiting rule (false positive) Rate limiting is implemented via @fastify/rate-limit plugin * ci: add explicit permissions to workflows Fixes CodeQL 'Workflow does not contain permissions' warnings. Sets minimal 'contents: read' at top level. * ci: add manual trigger to CodeQL workflow * ci: add explicit permissions to all workflow jobs * build(deps): bump esbuild, @vitest/coverage-v8 and vitest in /backend Bumps [esbuild](https://github.com/evanw/esbuild) to 0.27.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together. Updates `esbuild` from 0.21.5 to 0.27.2 - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2) Updates `@vitest/coverage-v8` from 2.1.9 to 4.0.16 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/coverage-v8) Updates `vitest` from 2.1.9 to 4.0.16 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.27.2 dependency-type: indirect - dependency-name: "@vitest/coverage-v8" dependency-version: 4.0.16 dependency-type: direct:development - dependency-name: vitest dependency-version: 4.0.16 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> * docs: add GitHub issue templates - Bug report template with deployment type, browser info, logs - Feature request template with affected area, priority - Config with link to discussions and README - Optimize test.yml to skip tests for non-code changes * Initial plan * Remove database schema duplication by creating shared schema-sql.ts module Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Refactor frontend date formatting to eliminate duplication Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * docs: Add branch protection warning and PR workflow to instructions * ci: remove paths filter from test workflow to fix branch protection * fix: add .js extension to schema-sql imports for ESM compatibility (#15) * feat: add setting to skip reminders for taken doses - Add skipRemindersForTakenDoses setting to database schema - Extend settings API to save and load new setting - Update intake reminder scheduler to filter taken doses - Add frontend toggle in settings with i18n (EN/DE) - Only check doses from today (timezone-aware) - Update all test schemas with new field - All 405 tests passing * feat: add repeat reminders for missed doses - Add repeatRemindersEnabled and reminderRepeatIntervalMinutes settings - Refactor intake reminder state from array to object with sendCount tracking - Update scheduler to send repeated reminders at configurable intervals - Only remind for today's doses (timezone-aware filtering) - Add frontend toggle and interval input (5-480 minutes) in settings - Maintain backward compatibility for old state file format - Update all test schemas and assertions - All 406 tests passing * feat: add nagging reminders with max limit and ENV defaults - Add maxNaggingReminders setting to limit repeat reminders (1-20) - Add ENV defaults for all user settings (DEFAULT_*) - Add ALTER TABLE migrations for backward compatibility - Add smtpConfigured/shoutrrrConfigured to health endpoint - Fix Push toggle to allow enabling without existing URL - Disable skip/repeat toggles when no notifications enabled - Add Pocket ID button to registration page - Add getTodaysIntakes() for repeat reminder logic - Update translations (en/de) for new settings - Add comprehensive tests for new features --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
477 lines
19 KiB
TypeScript
477 lines
19 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
||
import nodemailer from "nodemailer";
|
||
import { db } from "../db/client.js";
|
||
import { userSettings } from "../db/schema.js";
|
||
import { eq } from "drizzle-orm";
|
||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||
import { env } from "../plugins/env.js";
|
||
import type { AuthUser } from "../types/fastify.js";
|
||
import type { Language } from "../i18n/translations.js";
|
||
|
||
// Exported type for use in schedulers
|
||
export type UserSettings = {
|
||
userId: number;
|
||
emailEnabled: boolean;
|
||
notificationEmail: string | null;
|
||
emailStockReminders: boolean;
|
||
emailIntakeReminders: boolean;
|
||
shoutrrrEnabled: boolean;
|
||
shoutrrrUrl: string | null;
|
||
shoutrrrStockReminders: boolean;
|
||
shoutrrrIntakeReminders: boolean;
|
||
reminderDaysBefore: number;
|
||
repeatDailyReminders: boolean;
|
||
skipRemindersForTakenDoses: boolean;
|
||
repeatRemindersEnabled: boolean;
|
||
reminderRepeatIntervalMinutes: number;
|
||
maxNaggingReminders: number;
|
||
lowStockDays: number;
|
||
normalStockDays: number;
|
||
highStockDays: number;
|
||
language: Language;
|
||
stockCalculationMode: "automatic" | "manual";
|
||
lastAutoEmailSent: string | null;
|
||
lastNotificationType: string | null;
|
||
lastNotificationChannel: string | null;
|
||
};
|
||
|
||
type SettingsBody = {
|
||
emailEnabled: boolean;
|
||
notificationEmail: string;
|
||
reminderDaysBefore: number;
|
||
repeatDailyReminders: boolean;
|
||
lowStockDays: number;
|
||
normalStockDays: number;
|
||
highStockDays: number;
|
||
shoutrrrEnabled: boolean;
|
||
shoutrrrUrl: string;
|
||
emailStockReminders: boolean;
|
||
emailIntakeReminders: boolean;
|
||
shoutrrrStockReminders: boolean;
|
||
shoutrrrIntakeReminders: boolean;
|
||
skipRemindersForTakenDoses: boolean;
|
||
repeatRemindersEnabled: boolean;
|
||
reminderRepeatIntervalMinutes: number;
|
||
maxNaggingReminders: number;
|
||
language: string;
|
||
stockCalculationMode: "automatic" | "manual";
|
||
};
|
||
|
||
type TestEmailBody = {
|
||
email: string;
|
||
};
|
||
|
||
type TestShoutrrrBody = {
|
||
url: string;
|
||
};
|
||
|
||
// Helper to parse boolean env vars
|
||
function envBool(key: string, defaultVal: boolean): boolean {
|
||
const val = process.env[key];
|
||
if (val === undefined) return defaultVal;
|
||
return val === "true" || val === "1";
|
||
}
|
||
|
||
// Helper to parse integer env vars
|
||
function envInt(key: string, defaultVal: number): number {
|
||
const val = process.env[key];
|
||
if (val === undefined) return defaultVal;
|
||
const parsed = parseInt(val, 10);
|
||
return isNaN(parsed) ? defaultVal : parsed;
|
||
}
|
||
|
||
// Default settings for new users - read from ENV with fallbacks
|
||
function getDefaultSettings() {
|
||
return {
|
||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
||
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
||
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
||
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
||
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
||
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
||
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
||
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
||
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
|
||
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
|
||
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
|
||
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
|
||
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
|
||
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
|
||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||
lastAutoEmailSent: null,
|
||
lastNotificationType: null,
|
||
lastNotificationChannel: null,
|
||
};
|
||
}
|
||
|
||
// Helper to get or create user settings
|
||
async function getOrCreateUserSettings(userId: number) {
|
||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||
|
||
if (!settings) {
|
||
// Create default settings for user (using ENV defaults)
|
||
[settings] = await db.insert(userSettings).values({
|
||
userId,
|
||
...getDefaultSettings(),
|
||
}).returning();
|
||
}
|
||
|
||
return settings;
|
||
}
|
||
|
||
// Export for use in reminder scheduler
|
||
export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||
const settings = await getOrCreateUserSettings(userId);
|
||
return {
|
||
userId: settings.userId,
|
||
emailEnabled: settings.emailEnabled,
|
||
notificationEmail: settings.notificationEmail,
|
||
emailStockReminders: settings.emailStockReminders,
|
||
emailIntakeReminders: settings.emailIntakeReminders,
|
||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||
shoutrrrUrl: settings.shoutrrrUrl,
|
||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||
reminderDaysBefore: settings.reminderDaysBefore,
|
||
repeatDailyReminders: settings.repeatDailyReminders,
|
||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||
lowStockDays: settings.lowStockDays,
|
||
normalStockDays: settings.normalStockDays,
|
||
highStockDays: settings.highStockDays,
|
||
language: settings.language as Language,
|
||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||
lastNotificationType: settings.lastNotificationType,
|
||
lastNotificationChannel: settings.lastNotificationChannel,
|
||
};
|
||
}
|
||
|
||
// Get all users with settings for scheduler
|
||
export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||
const allSettings = await db.select().from(userSettings);
|
||
return allSettings.map(settings => ({
|
||
userId: settings.userId,
|
||
emailEnabled: settings.emailEnabled,
|
||
notificationEmail: settings.notificationEmail,
|
||
emailStockReminders: settings.emailStockReminders,
|
||
emailIntakeReminders: settings.emailIntakeReminders,
|
||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||
shoutrrrUrl: settings.shoutrrrUrl,
|
||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||
reminderDaysBefore: settings.reminderDaysBefore,
|
||
repeatDailyReminders: settings.repeatDailyReminders,
|
||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||
lowStockDays: settings.lowStockDays,
|
||
normalStockDays: settings.normalStockDays,
|
||
highStockDays: settings.highStockDays,
|
||
language: settings.language as Language,
|
||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||
lastNotificationType: settings.lastNotificationType,
|
||
lastNotificationChannel: settings.lastNotificationChannel,
|
||
}));
|
||
}
|
||
|
||
export async function settingsRoutes(app: FastifyInstance) {
|
||
// All settings routes require auth
|
||
app.addHook("preHandler", requireAuth);
|
||
|
||
// Helper to get user ID from request
|
||
// Returns anonymous user ID when auth is disabled
|
||
async function getUserId(request: any, reply: any): Promise<number> {
|
||
// If auth is disabled, use the anonymous user
|
||
if (!env.AUTH_ENABLED) {
|
||
return getAnonymousUserId();
|
||
}
|
||
|
||
const authUser = request.user as unknown as AuthUser | null;
|
||
if (!authUser) {
|
||
reply.status(401).send({ error: "Not authenticated" });
|
||
throw new Error("AUTH_REQUIRED");
|
||
}
|
||
return authUser.id;
|
||
}
|
||
|
||
// Get settings for current user
|
||
app.get("/settings", async (request, reply) => {
|
||
const userId = await getUserId(request, reply);
|
||
|
||
const settings = await getOrCreateUserSettings(userId);
|
||
|
||
return reply.send({
|
||
// User notification settings (from DB)
|
||
emailEnabled: settings.emailEnabled,
|
||
notificationEmail: settings.notificationEmail ?? "",
|
||
reminderDaysBefore: settings.reminderDaysBefore,
|
||
repeatDailyReminders: settings.repeatDailyReminders,
|
||
lowStockDays: settings.lowStockDays,
|
||
normalStockDays: settings.normalStockDays,
|
||
highStockDays: settings.highStockDays,
|
||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||
shoutrrrUrl: settings.shoutrrrUrl ?? "",
|
||
emailStockReminders: settings.emailStockReminders,
|
||
emailIntakeReminders: settings.emailIntakeReminders,
|
||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||
language: settings.language,
|
||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||
// SMTP settings (from .env - shared/server-configured)
|
||
smtpHost: process.env.SMTP_HOST ?? "",
|
||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
|
||
smtpUser: process.env.SMTP_USER ?? "",
|
||
smtpFrom: process.env.SMTP_FROM ?? "",
|
||
smtpSecure: process.env.SMTP_SECURE === "true",
|
||
hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS),
|
||
// Reminder state for this user
|
||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||
lastNotificationType: settings.lastNotificationType,
|
||
lastNotificationChannel: settings.lastNotificationChannel,
|
||
// Server settings (from .env, read-only)
|
||
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
||
});
|
||
});
|
||
|
||
// Update settings for current user
|
||
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
|
||
const userId = await getUserId(request, reply);
|
||
|
||
const body = request.body;
|
||
|
||
// Check if any stock reminders are configured
|
||
const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail;
|
||
const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl;
|
||
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
|
||
|
||
// Disable repeatDailyReminders if no stock reminders are configured
|
||
const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false;
|
||
|
||
// Update or insert user settings
|
||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||
|
||
const settingsData = {
|
||
emailEnabled: body.emailEnabled,
|
||
notificationEmail: body.notificationEmail || null,
|
||
emailStockReminders: body.emailStockReminders ?? true,
|
||
emailIntakeReminders: body.emailIntakeReminders ?? true,
|
||
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
|
||
shoutrrrUrl: body.shoutrrrUrl || null,
|
||
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
|
||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||
reminderDaysBefore: body.reminderDaysBefore,
|
||
repeatDailyReminders,
|
||
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
|
||
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
|
||
maxNaggingReminders: body.maxNaggingReminders ?? 5,
|
||
lowStockDays: body.lowStockDays ?? 30,
|
||
normalStockDays: body.normalStockDays ?? 90,
|
||
highStockDays: body.highStockDays ?? 180,
|
||
language: body.language ?? "en",
|
||
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
if (existingSettings.length > 0) {
|
||
await db.update(userSettings)
|
||
.set(settingsData)
|
||
.where(eq(userSettings.userId, userId));
|
||
} else {
|
||
await db.insert(userSettings).values({
|
||
userId: userId,
|
||
...settingsData,
|
||
});
|
||
}
|
||
|
||
return reply.send({ success: true });
|
||
});
|
||
|
||
// Test email - use SMTP settings from process.env
|
||
app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => {
|
||
const { email } = request.body;
|
||
|
||
const smtpHost = process.env.SMTP_HOST;
|
||
const smtpUser = process.env.SMTP_USER;
|
||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||
|
||
if (!smtpHost || !smtpUser) {
|
||
return reply.status(400).send({ error: "SMTP not configured" });
|
||
}
|
||
|
||
try {
|
||
const transporter = nodemailer.createTransport({
|
||
host: smtpHost,
|
||
port: smtpPort,
|
||
secure: smtpSecure,
|
||
auth: {
|
||
user: smtpUser,
|
||
pass: smtpPass ?? "",
|
||
},
|
||
});
|
||
|
||
await transporter.sendMail({
|
||
from: smtpFrom,
|
||
to: email,
|
||
subject: "MedAssist-ng - Test Email",
|
||
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
||
html: `
|
||
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h2 style="color: #2563eb;">MedAssist-ng - Test Email</h2>
|
||
<p>This is a test email from MedAssist-ng.</p>
|
||
<p style="color: #10b981; font-weight: 600;">✓ If you received this, your email configuration is working correctly!</p>
|
||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;" />
|
||
<p style="color: #6b7280; font-size: 14px;">Sent from MedAssist-ng Medication Planner</p>
|
||
</div>
|
||
`,
|
||
});
|
||
|
||
return reply.send({ success: true, message: "Test email sent successfully" });
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||
}
|
||
});
|
||
|
||
// Test Shoutrrr/ntfy notification
|
||
app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => {
|
||
const { url } = request.body;
|
||
|
||
if (!url) {
|
||
return reply.status(400).send({ error: "Notification URL is required" });
|
||
}
|
||
|
||
try {
|
||
const result = await sendShoutrrrNotification(url, "MedAssist-ng Test", "This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!");
|
||
|
||
if (result.success) {
|
||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||
} else {
|
||
return reply.status(500).send({ error: result.error });
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
|
||
}
|
||
});
|
||
}
|
||
|
||
// Validate URL to prevent SSRF attacks
|
||
function isAllowedNotificationUrl(urlStr: string): { allowed: boolean; error?: string } {
|
||
try {
|
||
// Convert ntfy:// to https:// for parsing
|
||
const normalizedUrl = urlStr.startsWith("ntfy://")
|
||
? urlStr.replace("ntfy://", "https://")
|
||
: urlStr;
|
||
|
||
const parsed = new URL(normalizedUrl);
|
||
|
||
// Only allow http and https protocols
|
||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||
return { allowed: false, error: "Only HTTP/HTTPS protocols are allowed" };
|
||
}
|
||
|
||
// Block private/internal IP addresses
|
||
const hostname = parsed.hostname.toLowerCase();
|
||
|
||
// Block localhost
|
||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||
return { allowed: false, error: "Localhost URLs are not allowed" };
|
||
}
|
||
|
||
// Block private IP ranges (basic check)
|
||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||
if (ipMatch) {
|
||
const [, a, b] = ipMatch.map(Number);
|
||
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
|
||
if (a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
|
||
(a === 192 && b === 168) || (a === 169 && b === 254)) {
|
||
return { allowed: false, error: "Private IP addresses are not allowed" };
|
||
}
|
||
}
|
||
|
||
// Block common internal hostnames
|
||
if (hostname.endsWith('.local') || hostname.endsWith('.internal') ||
|
||
hostname.endsWith('.lan') || hostname === 'metadata.google.internal') {
|
||
return { allowed: false, error: "Internal hostnames are not allowed" };
|
||
}
|
||
|
||
return { allowed: true };
|
||
} catch {
|
||
return { allowed: false, error: "Invalid URL format" };
|
||
}
|
||
}
|
||
|
||
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
||
export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> {
|
||
try {
|
||
// Validate URL to prevent SSRF
|
||
const validation = isAllowedNotificationUrl(urlStr);
|
||
if (!validation.allowed) {
|
||
return { success: false, error: validation.error };
|
||
}
|
||
|
||
let targetUrl: string;
|
||
let method = "POST";
|
||
let headers: Record<string, string> = {};
|
||
let body: string | undefined;
|
||
|
||
// Remove emojis from title for header compatibility
|
||
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]|⚠|️/gu, "").trim();
|
||
|
||
if (urlStr.startsWith("ntfy://")) {
|
||
const parsed = new URL(urlStr.replace("ntfy://", "https://"));
|
||
targetUrl = `https://${parsed.host}${parsed.pathname}`;
|
||
headers = { "Title": cleanTitle, "Tags": "pill" };
|
||
body = message;
|
||
|
||
if (parsed.username && parsed.password) {
|
||
headers["Authorization"] = "Basic " + Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
||
}
|
||
} else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) {
|
||
targetUrl = urlStr;
|
||
headers = { "Title": cleanTitle, "Tags": "pill" };
|
||
body = message;
|
||
} else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
|
||
targetUrl = urlStr;
|
||
headers = { "Content-Type": "application/json" };
|
||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||
} else {
|
||
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
|
||
}
|
||
|
||
const response = await fetch(targetUrl, {
|
||
method,
|
||
headers,
|
||
body,
|
||
});
|
||
|
||
if (response.ok) {
|
||
return { success: true };
|
||
} else {
|
||
const errorText = await response.text();
|
||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||
return { success: false, error: errorMessage };
|
||
}
|
||
}
|
||
|