import "server-only"; import { and, eq, isNull } from "drizzle-orm"; import { createHash } from "node:crypto"; import { getDb } from "@/db"; import { extraSectionRows, guides, guidePosterBleeding, guideSections, media, } from "@/db/schema"; import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage"; import { inspectImage } from "@/lib/media/inspect"; import { hasValidImageSignature, MAX_MEDIA_BYTES, mediaUploadSchema, safeObjectFileName, type MediaUploadInput, } from "@/lib/media/validation"; import { writeAuditLog, type AuditActor } from "@/lib/audit-log"; export async function createPendingMedia( input: MediaUploadInput, authorId: string, ): Promise<{ id: string; objectKey: string; uploadUrl: string; expiresIn: number }> { const id = crypto.randomUUID(); const objectKey = `media/${id}/${safeObjectFileName(input.fileName)}`; const expiresIn = 5 * 60; await getDb().insert(media).values({ id, objectKey, fileName: input.fileName, mimeType: input.mimeType, byteSize: input.byteSize, status: "pending", createdById: authorId, }); const uploadUrl = (await getMediaStorage()).presign(objectKey, { method: "PUT", expiresIn, type: input.mimeType, acl: "private", }); return { id, objectKey, uploadUrl, expiresIn }; } export async function uploadPublicMedia( file: File, actor: AuditActor, ): Promise<{ id: string; url: string; fileName: string; width: number; height: number }> { const parsed = mediaUploadSchema.parse({ fileName: file.name, mimeType: file.type, byteSize: file.size, }); const bytes = new Uint8Array(await file.arrayBuffer()); const { width, height } = await inspectImage(bytes, parsed.mimeType); const id = crypto.randomUUID(); const objectKey = `uploads/${id}/${safeObjectFileName(parsed.fileName)}`; const storage = await getMediaStorage(); await storage.write(objectKey, bytes, { type: parsed.mimeType, acl: "public-read" }); try { await getDb().transaction(async (tx) => { await tx.insert(media).values({ id, objectKey, fileName: parsed.fileName, mimeType: parsed.mimeType, byteSize: parsed.byteSize, width, height, status: "ready", currentReferenceCount: 0, createdById: actor.id, }); await writeAuditLog(tx, actor, { action: "media.uploaded", targetType: "media", targetId: id, metadata: { fileName: parsed.fileName, mimeType: parsed.mimeType, byteSize: parsed.byteSize, }, }); }); } catch (cause) { await storage.delete(objectKey).catch(() => undefined); throw cause; } return { id, url: publicMediaUrl(objectKey), fileName: parsed.fileName, width, height, }; } export async function completePendingMedia(mediaId: string, actor: AuditActor) { const [record] = await getDb() .select() .from(media) .where(and(eq(media.id, mediaId), eq(media.status, "pending"))) .limit(1); if (!record) return { status: "not-found" as const }; const storage = await getMediaStorage(); const file = storage.file(record.objectKey); try { const stat = await file.stat(); const prefix = await file.slice(0, 16).bytes(); const valid = stat.size > 0 && stat.size <= MAX_MEDIA_BYTES && stat.size === record.byteSize && hasValidImageSignature(prefix, record.mimeType as Parameters[1]); if (!valid) { await file.delete().catch(() => undefined); await getDb().update(media).set({ status: "failed" }).where(eq(media.id, record.id)); return { status: "invalid" as const }; } const bytes = await file.slice(0, MAX_MEDIA_BYTES + 1).bytes(); let dimensions; try { if (bytes.byteLength !== record.byteSize) throw new Error("size mismatch"); dimensions = await inspectImage(bytes, record.mimeType as Parameters[1]); } catch { await file.delete().catch(() => undefined); await getDb().update(media).set({ status: "failed" }).where(eq(media.id, record.id)); return { status: "invalid" as const }; } // A still-valid presigned PUT must not be able to overwrite verified bytes. const objectKey = `media/${record.id}/verified/${crypto.randomUUID()}/${safeObjectFileName(record.fileName)}`; await storage.write(objectKey, bytes, { type: record.mimeType, acl: "private" }); let updated: typeof record | undefined; try { updated = await getDb().transaction(async (tx) => { const [ready] = await tx .update(media) .set({ status: "ready", byteSize: bytes.byteLength, checksum: createHash("sha256").update(bytes).digest("hex"), objectKey, ...dimensions, }) .where(and(eq(media.id, record.id), eq(media.status, "pending"))) .returning(); if (ready) { await writeAuditLog(tx, actor, { action: "media.uploaded", targetType: "media", targetId: ready.id, metadata: { fileName: ready.fileName, mimeType: ready.mimeType, byteSize: ready.byteSize, }, }); } return ready; }); } catch (cause) { await storage.delete(objectKey).catch(() => undefined); throw cause; } if (!updated) await storage.delete(objectKey).catch(() => undefined); else await file.delete().catch(() => undefined); return updated ? { status: "ready" as const, media: updated } : { status: "not-found" as const }; } catch { return { status: "missing" as const }; } } export async function discardUnreferencedMedia( mediaId: string, actor: AuditActor, ): Promise { // Delete atomically under the FK/reference-count constraints before storage. const record = await getDb().transaction(async (tx) => { const [deleted] = await tx.delete(media) .where(and(eq(media.id, mediaId), eq(media.currentReferenceCount, 0))).returning(); if (deleted) { await writeAuditLog(tx, actor, { action: "media.deleted", targetType: "media", targetId: deleted.id, metadata: { fileName: deleted.fileName, mimeType: deleted.mimeType, byteSize: deleted.byteSize, }, }); } return deleted; }); if (!record) return false; await (await getMediaStorage()).delete(record.objectKey).catch(() => undefined); return true; } export async function getReadyMedia(mediaId: string) { const [record] = await getDb() .select() .from(media) .where(and(eq(media.id, mediaId), eq(media.status, "ready"))) .limit(1); return record ?? null; } export async function getMediaVisibility(mediaId: string): Promise<{ public: boolean; retained: boolean; }> { const db = getDb(); const [covers, posters, bleeding, extras, [record]] = await Promise.all([ db.select({ id: guides.id }).from(guides).where(and( eq(guides.coverMediaId, mediaId), eq(guides.isPublic, true), isNull(guides.trashedAt), )).limit(1), db.select({ id: guides.id }).from(guides).where(and( eq(guides.posterArtMediaId, mediaId), eq(guides.isPublic, true), isNull(guides.trashedAt), )).limit(1), db .select({ id: guides.id }) .from(guidePosterBleeding) .innerJoin(guides, eq(guides.id, guidePosterBleeding.guideId)) .where(and( eq(guidePosterBleeding.mediaId, mediaId), eq(guides.isPublic, true), isNull(guides.trashedAt), )) .limit(1), db .select({ id: guides.id }) .from(extraSectionRows) .innerJoin(guideSections, eq(guideSections.id, extraSectionRows.sectionId)) .innerJoin(guides, eq(guides.id, guideSections.guideId)) .where(and( eq(extraSectionRows.mediaId, mediaId), eq(guideSections.enabled, true), eq(guides.isPublic, true), isNull(guides.trashedAt), )) .limit(1), db.select({ currentReferenceCount: media.currentReferenceCount, }).from(media).where(eq(media.id, mediaId)).limit(1), ]); return { public: Boolean(covers.length || posters.length || bleeding.length || extras.length), retained: Boolean(record && record.currentReferenceCount > 0), }; }