134 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
}
|