Files
gerbeur/api/services/attachment-service.ts
khannurien c3594e4c27 v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:16:52 +00:00

134 lines
4.7 KiB
TypeScript

/**
* 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";
import { attachmentIdsIn } from "../../shared/attachment.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;
}
}
/**
* 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 = attachmentIdsIn(text);
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);
});
}