v2: global player, infinite scroll, image picker, threaded comments
This commit is contained in:
@@ -7,7 +7,11 @@ import {
|
||||
} from "../model/interfaces.ts";
|
||||
import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts";
|
||||
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
|
||||
import { broadcastDumpDeleted, broadcastNewDump } from "./ws-service.ts";
|
||||
import {
|
||||
broadcastDumpDeleted,
|
||||
broadcastDumpUpdated,
|
||||
broadcastNewDump,
|
||||
} from "./ws-service.ts";
|
||||
|
||||
const UPLOADS_DIR = "api/uploads";
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
@@ -39,8 +43,15 @@ function titleFromUrl(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT_COLS =
|
||||
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count";
|
||||
const BASE_COLS =
|
||||
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
||||
|
||||
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.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
|
||||
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
|
||||
|
||||
export async function createUrlDump(
|
||||
request: CreateUrlDumpRequest,
|
||||
@@ -54,10 +65,11 @@ export async function createUrlDump(
|
||||
const createdAt = new Date();
|
||||
const richContent = await fetchRichContent(request.url);
|
||||
const title = richContent?.title ?? titleFromUrl(request.url);
|
||||
const isPrivate = request.isPrivate ?? false;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content, is_private)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
dumpId,
|
||||
"url",
|
||||
@@ -67,6 +79,7 @@ export async function createUrlDump(
|
||||
createdAt.toISOString(),
|
||||
request.url,
|
||||
richContent ? JSON.stringify(richContent) : null,
|
||||
isPrivate ? 1 : 0,
|
||||
);
|
||||
|
||||
const dump: Dump = {
|
||||
@@ -79,8 +92,10 @@ export async function createUrlDump(
|
||||
url: request.url,
|
||||
richContent,
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
isPrivate,
|
||||
};
|
||||
broadcastNewDump(dump);
|
||||
if (!isPrivate) broadcastNewDump(dump);
|
||||
return dump;
|
||||
}
|
||||
|
||||
@@ -88,6 +103,7 @@ export async function createFileDump(
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
userId: string,
|
||||
isPrivate = false,
|
||||
): Promise<Dump> {
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
@@ -114,8 +130,8 @@ export async function createFileDump(
|
||||
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
dumpId,
|
||||
"file",
|
||||
@@ -126,6 +142,7 @@ export async function createFileDump(
|
||||
file.name,
|
||||
file.type,
|
||||
file.size,
|
||||
isPrivate ? 1 : 0,
|
||||
);
|
||||
} catch (err) {
|
||||
// Roll back the file if DB insert fails
|
||||
@@ -144,55 +161,80 @@ export async function createFileDump(
|
||||
fileMime: file.type,
|
||||
fileSize: file.size,
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
isPrivate,
|
||||
};
|
||||
broadcastNewDump(dump);
|
||||
if (!isPrivate) broadcastNewDump(dump);
|
||||
return dump;
|
||||
}
|
||||
|
||||
export function getDump(dumpId: string): Dump {
|
||||
// Internal fetch — no privacy check. Use only when ownership is already enforced.
|
||||
function fetchDump(dumpId: string): Dump {
|
||||
const row = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId);
|
||||
|
||||
if (!row || !isDumpRow(row)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
|
||||
return dumpRowToApi(row);
|
||||
}
|
||||
|
||||
export function listDumps(): Dump[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps;`,
|
||||
).all();
|
||||
// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own.
|
||||
export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||
const dump = fetchDump(dumpId);
|
||||
if (dump.isPrivate && dump.userId !== requestingUserId) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
return dump;
|
||||
}
|
||||
|
||||
export function listDumps(
|
||||
page: number,
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
): { items: Dump[]; total: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
// Show public dumps + the requesting user's own private dumps
|
||||
const rows = requestingUserId
|
||||
? db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(requestingUserId, limit, offset)
|
||||
: db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(limit, offset);
|
||||
const totalRow = requestingUserId
|
||||
? db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?);`,
|
||||
).get(requestingUserId) as { count: number } | undefined
|
||||
: db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0;`,
|
||||
).get() as { count: number } | undefined;
|
||||
|
||||
if (!rows || !rows.every(isDumpRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
}
|
||||
|
||||
return rows.map(dumpRowToApi);
|
||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
export async function updateDump(
|
||||
dumpId: string,
|
||||
request: UpdateDumpRequest,
|
||||
): Promise<Dump> {
|
||||
const dump = getDump(dumpId);
|
||||
const dump = fetchDump(dumpId);
|
||||
|
||||
// File dumps: only comment is editable
|
||||
// File dumps: only comment and isPrivate are editable
|
||||
if (dump.kind === "file") {
|
||||
const updatedDump = {
|
||||
const updatedDump: Dump = {
|
||||
...dump,
|
||||
comment: "comment" in request
|
||||
? (request.comment ?? undefined)
|
||||
: dump.comment,
|
||||
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
|
||||
};
|
||||
db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`)
|
||||
.run(updatedDump.comment ?? null, dumpId);
|
||||
db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`)
|
||||
.run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId);
|
||||
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
@@ -218,17 +260,19 @@ export async function updateDump(
|
||||
: dump.comment,
|
||||
url: newUrl,
|
||||
richContent,
|
||||
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
|
||||
};
|
||||
|
||||
const row = dumpApiToRow(updatedDump);
|
||||
const result = db.prepare(
|
||||
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ? WHERE id = ?;`,
|
||||
).run(row.title, row.comment, row.url, row.rich_content, row.id);
|
||||
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`,
|
||||
).run(row.title, row.comment, row.url, row.rich_content, row.is_private, row.id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
|
||||
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
@@ -252,7 +296,7 @@ export async function replaceFileDump(
|
||||
);
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
const dump = fetchDump(dumpId);
|
||||
if (dump.kind !== "file") {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
|
||||
}
|
||||
@@ -274,40 +318,98 @@ export async function replaceFileDump(
|
||||
};
|
||||
}
|
||||
|
||||
export function getDumpsByUser(userId: string): Dump[] {
|
||||
export function getDumpsByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
includePrivate: boolean,
|
||||
): { items: Dump[]; total: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
const privacyFilter = includePrivate ? "" : " AND is_private = 0";
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`,
|
||||
).all(userId);
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(userId, limit, offset);
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`,
|
||||
).get(userId) as { count: number } | undefined;
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
export function getVotedDumpsByUser(userId: string): Dump[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")}
|
||||
FROM dumps d
|
||||
INNER JOIN votes v ON d.id = v.dump_id
|
||||
WHERE v.user_id = ?
|
||||
ORDER BY v.created_at DESC;`,
|
||||
).all(userId);
|
||||
export function getVotedDumpsByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
requestingUserId: string | null,
|
||||
): { items: Dump[]; total: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
const dumpCols = SELECT_COLS_ALIASED;
|
||||
|
||||
let totalRow: { count: number } | undefined;
|
||||
let rawRows: unknown[];
|
||||
|
||||
if (requestingUserId) {
|
||||
rawRows = db.prepare(
|
||||
`SELECT ${dumpCols}
|
||||
FROM dumps d
|
||||
INNER JOIN votes v ON d.id = v.dump_id
|
||||
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
|
||||
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(userId, requestingUserId, limit, offset);
|
||||
totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps d
|
||||
INNER JOIN votes v ON d.id = v.dump_id
|
||||
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`,
|
||||
).get(userId, requestingUserId) as { count: number } | undefined;
|
||||
} else {
|
||||
rawRows = db.prepare(
|
||||
`SELECT ${dumpCols}
|
||||
FROM dumps d
|
||||
INNER JOIN votes v ON d.id = v.dump_id
|
||||
WHERE v.user_id = ? AND d.is_private = 0
|
||||
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(userId, limit, offset);
|
||||
totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps d
|
||||
INNER JOIN votes v ON d.id = v.dump_id
|
||||
WHERE v.user_id = ? AND d.is_private = 0;`,
|
||||
).get(userId) as { count: number } | undefined;
|
||||
}
|
||||
|
||||
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
}
|
||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
|
||||
if (dump.kind !== "url" || !dump.url) {
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Only URL dumps support metadata refresh",
|
||||
);
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
|
||||
const richContent = await fetchRichContent(dump.url);
|
||||
const title = richContent?.title ?? titleFromUrl(dump.url);
|
||||
|
||||
const updatedDump: Dump = { ...dump, title, richContent };
|
||||
const row = dumpApiToRow(updatedDump);
|
||||
db.prepare(
|
||||
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
||||
).run(row.title, row.rich_content, row.id);
|
||||
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
export async function deleteDump(dumpId: string): Promise<void> {
|
||||
const dump = getDump(dumpId);
|
||||
const dump = fetchDump(dumpId);
|
||||
|
||||
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user