Files
gerbeur/api/services/attachment-service.ts
khannurien 07ac4bc629
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:19:33 +00:00

135 lines
4.6 KiB
TypeScript

/**
* Records the images pasted into Markdown bodies, and binds each one to the
* resource whose body links it. `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);
});
}