v3: allow users to edit dump thumbnail, fixed some linter errors in the frontend
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
khannurien
2026-06-21 15:41:40 +00:00
parent 57cf55cc48
commit cf988ae608
27 changed files with 431 additions and 121 deletions

View File

@@ -1,5 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
interface Migration {
name: string;
@@ -11,6 +12,7 @@ interface Migration {
// straight from schema.sql, where the change it makes already exists.
const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes },
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,14 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with this column) or an existing one that predates it.
export function up(db: DatabaseSync): void {
const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string;
}[];
if (!dumpCols.some((c) => c.name === "custom_thumbnail_mime")) {
db.exec(
`ALTER TABLE dumps ADD COLUMN custom_thumbnail_mime TEXT;`,
);
}
}

View File

@@ -14,6 +14,7 @@ CREATE TABLE dumps (
file_size INTEGER,
vote_count INTEGER NOT NULL DEFAULT 0,
is_private INTEGER NOT NULL DEFAULT 0,
custom_thumbnail_mime TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@@ -79,6 +79,7 @@ export interface DumpRow {
vote_count: number;
comment_count: number;
is_private: number;
custom_thumbnail_mime: string | null;
[key: string]: SQLOutputValue; // Index signature
}
@@ -126,7 +127,10 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
(typeof obj.file_size === "number" || obj.file_size === null) &&
"vote_count" in obj && typeof obj.vote_count === "number" &&
"comment_count" in obj && typeof obj.comment_count === "number" &&
"is_private" in obj && typeof obj.is_private === "number";
"is_private" in obj && typeof obj.is_private === "number" &&
"custom_thumbnail_mime" in obj &&
(typeof obj.custom_thumbnail_mime === "string" ||
obj.custom_thumbnail_mime === null);
}
export function isUserRow(obj: unknown): obj is UserRow {
@@ -172,6 +176,7 @@ export function dumpRowToApi(row: DumpRow): Dump {
voteCount: row.vote_count,
commentCount: row.comment_count,
isPrivate: Boolean(row.is_private),
thumbnailMime: row.custom_thumbnail_mime ?? undefined,
};
}
@@ -193,6 +198,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
vote_count: dump.voteCount,
comment_count: dump.commentCount,
is_private: dump.isPrivate ? 1 : 0,
custom_thumbnail_mime: dump.thumbnailMime ?? null,
};
}

View File

@@ -32,6 +32,7 @@ export interface Dump {
voteCount: number;
commentCount: number;
isPrivate: boolean;
thumbnailMime?: string;
}
/**

View File

@@ -13,6 +13,8 @@ import {
import { authMiddleware } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import { validateImageUpload } from "../lib/upload.ts";
import { THUMBNAILS_DIR } from "../config.ts";
import {
createFileDump,
createUrlDump,
@@ -20,7 +22,9 @@ import {
getDump,
listDumps,
refreshDumpMetadata,
removeDumpThumbnail,
replaceFileDump,
setDumpThumbnail,
updateDump,
} from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts";
@@ -175,6 +179,66 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const formData = await ctx.request.body.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Missing file field",
);
}
const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data);
// DB update first (resolves slug → id), then file write.
const updatedDump = setDumpThumbnail(dump.id, mime);
const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`;
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
try {
await Deno.writeFile(filePath, data);
} catch (err) {
await Deno.remove(filePath).catch(() => {});
throw err;
}
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.delete("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;
const dump = getDump(dumpId, userId);
if (userId !== dump.userId) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
403,
"Not authorized to update dump",
);
}
const updatedDump = await removeDumpThumbnail(dump.id);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId;
const userId = ctx.state.user?.userId;

View File

@@ -1,6 +1,7 @@
import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { getDump } from "../services/dump-service.ts";
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
const router = new Router({ prefix: "/api/thumbnails" });
@@ -53,6 +54,16 @@ router.get("/:dumpId", async (ctx) => {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
}
const customThumb = getDumpThumbnailInfo(dumpId);
if (customThumb) {
const served = await serveUploadedFile(
ctx,
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
customThumb.mime,
);
if (served) return;
}
const dump = getDump(dumpId);
if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) {

View File

@@ -22,6 +22,7 @@ import {
DUMP_ALLOWED_MIME_TYPES,
DUMP_MAX_FILE_SIZE_BYTES,
DUMPS_DIR,
THUMBNAILS_DIR,
} from "../config.ts";
import { linkAttachments } from "./attachment-service.ts";
@@ -39,13 +40,13 @@ function titleFromUrl(url: string): string {
}
const BASE_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";
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime";
const SELECT_COLS = `${BASE_COLS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
const SELECT_COLS_ALIASED =
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime," +
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
export async function createUrlDump(
@@ -566,6 +567,36 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
return updatedDump;
}
export function getDumpThumbnailInfo(
idOrSlug: string,
): { id: string; mime: string } | undefined {
const dump = fetchDump(idOrSlug);
return dump.thumbnailMime
? { id: dump.id, mime: dump.thumbnailMime }
: undefined;
}
export function setDumpThumbnail(dumpId: string, mime: string): Dump {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = ? WHERE id = ?;`).run(
mime,
dump.id,
);
const updatedDump: Dump = { ...dump, thumbnailMime: mime };
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}
export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
.run(dump.id);
await Deno.remove(`${THUMBNAILS_DIR}/${dump.id}-custom`).catch(() => {});
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}
export async function deleteDump(dumpId: string): Promise<void> {
const dump = fetchDump(dumpId);