mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -0,0 +1,300 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
notificationChannel: {
|
||||
findUnique: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `encrypted:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telegram", () => ({
|
||||
setTelegramWebhook: vi.fn().mockResolvedValue(true),
|
||||
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/senders/email-config", () => ({
|
||||
isEmailConfigured: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT as putWebhook } from "../webhook/route";
|
||||
import { PUT as putNtfy } from "../ntfy/route";
|
||||
import { PUT as putEmail } from "../email/route";
|
||||
import { PUT as putTelegram } from "../telegram/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { setTelegramWebhook } from "@/lib/telegram";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const routes = {
|
||||
webhook: putWebhook,
|
||||
ntfy: putNtfy,
|
||||
email: putEmail,
|
||||
telegram: putTelegram,
|
||||
};
|
||||
|
||||
type PutRoute = (request: Request) => Promise<Response>;
|
||||
|
||||
function put(channel: keyof typeof routes, body: unknown) {
|
||||
return (routes[channel] as PutRoute)(
|
||||
new Request(`http://localhost/api/settings/${channel}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("notification channel enable-only routes", () => {
|
||||
it.each(Object.keys(routes) as Array<keyof typeof routes>)(
|
||||
"%s rejects unauthenticated toggle writes",
|
||||
async (channel) => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const response = await put(channel, { enabled: false });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("updates only the owned webhook enabled field", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config:
|
||||
'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}',
|
||||
} as never);
|
||||
|
||||
const response = await put("webhook", { enabled: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: { saved: true },
|
||||
error: null,
|
||||
});
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "WEBHOOK" },
|
||||
data: { enabled: true },
|
||||
});
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates only the owned ntfy enabled field", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config:
|
||||
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
|
||||
} as never);
|
||||
|
||||
const response = await put("ntfy", { enabled: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: { saved: true },
|
||||
error: null,
|
||||
});
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "NTFY" },
|
||||
data: { enabled: true },
|
||||
});
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates only the owned email enabled field", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config: 'encrypted:{"recipient":"saved@example.com"}',
|
||||
} as never);
|
||||
|
||||
const response = await put("email", { enabled: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: { saved: true },
|
||||
error: null,
|
||||
});
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "EMAIL" },
|
||||
data: { enabled: true },
|
||||
});
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates only Telegram enabled fields and leaves stored credentials untouched", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
telegramBotToken: "encrypted:saved-token",
|
||||
telegramChatId: "saved-chat",
|
||||
telegramEnabled: true,
|
||||
} as never);
|
||||
|
||||
const response = await put("telegram", { enabled: false });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
data: { updated: true },
|
||||
error: null,
|
||||
});
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "TELEGRAM" },
|
||||
data: { enabled: false },
|
||||
});
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers Telegram on enable without rewriting stored credentials", async () => {
|
||||
vi.stubEnv("APP_URL", "https://health.example");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
telegramBotToken: "encrypted:saved-token",
|
||||
telegramChatId: "saved-chat",
|
||||
telegramEnabled: false,
|
||||
} as never);
|
||||
|
||||
const response = await put("telegram", { enabled: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(setTelegramWebhook).toHaveBeenCalledWith(
|
||||
"saved-token",
|
||||
"https://health.example/api/telegram/webhook",
|
||||
undefined,
|
||||
);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { telegramEnabled: true },
|
||||
});
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "TELEGRAM" },
|
||||
data: { enabled: true },
|
||||
});
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"webhook",
|
||||
{
|
||||
url: "https://saved.example/hook",
|
||||
headerName: "X-Test",
|
||||
headerValue: "secret",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"ntfy",
|
||||
{
|
||||
serverUrl: "https://ntfy.sh",
|
||||
topic: "saved-topic",
|
||||
authToken: "secret",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
["email", { recipient: "saved@example.com", enabled: false }],
|
||||
] as const)(
|
||||
"keeps the existing full %s save contract",
|
||||
async (channel, body) => {
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
|
||||
|
||||
const response = await put(channel, body);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the existing full Telegram save contract", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
telegramBotToken: null,
|
||||
telegramChatId: null,
|
||||
telegramEnabled: false,
|
||||
} as never);
|
||||
|
||||
const response = await put("telegram", {
|
||||
botToken: "new-token",
|
||||
chatId: "new-chat",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
telegramEnabled: false,
|
||||
telegramBotToken: "encrypted:new-token",
|
||||
telegramChatId: "new-chat",
|
||||
},
|
||||
});
|
||||
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["webhook", "ntfy", "email"] as const)(
|
||||
"rejects enabling an unconfigured %s channel without creating configuration",
|
||||
async (channel) => {
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
|
||||
|
||||
const response = await put(channel, { enabled: true });
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { emailSettingsSchema } from "@/lib/validations/notifications";
|
||||
import {
|
||||
emailSettingsSchema,
|
||||
notificationChannelEnabledSchema,
|
||||
} from "@/lib/validations/notifications";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -53,6 +56,51 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
|
||||
if (enabled) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "EMAIL" } },
|
||||
});
|
||||
|
||||
let hasRecipient = false;
|
||||
if (existing) {
|
||||
try {
|
||||
const config = JSON.parse(decrypt(existing.config)) as {
|
||||
recipient?: string;
|
||||
};
|
||||
hasRecipient = !!config.recipient;
|
||||
} catch {
|
||||
hasRecipient = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRecipient) {
|
||||
return apiError(
|
||||
"A recipient address is required when email is enabled",
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.notificationChannel.updateMany({
|
||||
where: { userId: user.id, type: "EMAIL" },
|
||||
data: { enabled },
|
||||
});
|
||||
if (enabled && result.count === 0) {
|
||||
return apiError(
|
||||
"A recipient address is required when email is enabled",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "settings.email.update" }, meta: { enabled } });
|
||||
return apiSuccess({ saved: true });
|
||||
}
|
||||
|
||||
const parsed = emailSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { ntfySettingsSchema } from "@/lib/validations/notifications";
|
||||
import {
|
||||
notificationChannelEnabledSchema,
|
||||
ntfySettingsSchema,
|
||||
} from "@/lib/validations/notifications";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -52,6 +55,52 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
|
||||
if (enabled) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "NTFY" } },
|
||||
});
|
||||
|
||||
let isConfigured = false;
|
||||
if (existing) {
|
||||
try {
|
||||
const config = JSON.parse(decrypt(existing.config)) as {
|
||||
serverUrl?: string;
|
||||
topic?: string;
|
||||
};
|
||||
isConfigured = !!config.serverUrl && !!config.topic;
|
||||
} catch {
|
||||
isConfigured = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConfigured) {
|
||||
return apiError(
|
||||
"Server URL and topic are required when ntfy is enabled",
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.notificationChannel.updateMany({
|
||||
where: { userId: user.id, type: "NTFY" },
|
||||
data: { enabled },
|
||||
});
|
||||
if (enabled && result.count === 0) {
|
||||
return apiError(
|
||||
"Server URL and topic are required when ntfy is enabled",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "settings.ntfy.update" }, meta: { enabled } });
|
||||
return apiSuccess({ saved: true });
|
||||
}
|
||||
|
||||
const parsed = ntfySettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram";
|
||||
import { telegramSettingsSchema } from "@/lib/validations/telegram";
|
||||
import { notificationChannelEnabledSchema } from "@/lib/validations/notifications";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -33,6 +34,64 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function registerTelegramWebhook(botToken: string) {
|
||||
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (!appUrl) {
|
||||
return apiError(
|
||||
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
let appBaseUrl: URL;
|
||||
try {
|
||||
appBaseUrl = new URL(appUrl);
|
||||
} catch {
|
||||
return apiError("Server configuration error: APP_URL is invalid.", 500);
|
||||
}
|
||||
|
||||
if (appBaseUrl.protocol !== "https:") {
|
||||
return apiError(
|
||||
"Telegram webhook requires a public HTTPS URL (no http/localhost).",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const localHosts: Record<string, true> = {
|
||||
localhost: true,
|
||||
"127.0.0.1": true,
|
||||
"::1": true,
|
||||
};
|
||||
if (localHosts[appBaseUrl.hostname.toLowerCase()]) {
|
||||
return apiError(
|
||||
"Telegram webhook requires a publicly reachable domain (localhost is not allowed).",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
appBaseUrl.port &&
|
||||
!["80", "88", "443", "8443"].includes(appBaseUrl.port)
|
||||
) {
|
||||
return apiError(
|
||||
"Telegram webhook only allows ports 80, 88, 443, or 8443.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const webhookUrl = new URL("/api/telegram/webhook", appBaseUrl).toString();
|
||||
const webhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
const ok = await setTelegramWebhook(botToken, webhookUrl, webhookSecret);
|
||||
if (!ok) {
|
||||
return apiError(
|
||||
"Failed to set Telegram webhook. Check bot token and reachability.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Telegram notification settings.
|
||||
*/
|
||||
@@ -54,6 +113,45 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
if (enabled && (!current.telegramBotToken || !current.telegramChatId)) {
|
||||
return apiError(
|
||||
"Bot token and chat ID are required when Telegram is enabled",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const currentTokenPlain = current.telegramBotToken
|
||||
? decrypt(current.telegramBotToken)
|
||||
: null;
|
||||
if (enabled && currentTokenPlain) {
|
||||
const webhookError = await registerTelegramWebhook(currentTokenPlain);
|
||||
if (webhookError) return webhookError;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { telegramEnabled: enabled },
|
||||
});
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: { userId: user.id, type: "TELEGRAM" },
|
||||
data: { enabled },
|
||||
});
|
||||
|
||||
if (!enabled && currentTokenPlain) {
|
||||
await deleteTelegramWebhook(currentTokenPlain).catch(() => {});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.telegram.update" },
|
||||
meta: { enabled },
|
||||
});
|
||||
return apiSuccess({ updated: true });
|
||||
}
|
||||
|
||||
const result = z.safeParse(telegramSettingsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Invalid input", 422);
|
||||
@@ -81,59 +179,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const tokenForWebhook = trimmedToken || currentTokenPlain;
|
||||
|
||||
if (enabled && tokenForWebhook) {
|
||||
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (!appUrl) {
|
||||
return apiError(
|
||||
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
let appBaseUrl: URL;
|
||||
try {
|
||||
appBaseUrl = new URL(appUrl);
|
||||
} catch {
|
||||
return apiError("Server configuration error: APP_URL is invalid.", 500);
|
||||
}
|
||||
|
||||
if (appBaseUrl.protocol !== "https:") {
|
||||
return apiError(
|
||||
"Telegram webhook requires a public HTTPS URL (no http/localhost).",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
if (localHosts.has(appBaseUrl.hostname.toLowerCase())) {
|
||||
return apiError(
|
||||
"Telegram webhook requires a publicly reachable domain (localhost is not allowed).",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
appBaseUrl.port &&
|
||||
!["80", "88", "443", "8443"].includes(appBaseUrl.port)
|
||||
) {
|
||||
return apiError(
|
||||
"Telegram webhook only allows ports 80, 88, 443, or 8443.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const webhookUrl = new URL("/api/telegram/webhook", appBaseUrl).toString();
|
||||
const webhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
const ok = await setTelegramWebhook(
|
||||
tokenForWebhook,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
);
|
||||
if (!ok) {
|
||||
return apiError(
|
||||
"Failed to set Telegram webhook. Check bot token and reachability.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const webhookError = await registerTelegramWebhook(tokenForWebhook);
|
||||
if (webhookError) return webhookError;
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { telegramEnabled: enabled };
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { webhookSettingsSchema } from "@/lib/validations/notifications";
|
||||
import {
|
||||
notificationChannelEnabledSchema,
|
||||
webhookSettingsSchema,
|
||||
} from "@/lib/validations/notifications";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -56,6 +59,54 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
|
||||
if (enabled) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
|
||||
});
|
||||
|
||||
let hasUrl = false;
|
||||
if (existing) {
|
||||
try {
|
||||
const config = JSON.parse(decrypt(existing.config)) as {
|
||||
url?: string;
|
||||
};
|
||||
hasUrl = !!config.url;
|
||||
} catch {
|
||||
hasUrl = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUrl) {
|
||||
return apiError(
|
||||
"Webhook URL is required when the webhook is enabled",
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.notificationChannel.updateMany({
|
||||
where: { userId: user.id, type: "WEBHOOK" },
|
||||
data: { enabled },
|
||||
});
|
||||
if (enabled && result.count === 0) {
|
||||
return apiError(
|
||||
"Webhook URL is required when the webhook is enabled",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.webhook.update" },
|
||||
meta: { enabled },
|
||||
});
|
||||
return apiSuccess({ saved: true });
|
||||
}
|
||||
|
||||
const parsed = webhookSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
|
||||
|
||||
Reference in new issue
Block a user