/** * Ties each image pasted into a Markdown body to the dump, comment, playlist or * message that links it, so the image is served only to those who may see that, * and purged at startup once it is gone. `routes/attachments.ts` stores and * serves the files; the services that save a body call {@link linkAttachments}. * * @module */ import { db, transaction } from "../db/connection.ts"; import { APIException } from "../lib/api-exception.ts"; /** * Records attachment `id` once `POST /api/attachments` has written its file. * Until a saved body links it, the startup purge deletes it after * `ORPHANED_ATTACHMENTS_RETENTION_HOURS`. */ export function createAttachment(id: string, mime: string): void { db.prepare( "INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)", ).run(id, mime, new Date().toISOString()); } /** * Stored in `attachments.resource_type` by {@link bindAttachments}, it names * the kind of resource whose saved text links the attachment. */ export type AttachmentResourceType = | "dump" | "comment" | "playlist" | "chat" | "user"; /** * Returned to `GET /api/attachments/:attachmentId` by * {@link getAttachmentAccess}. `ownerId` is set when only that user may see * the image, because the resource linking it is private. */ export interface AttachmentAccess { mime: string; ownerId?: string; } /** * Returns attachment `attachmentId`'s type and who may see it. An attachment * nothing links yet is public, so the editor can show it before the text is * saved; its id is unguessable. One linked by a private dump or playlist, or * by a comment on a private dump, is its owner's. * * @throws {APIException} 404 when there is no such attachment, or when the * resource linking it has been deleted. */ export function getAttachmentAccess(attachmentId: string): AttachmentAccess { const row = db.prepare( "SELECT mime, resource_type, resource_id FROM attachments WHERE id = ?", ).get(attachmentId) as | { mime: string; resource_type: string | null; resource_id: string | null } | undefined; if (!row) throw new APIException(404, "Attachment not found"); if (!row.resource_id) return { mime: row.mime }; const owner = resourceOwner(row.resource_type, row.resource_id); if (owner === undefined) { throw new APIException(404, "Attachment not found"); } return owner ? { mime: row.mime, ownerId: owner } : { mime: row.mime }; } /** * Returns the owner of a private resource, null for a public one, and * undefined for one that no longer exists. */ function resourceOwner( type: string | null, id: string, ): string | null | undefined { switch (type) { case "dump": { const dump = db.prepare( "SELECT user_id, is_private FROM dumps WHERE id = ?", ).get(id) as { user_id: string; is_private: number } | undefined; return dump && (dump.is_private ? dump.user_id : null); } case "comment": { const comment = db.prepare( `SELECT d.user_id, d.is_private FROM comments c JOIN dumps d ON d.id = c.dump_id WHERE c.id = ? AND c.deleted = 0`, ).get(id) as { user_id: string; is_private: number } | undefined; return comment && (comment.is_private ? comment.user_id : null); } case "playlist": { const playlist = db.prepare( "SELECT user_id, is_public FROM playlists WHERE id = ?", ).get(id) as { user_id: string; is_public: number } | undefined; return playlist && (playlist.is_public ? null : playlist.user_id); } case "chat": return db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(id) ? null : undefined; case "user": return db.prepare("SELECT 1 FROM users WHERE id = ?").get(id) ? null : undefined; default: return undefined; } } const ATTACHMENT_RE = /\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; /** * Binds the attachments whose URLs appear in `text` to the resource that saved * it, so the startup purge keeps them as long as that resource exists, and * they are seen by whoever may see it. It is called whenever a dump, comment, * chat message, playlist or profile description is saved. An attachment * already bound stays with its resource, so pasting another's URL claims * nothing, and removing the link from a body never unbinds it. */ export function linkAttachments( text: string, resourceType: AttachmentResourceType, resourceId: string, ): void { const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]); if (ids.length === 0) return; const update = db.prepare( "UPDATE attachments SET resource_type = ?, resource_id = ? WHERE id = ? AND resource_id IS NULL", ); transaction(() => { for (const id of ids) update.run(resourceType, resourceId, id); }); }