Task 60 — Postgres backups: - deploy/backups/: backup.sh (pg_dump -Fc nightly), retain.sh (14/8/6 tiers), restore.sh, entrypoint.sh, crontab - famapp-backup Alpine service + backups volume added to deploy/compose.yaml - Restore procedure in deploy/backups/README.md Task 61 — Rate limiting on share links: - src/lib/rate-limit.ts: Edge-compatible sliding-window counter (50/min, LRU eviction) with consume(), isRateLimited(), recordFailure() exports - middleware.ts: enforces 429 with Retry-After: 60 for /s/[token] (IP + token prefix) - /s/[token]/page.tsx: tracks only failed resolveShareToken calls via recordFailure() Task 62 — Structured logging: - pino + pino-pretty installed; serverExternalPackages added to next.config.ts - src/lib/logger.ts: JSON in production, pretty in dev, level from LOG_LEVEL env - middleware.ts: structured JSON request log (method, path, status, ms, authenticated) - _core/push.ts, notify.ts, reminders.ts: console.error/log → logger.error/info Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
56 lines
1.6 KiB
TypeScript
56 lines
1.6 KiB
TypeScript
import webPush from "web-push";
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import logger from "@/lib/logger";
|
|
import { pushSubscriptions } from "./schema";
|
|
|
|
function ensureVapidConfigured() {
|
|
const subject = process.env["VAPID_SUBJECT"];
|
|
const publicKey = process.env["VAPID_PUBLIC_KEY"];
|
|
const privateKey = process.env["VAPID_PRIVATE_KEY"];
|
|
if (!subject || !publicKey || !privateKey) {
|
|
throw new Error("VAPID_SUBJECT, VAPID_PUBLIC_KEY, and VAPID_PRIVATE_KEY must be set");
|
|
}
|
|
webPush.setVapidDetails(subject, publicKey, privateKey);
|
|
}
|
|
|
|
export async function sendPush(
|
|
userId: string,
|
|
payload: { title: string; body: string; url?: string },
|
|
) {
|
|
ensureVapidConfigured();
|
|
|
|
const subs = await db
|
|
.select()
|
|
.from(pushSubscriptions)
|
|
.where(eq(pushSubscriptions.userId, userId));
|
|
|
|
if (subs.length === 0) return;
|
|
|
|
const staleIds: string[] = [];
|
|
|
|
await Promise.allSettled(
|
|
subs.map(async (sub) => {
|
|
try {
|
|
await webPush.sendNotification(
|
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
|
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
|
|
);
|
|
} catch (err) {
|
|
const status = (err as { statusCode?: number }).statusCode;
|
|
if (status === 404 || status === 410) {
|
|
staleIds.push(sub.id);
|
|
} else {
|
|
logger.error({ err }, "push delivery failed");
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (staleIds.length > 0) {
|
|
await db
|
|
.delete(pushSubscriptions)
|
|
.where(and(eq(pushSubscriptions.userId, userId), inArray(pushSubscriptions.id, staleIds)));
|
|
}
|
|
}
|