v2: global player, infinite scroll, image picker, threaded comments
This commit is contained in:
@@ -8,6 +8,7 @@ import avatarsRouter from "./routes/avatars.ts";
|
||||
import wsRouter from "./routes/ws.ts";
|
||||
import previewRouter from "./routes/preview.ts";
|
||||
import playlistsRouter from "./routes/playlists.ts";
|
||||
import commentsRouter from "./routes/comments.ts";
|
||||
|
||||
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
||||
import { errorMiddleware } from "./middleware/error.ts";
|
||||
@@ -45,6 +46,10 @@ app.use(
|
||||
playlistsRouter.routes(),
|
||||
playlistsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
commentsRouter.routes(),
|
||||
commentsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(routeStaticFilesFrom([
|
||||
`${Deno.cwd()}/dist`,
|
||||
`${Deno.cwd()}/public`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||
import {
|
||||
type Comment,
|
||||
Dump,
|
||||
type Playlist,
|
||||
type RichContent,
|
||||
@@ -9,6 +10,32 @@ import {
|
||||
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
// Migration: add is_private column if it doesn't exist yet
|
||||
try {
|
||||
db.exec(`ALTER TABLE dumps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;`);
|
||||
} catch { /* column already exists */ }
|
||||
|
||||
// Migration: create comments table if it doesn't exist yet
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_dump ON comments(dump_id, created_at);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_dumps_dump ON playlist_dumps(dump_id);`);
|
||||
} catch { /* already exists */ }
|
||||
|
||||
// Migration: add deleted column to comments if it doesn't exist yet
|
||||
try {
|
||||
db.exec(`ALTER TABLE comments ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;`);
|
||||
} catch { /* column already exists */ }
|
||||
|
||||
/**
|
||||
* Database Row Types
|
||||
*/
|
||||
@@ -26,6 +53,8 @@ export interface DumpRow {
|
||||
file_mime: string | null;
|
||||
file_size: number | null;
|
||||
vote_count: number;
|
||||
comment_count?: number;
|
||||
is_private: number;
|
||||
[key: string]: SQLOutputValue; // Index signature
|
||||
}
|
||||
|
||||
@@ -62,7 +91,8 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
||||
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
|
||||
"file_size" in obj &&
|
||||
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
||||
"vote_count" in obj && typeof obj.vote_count === "number";
|
||||
"vote_count" in obj && typeof obj.vote_count === "number" &&
|
||||
"is_private" in obj && typeof obj.is_private === "number";
|
||||
}
|
||||
|
||||
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
||||
@@ -97,6 +127,8 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
||||
fileMime: row.file_mime ?? undefined,
|
||||
fileSize: row.file_size ?? undefined,
|
||||
voteCount: row.vote_count,
|
||||
commentCount: row.comment_count ?? 0,
|
||||
isPrivate: Boolean(row.is_private),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +146,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
file_mime: dump.fileMime ?? null,
|
||||
file_size: dump.fileSize ?? null,
|
||||
vote_count: dump.voteCount,
|
||||
is_private: dump.isPrivate ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +172,46 @@ export function userApiToRow(user: User): UserRow {
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommentRow {
|
||||
id: string;
|
||||
dump_id: string;
|
||||
user_id: string;
|
||||
parent_id: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
deleted: number;
|
||||
author_username: string;
|
||||
author_avatar_mime: string | null;
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
export function isCommentRow(obj: Record<string, SQLOutputValue>): obj is CommentRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
typeof obj.id === "string" &&
|
||||
typeof obj.dump_id === "string" &&
|
||||
typeof obj.user_id === "string" &&
|
||||
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
|
||||
typeof obj.body === "string" &&
|
||||
typeof obj.created_at === "string" &&
|
||||
typeof obj.deleted === "number" &&
|
||||
typeof obj.author_username === "string" &&
|
||||
(typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null);
|
||||
}
|
||||
|
||||
export function commentRowToApi(row: CommentRow): Comment {
|
||||
return {
|
||||
id: row.id,
|
||||
dumpId: row.dump_id,
|
||||
userId: row.user_id,
|
||||
parentId: row.parent_id ?? undefined,
|
||||
body: row.body,
|
||||
createdAt: new Date(row.created_at),
|
||||
deleted: Boolean(row.deleted),
|
||||
authorUsername: row.author_username,
|
||||
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlaylistRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface RichContent {
|
||||
description?: string;
|
||||
thumbnailUrl?: string;
|
||||
videoId?: string;
|
||||
embedUrl?: string;
|
||||
}
|
||||
|
||||
export interface Dump {
|
||||
@@ -25,6 +26,8 @@ export interface Dump {
|
||||
fileMime?: string;
|
||||
fileSize?: number;
|
||||
voteCount: number;
|
||||
commentCount: number;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +133,12 @@ export interface APIFailure {
|
||||
|
||||
export type APIResponse<T> = APISuccess<T> | APIFailure;
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export class APIException extends Error {
|
||||
readonly code: APIErrorCode;
|
||||
readonly status: number;
|
||||
@@ -141,6 +150,34 @@ export class APIException extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comments
|
||||
*/
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
dumpId: string;
|
||||
userId: string;
|
||||
parentId?: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
deleted: boolean;
|
||||
authorUsername: string;
|
||||
authorAvatarMime?: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
body: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export function isCreateCommentRequest(obj: unknown): obj is CreateCommentRequest {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
const o = obj as Record<string, unknown>;
|
||||
return typeof o.body === "string" && (o.body as string).trim().length > 0 &&
|
||||
(!("parentId" in o) || typeof o.parentId === "string" || o.parentId === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Playlists
|
||||
*/
|
||||
@@ -216,6 +253,7 @@ export function isReorderPlaylistRequest(
|
||||
export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export function isCreateUrlDumpRequest(
|
||||
@@ -225,12 +263,14 @@ export function isCreateUrlDumpRequest(
|
||||
typeof obj === "object" &&
|
||||
"url" in obj && typeof obj.url === "string" &&
|
||||
(!("comment" in obj) ||
|
||||
typeof obj.comment === "string" || obj.comment === null);
|
||||
typeof obj.comment === "string" || obj.comment === null) &&
|
||||
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
|
||||
}
|
||||
|
||||
export interface UpdateDumpRequest {
|
||||
url?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
||||
@@ -238,7 +278,8 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
||||
typeof obj === "object" &&
|
||||
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) &&
|
||||
(!("comment" in obj) ||
|
||||
typeof obj.comment === "string" || obj.comment === null);
|
||||
typeof obj.comment === "string" || obj.comment === null) &&
|
||||
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
77
api/routes/comments.ts
Normal file
77
api/routes/comments.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type Comment,
|
||||
isCreateCommentRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
getComments,
|
||||
} from "../services/comment-service.ts";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import {
|
||||
broadcastCommentCreated,
|
||||
broadcastCommentDeleted,
|
||||
} from "../services/ws-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api" });
|
||||
|
||||
// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments)
|
||||
router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||
let requestingUserId: string | undefined;
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
const comments = getComments(dump.id);
|
||||
const responseBody: APIResponse<Comment[]> = { success: true, data: comments };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/dumps/:dumpId/comments — auth required
|
||||
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId as string;
|
||||
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
const body = await ctx.request.body.json();
|
||||
if (!isCreateCommentRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid comment data",
|
||||
);
|
||||
}
|
||||
const comment = createComment(
|
||||
dump.id,
|
||||
userId,
|
||||
body.body,
|
||||
body.parentId ?? undefined,
|
||||
);
|
||||
if (!dump.isPrivate) broadcastCommentCreated(comment);
|
||||
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// DELETE /api/comments/:commentId — auth required
|
||||
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||
const userId = ctx.state.user.userId as string;
|
||||
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
||||
const { dumpId, isPrivate } = deleteComment(
|
||||
ctx.params.commentId,
|
||||
userId,
|
||||
isAdmin,
|
||||
);
|
||||
if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId);
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,15 +7,18 @@ import {
|
||||
type Dump,
|
||||
isCreateUrlDumpRequest,
|
||||
isUpdateDumpRequest,
|
||||
type PaginatedData,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import {
|
||||
createFileDump,
|
||||
createUrlDump,
|
||||
deleteDump,
|
||||
getDump,
|
||||
listDumps,
|
||||
refreshDumpMetadata,
|
||||
replaceFileDump,
|
||||
updateDump,
|
||||
} from "../services/dump-service.ts";
|
||||
@@ -35,6 +38,7 @@ router.post(
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
const comment = formData.get("comment");
|
||||
const isPrivate = formData.get("isPrivate") === "true";
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(
|
||||
@@ -48,6 +52,7 @@ router.post(
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
userId,
|
||||
isPrivate,
|
||||
);
|
||||
} else {
|
||||
const body = await ctx.request.body.json();
|
||||
@@ -69,15 +74,32 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/:dumpId", (ctx) => {
|
||||
const dump = getDump(ctx.params.dumpId);
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
let requestingUserId: string | undefined;
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: dump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/", (ctx) => {
|
||||
const dumps = listDumps();
|
||||
const responseBody: APIResponse<Dump[]> = { success: true, data: dumps };
|
||||
router.get("/", async (ctx) => {
|
||||
let requestingUserId: string | undefined;
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const { items, total } = listDumps(page, limit, requestingUserId);
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
@@ -85,7 +107,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
const dump = getDump(dumpId, userId);
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
@@ -128,7 +150,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(
|
||||
@@ -143,10 +165,28 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/:dumpId/refresh-metadata", 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,
|
||||
401,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
}
|
||||
|
||||
const updatedDump = await refreshDumpMetadata(dumpId);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const dump = getDump(dumpId);
|
||||
const dump = getDump(dumpId, userId);
|
||||
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
APIException,
|
||||
isLoginUserRequest,
|
||||
isRegisterUserRequest,
|
||||
type PaginatedData,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { createJWT, verifyJWT, verifyPassword } from "../lib/jwt.ts";
|
||||
@@ -150,8 +151,13 @@ router.get("/:username/playlists", async (ctx) => {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const playlists = listPlaylistsByUser(user.id, requestingUserId);
|
||||
ctx.response.body = { success: true, data: playlists };
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const { items, total } = listPlaylistsByUser(user.id, requestingUserId, page, limit);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
// Public user profile by username (no passwordHash)
|
||||
@@ -161,18 +167,41 @@ router.get("/:username", (ctx) => {
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
});
|
||||
|
||||
// Dumps posted by user
|
||||
router.get("/:username/dumps", (ctx) => {
|
||||
// Dumps posted by user (optional auth: owner sees their private dumps)
|
||||
router.get("/:username/dumps", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const dumps = getDumpsByUser(user.id);
|
||||
ctx.response.body = { success: true, data: dumps };
|
||||
let requestingUserId: string | null = null;
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const includePrivate = requestingUserId === user.id;
|
||||
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
// Dumps upvoted by user
|
||||
router.get("/:username/votes", (ctx) => {
|
||||
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
||||
router.get("/:username/votes", async (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const dumps = getVotedDumpsByUser(user.id);
|
||||
ctx.response.body = { success: true, data: dumps };
|
||||
let requestingUserId: string | null = null;
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const payload = await verifyJWT(authHeader.substring(7));
|
||||
if (payload) requestingUserId = payload.userId;
|
||||
}
|
||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||
const { items, total } = getVotedDumpsByUser(user.id, page, limit, requestingUserId);
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
78
api/services/comment-service.ts
Normal file
78
api/services/comment-service.ts
Normal 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) };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,56 +1,77 @@
|
||||
CREATE TABLE dumps (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
comment TEXT,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
url TEXT,
|
||||
rich_content TEXT,
|
||||
file_name TEXT,
|
||||
file_mime TEXT,
|
||||
file_size INTEGER,
|
||||
vote_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
comment TEXT,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
url TEXT,
|
||||
rich_content TEXT,
|
||||
file_name TEXT,
|
||||
file_mime TEXT,
|
||||
file_size INTEGER,
|
||||
vote_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_private INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
avatar_mime TEXT
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
avatar_mime TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE votes (
|
||||
dump_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
dump_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (dump_id, user_id),
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- v2: playlists
|
||||
CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_public INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
image_mime TEXT
|
||||
created_at TEXT NOT NULL,
|
||||
image_mime TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE playlist_dumps (
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
playlist_id TEXT NOT NULL,
|
||||
dump_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (playlist_id, dump_id)
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (playlist_id, dump_id),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
||||
-- v3: comments
|
||||
CREATE TABLE comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
dump_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
||||
CREATE INDEX idx_votes_user ON votes(user_id);
|
||||
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
||||
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
||||
|
||||
Reference in New Issue
Block a user