v3: added attachments to resources, allow users to paste images into TextEditor, strengthened WS reliability

This commit is contained in:
khannurien
2026-03-27 08:53:32 +00:00
parent ca70bdc14b
commit f0f6472db6
19 changed files with 450 additions and 57 deletions

View File

@@ -0,0 +1,42 @@
import { db } from "../model/db.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
export function createAttachment(id: string, mime: string): void {
db.prepare(
"INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)",
).run(id, mime, new Date().toISOString());
}
export function getAttachment(attachmentId: string): { mime: string } {
const row = db.prepare(
"SELECT mime FROM attachments WHERE id = ?",
).get(attachmentId) as { mime: string } | undefined;
if (!row) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Attachment not found",
);
}
return { mime: row.mime };
}
// UUID pattern used to extract attachment IDs from markdown/text bodies.
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;
/**
* Parse `text` for attachment URLs and bind any unowned attachments to
* `resourceId`. Only updates rows where resource_id IS NULL so a user cannot
* claim attachments that already belong to another resource.
*/
export function linkAttachments(text: string, resourceId: string): void {
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
for (const id of ids) {
db.prepare(
"UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL",
).run(resourceId, id);
}
}

View File

@@ -6,6 +6,7 @@ import {
import { type SQLOutputValue } from "node:sqlite";
import { commentRowToApi, db, isCommentRow } from "../model/db.ts";
import { notifyMentions } from "./notification-service.ts";
import { linkAttachments } from "./attachment-service.ts";
const SELECT_COLS =
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted,
@@ -66,6 +67,7 @@ export function createComment(
dumpId,
) as { title: string } | undefined;
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
linkAttachments(body, id);
return comment;
}
@@ -113,6 +115,7 @@ export function updateComment(
dumpRow?.title ?? "",
row.dump_id,
);
linkAttachments(body, commentId);
return {
comment: fetchComment(commentId),
dumpId: row.dump_id,

View File

@@ -18,6 +18,7 @@ import {
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
import { DUMPS_DIR } from "../lib/upload.ts";
import { linkAttachments } from "./attachment-service.ts";
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
@@ -109,6 +110,7 @@ export async function createUrlDump(
}
if (request.comment) {
notifyMentions(userId, request.comment, "dump", dumpId, title);
linkAttachments(request.comment, dumpId);
}
return dump;
}
@@ -185,7 +187,10 @@ export async function createFileDump(
broadcastNewDump(dump);
notifyUserFollowersNewDump(userId, dumpId, file.name);
}
if (comment) notifyMentions(userId, comment, "dump", dumpId, file.name);
if (comment) {
notifyMentions(userId, comment, "dump", dumpId, file.name);
linkAttachments(comment, dumpId);
}
return dump;
}
@@ -282,6 +287,7 @@ export async function updateDump(
dumpId,
updatedDump.title,
);
linkAttachments(updatedDump.comment, dumpId);
}
return updatedDump;
}
@@ -344,6 +350,7 @@ export async function updateDump(
dumpId,
updatedDump.title,
);
linkAttachments(updatedDump.comment, dumpId);
}
return updatedDump;
}
@@ -401,7 +408,10 @@ export async function replaceFileDump(
throw err;
}
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
if (comment) {
notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
linkAttachments(comment, dumpId);
}
return {
...dump,
title: file.name,

View File

@@ -26,6 +26,7 @@ import {
notifyPlaylistFollowersNewDump,
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
import { linkAttachments } from "./attachment-service.ts";
const DUMP_SELECT_COLS =
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
@@ -74,6 +75,7 @@ export function createPlaylist(
};
if (req.description) {
notifyMentions(userId, req.description, "playlist", id, req.title);
linkAttachments(req.description, id);
}
broadcastPlaylistCreated(playlist);
return playlist;
@@ -184,6 +186,7 @@ export function updatePlaylist(
playlist.id,
newTitle,
);
linkAttachments(newDescription, playlist.id);
}
broadcastPlaylistUpdated(updated);
return updated;

View File

@@ -9,6 +9,7 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
import { disconnectUser } from "./ws-service.ts";
import { hashPassword } from "../lib/jwt.ts";
import { linkAttachments } from "./attachment-service.ts";
const USER_SELECT =
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by,
@@ -147,6 +148,10 @@ export async function updateUser(
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
if (updatedUser.description) {
linkAttachments(updatedUser.description, userId);
}
return updatedUser;
}

View File

@@ -3,6 +3,7 @@ import { db } from "../model/db.ts";
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
export function castVote(dumpId: string, userId: string): number {
let voteCount: number;
try {
db.exec("BEGIN;");
db.prepare(
@@ -15,10 +16,11 @@ export function castVote(dumpId: string, userId: string): number {
`SELECT vote_count FROM dumps WHERE id = ?;`,
).get(dumpId) as { vote_count: number } | undefined;
db.exec("COMMIT;");
notifyDumpOwnerUpvote(userId, dumpId);
return row?.vote_count ?? 0;
voteCount = row?.vote_count ?? 0;
} catch (err) {
db.exec("ROLLBACK;");
try {
db.exec("ROLLBACK;");
} catch { /* ignore if no active transaction */ }
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
@@ -28,6 +30,11 @@ export function castVote(dumpId: string, userId: string): number {
}
throw err;
}
// Notification is best-effort — must not prevent vote_ack from being sent
try {
notifyDumpOwnerUpvote(userId, dumpId);
} catch { /* ignore */ }
return voteCount;
}
export function removeVote(dumpId: string, userId: string): number {