v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -0,0 +1,78 @@
import {
APIErrorCode,
APIException,
type Comment,
} from "../model/interfaces.ts";
import {
commentRowToApi,
type CommentRow,
db,
isCommentRow,
} from "../model/db.ts";
const SELECT_COLS =
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.deleted,
u.username as author_username, u.avatar_mime as author_avatar_mime`;
function fetchComment(commentId: string): Comment {
const row = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
).get(commentId);
if (!row || !isCommentRow(row as Record<string, unknown>)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
return commentRowToApi(row as CommentRow);
}
export function getComments(dumpId: string): Comment[] {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
WHERE c.dump_id = ? ORDER BY c.created_at ASC;`,
).all(dumpId);
const typed = rows as Parameters<typeof isCommentRow>[0][];
if (!typed.every(isCommentRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed comment data",
);
}
return typed.map(commentRowToApi);
}
export function createComment(
dumpId: string,
userId: string,
body: string,
parentId?: string,
): Comment {
const id = crypto.randomUUID();
const createdAt = new Date();
db.prepare(
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
).run(id, dumpId, userId, parentId ?? null, body.trim(), createdAt.toISOString());
return fetchComment(id);
}
export function deleteComment(
commentId: string,
requestingUserId: string,
isAdmin: boolean,
): { dumpId: string; isPrivate: boolean } {
const row = db.prepare(
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as { dump_id: string; is_private: number } | undefined;
if (!row) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
const comment = fetchComment(commentId);
if (comment.userId !== requestingUserId && !isAdmin) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authorized to delete this comment",
);
}
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(commentId);
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
}

View File

@@ -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);

View File

@@ -24,7 +24,7 @@ import {
} from "./ws-service.ts";
const DUMP_SELECT_COLS =
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count";
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
function getPlaylistById(playlistId: string): Playlist {
const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get(
@@ -75,32 +75,54 @@ export function getPlaylist(
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
}
const dumpCols = DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ");
const isOwner = requestingUserId === playlist.userId;
// For public playlists (or when viewed by non-owner), filter out private dumps
const rows = db.prepare(
`SELECT ${DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")}
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
WHERE pd.playlist_id = ?
AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY pd.position ASC;`,
).all(playlistId);
).all(playlistId, requestingUserId ?? "");
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi);
// Owners always see their own private dumps; strip them for non-owners regardless
const visibleDumps = isOwner
? dumps
: dumps.filter((d) => !d.isPrivate);
return { ...playlist, dumps };
return { ...playlist, dumps: visibleDumps };
}
export function listPlaylistsByUser(
userId: string,
requestingUserId: string | null,
): Playlist[] {
page: number,
limit: number,
): { items: Playlist[]; total: number } {
const isOwner = requestingUserId === userId;
const offset = (page - 1) * limit;
const countSql = isOwner
? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;`
: `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`;
const sql = isOwner
? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC;`
FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
: `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC;`;
FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
const rows = db.prepare(sql).all(userId);
return rows.filter(isPlaylistRow).map(playlistRowToApi);
const totalRow = db.prepare(countSql).get(userId) as
| { count: number }
| undefined;
const rows = db.prepare(sql).all(userId, limit, offset);
return {
items: rows.filter(isPlaylistRow).map(playlistRowToApi),
total: totalRow?.count ?? 0,
};
}
export function updatePlaylist(
@@ -179,11 +201,11 @@ export function addDumpToPlaylist(
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
const maxRow = db.prepare(
`SELECT MAX(position) as max_pos FROM playlist_dumps WHERE playlist_id = ?;`,
).get(playlistId) as { max_pos: number | null } | undefined;
const minRow = db.prepare(
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
).get(playlistId) as { min_pos: number | null } | undefined;
const nextPos = (maxRow?.max_pos ?? -1) + 1;
const nextPos = (minRow?.min_pos ?? 1) - 1;
const addedAt = new Date().toISOString();
try {

View File

@@ -32,6 +32,7 @@ export const bandcampProvider: RichContentProvider = {
title: extractOgTag(html, "title"),
description: extractOgTag(html, "description"),
thumbnailUrl: extractOgTag(html, "image"),
embedUrl: extractOgTag(html, "video") ?? undefined,
};
},
};

View File

@@ -30,6 +30,7 @@ export const soundcloudProvider: RichContentProvider = {
title: extractOgTag(html, "title"),
description: extractOgTag(html, "description"),
thumbnailUrl: extractOgTag(html, "image"),
embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&visual=true&auto_play=false`,
};
},
};

View File

@@ -2,18 +2,35 @@ import type { RichContent } from "../../model/interfaces.ts";
import type { RichContentProvider } from "../rich-content-service.ts";
import { fetchWithTimeout } from "../rich-content-service.ts";
const YOUTUBE_REGEX =
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
function extractVideoId(url: string): string | null {
try {
const u = new URL(url);
if (u.hostname === "youtu.be") {
return u.pathname.slice(1).split("/")[0] || null;
}
if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") {
if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) {
return u.searchParams.get("v");
}
if (u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")) {
return u.pathname.split("/")[2] || null;
}
}
} catch {
// invalid URL
}
return null;
}
export const youtubeProvider: RichContentProvider = {
name: "youtube",
matches(url: string): boolean {
return YOUTUBE_REGEX.test(url);
return extractVideoId(url) !== null;
},
async fetch(url: string): Promise<RichContent> {
const videoId = url.match(YOUTUBE_REGEX)![1];
const videoId = extractVideoId(url)!;
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
let title: string | undefined;
@@ -36,6 +53,7 @@ export const youtubeProvider: RichContentProvider = {
videoId,
title,
thumbnailUrl,
embedUrl: `https://www.youtube.com/embed/${videoId}?rel=0`,
};
},
};

View File

@@ -1,4 +1,4 @@
import type { Dump, OnlineUser, Playlist } from "../model/interfaces.ts";
import type { Comment, Dump, OnlineUser, Playlist } from "../model/interfaces.ts";
export interface WsClient {
socket: WebSocket;
@@ -59,6 +59,12 @@ export function broadcastNewDump(dump: Dump): void {
}
}
export function broadcastDumpUpdated(dump: Dump): void {
for (const client of clients) {
send(client.socket, { type: "dump_updated", dump });
}
}
export function broadcastDumpDeleted(dumpId: string): void {
for (const client of clients) {
send(client.socket, { type: "dump_deleted", dumpId });
@@ -124,6 +130,18 @@ export function broadcastPlaylistDumpsUpdated(
});
}
export function broadcastCommentCreated(comment: Comment): void {
for (const client of clients) {
send(client.socket, { type: "comment_created", comment });
}
}
export function broadcastCommentDeleted(commentId: string, dumpId: string): void {
for (const client of clients) {
send(client.socket, { type: "comment_deleted", commentId, dumpId });
}
}
// Keepalive: ping all clients every 30s, remove non-responsive ones
const PING_INTERVAL = 30_000;