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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -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 {
|
||||
|
||||
14
api/db/migrations/0002_dump_thumbnail.ts
Normal file
14
api/db/migrations/0002_dump_thumbnail.ts
Normal 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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface Dump {
|
||||
voteCount: number;
|
||||
commentCount: number;
|
||||
isPrivate: boolean;
|
||||
thumbnailMime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/")) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user