v3: added attachments to resources, allow users to paste images into TextEditor, strengthened WS reliability
This commit is contained in:
@@ -9,6 +9,11 @@ export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
|
||||
|
||||
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
|
||||
"http://localhost:3000";
|
||||
export const ALLOWED_ORIGINS: string[] = rawOrigins
|
||||
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
|
||||
: [];
|
||||
export const ALLOWED_ORIGINS: string[] = Array.from(new Set([
|
||||
BASE_URL,
|
||||
...(
|
||||
rawOrigins
|
||||
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
|
||||
: []
|
||||
),
|
||||
]));
|
||||
|
||||
@@ -5,6 +5,7 @@ export const UPLOADS_DIR = "api/uploads";
|
||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
||||
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
||||
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||
|
||||
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Application } from "@oak/oak";
|
||||
import { oakCors } from "@tajpouria/cors";
|
||||
|
||||
import attachmentsRouter from "./routes/attachments.ts";
|
||||
import dumpsRouter from "./routes/dumps.ts";
|
||||
import filesRouter from "./routes/files.ts";
|
||||
import usersRouter from "./routes/users.ts";
|
||||
@@ -42,6 +43,10 @@ app.use(
|
||||
filesRouter.routes(),
|
||||
filesRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
attachmentsRouter.routes(),
|
||||
attachmentsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
usersRouter.routes(),
|
||||
usersRouter.allowedMethods(),
|
||||
|
||||
@@ -18,6 +18,20 @@ db.prepare(
|
||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
||||
).run();
|
||||
|
||||
// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour
|
||||
const orphanedAttachments = db.prepare(
|
||||
`SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`,
|
||||
).all() as { id: string }[];
|
||||
if (orphanedAttachments.length > 0) {
|
||||
const { ATTACHMENTS_DIR } = await import("../lib/upload.ts");
|
||||
for (const { id } of orphanedAttachments) {
|
||||
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
|
||||
}
|
||||
db.prepare(
|
||||
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`,
|
||||
).run();
|
||||
}
|
||||
|
||||
// Create default admin user if no users exist
|
||||
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
|
||||
count: number;
|
||||
|
||||
68
api/routes/attachments.ts
Normal file
68
api/routes/attachments.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
createAttachment,
|
||||
getAttachment,
|
||||
} from "../services/attachment-service.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import {
|
||||
ATTACHMENTS_DIR,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Expected multipart/form-data",
|
||||
);
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.formData();
|
||||
const file = body.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const mime = validateImageUpload(data);
|
||||
|
||||
const attachmentId = crypto.randomUUID();
|
||||
await Deno.mkdir(ATTACHMENTS_DIR, { recursive: true });
|
||||
await Deno.writeFile(`${ATTACHMENTS_DIR}/${attachmentId}`, data);
|
||||
try {
|
||||
createAttachment(attachmentId, mime);
|
||||
} catch (err) {
|
||||
await Deno.remove(`${ATTACHMENTS_DIR}/${attachmentId}`).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { success: true, data: { id: attachmentId } };
|
||||
});
|
||||
|
||||
router.get("/api/attachments/:attachmentId", async (ctx) => {
|
||||
const { attachmentId } = ctx.params;
|
||||
|
||||
let attachment;
|
||||
try {
|
||||
attachment = getAttachment(attachmentId);
|
||||
} catch {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
await serveUploadedFile(
|
||||
ctx,
|
||||
`${ATTACHMENTS_DIR}/${attachmentId}`,
|
||||
attachment.mime,
|
||||
);
|
||||
});
|
||||
|
||||
export default router;
|
||||
42
api/services/attachment-service.ts
Normal file
42
api/services/attachment-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -116,6 +116,15 @@ CREATE TABLE invites (
|
||||
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
resource_id TEXT,
|
||||
mime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attachments_resource ON attachments(resource_id);
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user