diff --git a/vault/lib/server-api.ts b/vault/lib/server-api.ts new file mode 100644 index 0000000..939dba4 --- /dev/null +++ b/vault/lib/server-api.ts @@ -0,0 +1,137 @@ +import "server-only"; + +import { NextResponse } from "next/server"; +import { calculateExpiresAt, createSnippetId, isExpired, publicSnippet, readDb, StoredSnippet, writeDb } from "@/lib/server-db"; +import { hashPassword } from "@/lib/server-password"; +import { Expiration, LANGUAGES, PrivacyType } from "@/lib/types"; + +export function jsonError(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +interface ValidSnippetPayload { + title: string; + code: string; + language: string; + privacy: PrivacyType; + expiration: Expiration; + tags: string[]; + password?: string; +} + +export async function readJsonBody(request: Request): Promise> { + try { + const body = (await request.json()) as unknown; + return body && typeof body === "object" && !Array.isArray(body) ? (body as Record) : {}; + } catch { + return {}; + } +} + +function isPrivacy(value: unknown): value is PrivacyType { + return value === "secret" || value === "password"; +} + +function isExpiration(value: unknown): value is Expiration { + return value === "never" || value === "1h" || value === "1d" || value === "1w"; +} + +export function validateSnippetPayload(body: Record, options: { requirePassword: boolean }): { value: ValidSnippetPayload } | { error: string } { + const title = typeof body.title === "string" ? body.title.trim() : ""; + const code = typeof body.code === "string" ? body.code : ""; + const language = typeof body.language === "string" ? body.language : ""; + const privacy = body.privacy; + const expiration = body.expiration; + const tags = Array.isArray(body.tags) + ? body.tags.filter((tag): tag is string => typeof tag === "string").map((tag) => tag.trim()).filter(Boolean) + : []; + const password = typeof body.password === "string" ? body.password : ""; + + if (!title) return { error: "Title is required" as const }; + if (!code.trim()) return { error: "Content is required" as const }; + if (!LANGUAGES.includes(language as (typeof LANGUAGES)[number])) return { error: "Unsupported language" as const }; + if (!isPrivacy(privacy)) return { error: "Invalid privacy setting" as const }; + if (!isExpiration(expiration)) return { error: "Invalid expiration setting" as const }; + if (options.requirePassword && privacy === "password" && !password.trim()) return { error: "Password is required" as const }; + + return { + value: { + title, + code, + language, + privacy, + expiration, + tags: [...new Set(tags)].slice(0, 12), + password: password.trim() || undefined, + }, + }; +} + +export async function purgeExpiredSnippets(): Promise { + const db = await readDb(); + if (!db.snippets.some(isExpired)) return; + + await writeDb((current) => { + current.snippets = current.snippets.filter((snippet) => !isExpired(snippet)); + }); +} + +export async function createStoredSnippet(ownerId: string, body: Record): Promise { + const parsed = validateSnippetPayload(body, { requirePassword: true }); + if (!("value" in parsed)) return parsed; + if (parsed.value.privacy === "password" && !parsed.value.password) { + return { error: "Password is required" }; + } + + const now = new Date(); + const passwordHash = + parsed.value.privacy === "password" && parsed.value.password + ? hashPassword(parsed.value.password) + : undefined; + + return { + id: createSnippetId(), + ownerId, + title: parsed.value.title, + code: parsed.value.code, + language: parsed.value.language, + privacy: parsed.value.privacy, + passwordHash, + expiration: parsed.value.expiration, + expiresAt: calculateExpiresAt(parsed.value.expiration, now), + tags: parsed.value.tags, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; +} + +export async function applySnippetUpdate(existing: StoredSnippet, body: Record): Promise { + const parsed = validateSnippetPayload(body, { requirePassword: false }); + if (!("value" in parsed)) return parsed; + + const nextPasswordHash = + parsed.value.privacy === "password" + ? parsed.value.password + ? hashPassword(parsed.value.password) + : existing.passwordHash + : undefined; + + if (parsed.value.privacy === "password" && !nextPasswordHash) { + return { error: "Password is required" }; + } + + return { + ...existing, + title: parsed.value.title, + code: parsed.value.code, + language: parsed.value.language, + privacy: parsed.value.privacy, + passwordHash: nextPasswordHash, + expiration: parsed.value.expiration, + expiresAt: calculateExpiresAt(parsed.value.expiration), + tags: parsed.value.tags, + updatedAt: new Date().toISOString(), + }; +} + +export { publicSnippet };