vibe coded v1
This commit is contained in:
@@ -1,99 +1,275 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type CreateDumpRequest,
|
||||
type CreateUrlDumpRequest,
|
||||
type Dump,
|
||||
type UpdateDumpRequest,
|
||||
} 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";
|
||||
|
||||
export function createDump(
|
||||
request: CreateDumpRequest,
|
||||
const UPLOADS_DIR = "api/uploads";
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/msword",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
]);
|
||||
|
||||
function isAllowedMime(mime: string): boolean {
|
||||
return ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||
ALLOWED_MIME_TYPES.has(mime);
|
||||
}
|
||||
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT_COLS =
|
||||
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count";
|
||||
|
||||
export async function createUrlDump(
|
||||
request: CreateUrlDumpRequest,
|
||||
userId: string,
|
||||
): Dump {
|
||||
): Promise<Dump> {
|
||||
if (!isValidHttpUrl(request.url)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
||||
}
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
const richContent = await fetchRichContent(request.url);
|
||||
const title = richContent?.title ?? titleFromUrl(request.url);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
dumpId,
|
||||
"url",
|
||||
title,
|
||||
request.comment ?? null,
|
||||
userId,
|
||||
createdAt.toISOString(),
|
||||
request.url,
|
||||
richContent ? JSON.stringify(richContent) : null,
|
||||
);
|
||||
|
||||
const dump: Dump = { id: dumpId, kind: "url", title, comment: request.comment, userId, createdAt, url: request.url, richContent, voteCount: 0 };
|
||||
broadcastNewDump(dump);
|
||||
return dump;
|
||||
}
|
||||
|
||||
export async function createFileDump(
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
userId: string,
|
||||
): Promise<Dump> {
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
`File type '${file.type}' is not allowed`,
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)");
|
||||
}
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO dumps (id, title, description, user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
dumpId,
|
||||
request.title,
|
||||
request.description ?? null,
|
||||
userId,
|
||||
createdAt.toISOString(),
|
||||
);
|
||||
await Deno.mkdir(UPLOADS_DIR, { recursive: true });
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
return {
|
||||
try {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
dumpId,
|
||||
"file",
|
||||
file.name,
|
||||
comment ?? null,
|
||||
userId,
|
||||
createdAt.toISOString(),
|
||||
file.name,
|
||||
file.type,
|
||||
file.size,
|
||||
);
|
||||
} catch (err) {
|
||||
// Roll back the file if DB insert fails
|
||||
await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const dump: Dump = {
|
||||
id: dumpId,
|
||||
title: request.title,
|
||||
description: request.description ?? undefined,
|
||||
userId: userId,
|
||||
kind: "file",
|
||||
title: file.name,
|
||||
comment,
|
||||
userId,
|
||||
createdAt,
|
||||
fileName: file.name,
|
||||
fileMime: file.type,
|
||||
fileSize: file.size,
|
||||
voteCount: 0,
|
||||
};
|
||||
broadcastNewDump(dump);
|
||||
return dump;
|
||||
}
|
||||
|
||||
export function getDump(dumpId: string): Dump {
|
||||
const dumpRow = db.prepare(
|
||||
`SELECT id, title, description, user_id, created_at
|
||||
FROM dumps WHERE id = ?;`,
|
||||
const row = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId);
|
||||
|
||||
if (!dumpRow || !isDumpRow(dumpRow)) {
|
||||
if (!row || !isDumpRow(row)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
|
||||
return dumpRowToApi(dumpRow);
|
||||
return dumpRowToApi(row);
|
||||
}
|
||||
|
||||
export function listDumps(): Dump[] {
|
||||
const dumpRows = db.prepare(
|
||||
`SELECT id, title, description, user_id, created_at FROM dumps;`,
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps;`,
|
||||
).all();
|
||||
|
||||
if (!dumpRows || !dumpRows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No dump found");
|
||||
if (!rows || !rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
}
|
||||
|
||||
const dumps: Dump[] = dumpRows.map(dumpRowToApi);
|
||||
|
||||
return dumps;
|
||||
return rows.map(dumpRowToApi);
|
||||
}
|
||||
|
||||
export function updateDump(
|
||||
export async function updateDump(
|
||||
dumpId: string,
|
||||
request: UpdateDumpRequest,
|
||||
): Dump {
|
||||
): Promise<Dump> {
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
const updatedDump = {
|
||||
// File dumps: only comment is editable
|
||||
if (dump.kind === "file") {
|
||||
const updatedDump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment };
|
||||
db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`)
|
||||
.run(updatedDump.comment ?? null, dumpId);
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
// URL dumps
|
||||
const newUrl = request.url ?? dump.url!;
|
||||
|
||||
if (!isValidHttpUrl(newUrl)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
||||
}
|
||||
|
||||
let { richContent, title } = dump;
|
||||
|
||||
if (newUrl !== dump.url) {
|
||||
richContent = await fetchRichContent(newUrl);
|
||||
title = richContent?.title ?? titleFromUrl(newUrl);
|
||||
}
|
||||
|
||||
const updatedDump: Dump = {
|
||||
...dump,
|
||||
...request,
|
||||
title,
|
||||
comment: "comment" in request ? (request.comment ?? undefined) : dump.comment,
|
||||
url: newUrl,
|
||||
richContent,
|
||||
};
|
||||
const updatedDumpRow = dumpApiToRow(updatedDump);
|
||||
|
||||
const dumpResult = db.prepare(
|
||||
`UPDATE dumps SET title = ?, description = ? WHERE id = ?;`,
|
||||
).run(
|
||||
updatedDumpRow.title,
|
||||
updatedDumpRow.description,
|
||||
updatedDumpRow.id,
|
||||
);
|
||||
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);
|
||||
|
||||
if (dumpResult.changes === 0) {
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
export function deleteDump(dumpId: string): void {
|
||||
const result = db.prepare(
|
||||
`DELETE FROM dumps WHERE id = ?;`,
|
||||
).run(dumpId);
|
||||
export async function replaceFileDump(
|
||||
dumpId: string,
|
||||
file: File,
|
||||
comment: string | undefined,
|
||||
): Promise<Dump> {
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, `File type '${file.type}' is not allowed`);
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)");
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
if (dump.kind !== "file") {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ? WHERE id = ?;`,
|
||||
).run(file.name, file.name, file.type, file.size, comment ?? null, dumpId);
|
||||
|
||||
return { ...dump, title: file.name, fileName: file.name, fileMime: file.type, fileSize: file.size, comment };
|
||||
}
|
||||
|
||||
export function getDumpsByUser(userId: string): Dump[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`,
|
||||
).all(userId);
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
}
|
||||
|
||||
export async function deleteDump(dumpId: string): Promise<void> {
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
|
||||
if (dump.kind === "file") {
|
||||
await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {});
|
||||
}
|
||||
|
||||
broadcastDumpDeleted(dumpId);
|
||||
}
|
||||
|
||||
37
api/services/providers/bandcamp.ts
Normal file
37
api/services/providers/bandcamp.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RichContent } from "../../model/interfaces.ts";
|
||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
|
||||
|
||||
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
|
||||
|
||||
export const bandcampProvider: RichContentProvider = {
|
||||
name: "bandcamp",
|
||||
|
||||
matches(url: string): boolean {
|
||||
try {
|
||||
return BANDCAMP_REGEX.test(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const res = await fetchWithTimeout(url);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.startsWith("text/html")) {
|
||||
return { type: "bandcamp", siteName: "Bandcamp", url };
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
return {
|
||||
type: "bandcamp",
|
||||
siteName: "Bandcamp",
|
||||
url,
|
||||
title: extractOgTag(html, "title"),
|
||||
description: extractOgTag(html, "description"),
|
||||
thumbnailUrl: extractOgTag(html, "image"),
|
||||
};
|
||||
},
|
||||
};
|
||||
31
api/services/providers/generic.ts
Normal file
31
api/services/providers/generic.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { RichContent } from "../../model/interfaces.ts";
|
||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
|
||||
|
||||
export const genericProvider: RichContentProvider = {
|
||||
name: "generic",
|
||||
|
||||
matches(_url: string): boolean {
|
||||
return true; // fallback — always matches
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const res = await fetchWithTimeout(url);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.startsWith("text/html")) {
|
||||
return { type: "generic", url };
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
return {
|
||||
type: "generic",
|
||||
url,
|
||||
title: extractOgTag(html, "title"),
|
||||
description: extractOgTag(html, "description"),
|
||||
thumbnailUrl: extractOgTag(html, "image"),
|
||||
siteName: extractOgTag(html, "site_name"),
|
||||
};
|
||||
},
|
||||
};
|
||||
35
api/services/providers/soundcloud.ts
Normal file
35
api/services/providers/soundcloud.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { RichContent } from "../../model/interfaces.ts";
|
||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
|
||||
|
||||
export const soundcloudProvider: RichContentProvider = {
|
||||
name: "soundcloud",
|
||||
|
||||
matches(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).hostname === "soundcloud.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const res = await fetchWithTimeout(url);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.startsWith("text/html")) {
|
||||
return { type: "soundcloud", siteName: "SoundCloud", url };
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
return {
|
||||
type: "soundcloud",
|
||||
siteName: "SoundCloud",
|
||||
url,
|
||||
title: extractOgTag(html, "title"),
|
||||
description: extractOgTag(html, "description"),
|
||||
thumbnailUrl: extractOgTag(html, "image"),
|
||||
};
|
||||
},
|
||||
};
|
||||
34
api/services/providers/youtube.ts
Normal file
34
api/services/providers/youtube.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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})/;
|
||||
|
||||
export const youtubeProvider: RichContentProvider = {
|
||||
name: "youtube",
|
||||
|
||||
matches(url: string): boolean {
|
||||
return YOUTUBE_REGEX.test(url);
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const videoId = url.match(YOUTUBE_REGEX)![1];
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
let title: string | undefined;
|
||||
|
||||
try {
|
||||
const oembedUrl =
|
||||
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
|
||||
const res = await fetchWithTimeout(oembedUrl);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { title?: string };
|
||||
title = data.title;
|
||||
}
|
||||
} catch {
|
||||
// oembed failed — thumbnail still works
|
||||
}
|
||||
|
||||
return { type: "youtube", siteName: "YouTube", url, videoId, title, thumbnailUrl };
|
||||
},
|
||||
};
|
||||
97
api/services/rich-content-service.ts
Normal file
97
api/services/rich-content-service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { RichContent } from "../model/interfaces.ts";
|
||||
import { youtubeProvider } from "./providers/youtube.ts";
|
||||
import { bandcampProvider } from "./providers/bandcamp.ts";
|
||||
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
||||
import { genericProvider } from "./providers/generic.ts";
|
||||
|
||||
export interface RichContentProvider {
|
||||
name: string;
|
||||
matches(url: string): boolean;
|
||||
fetch(url: string): Promise<RichContent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register providers in priority order. The first match wins.
|
||||
* `genericProvider` must stay last — it always matches.
|
||||
*/
|
||||
const providers: RichContentProvider[] = [
|
||||
youtubeProvider,
|
||||
bandcampProvider,
|
||||
soundcloudProvider,
|
||||
genericProvider,
|
||||
];
|
||||
|
||||
// Shared utilities exported for use by providers
|
||||
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(str: string): string {
|
||||
return str
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)));
|
||||
}
|
||||
|
||||
export function extractOgTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
): string | undefined {
|
||||
const patterns = [
|
||||
new RegExp(
|
||||
`<meta[^>]+property=["']og:${tag}["'][^>]+content=["']([^"']+)["']`,
|
||||
"i",
|
||||
),
|
||||
new RegExp(
|
||||
`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:${tag}["']`,
|
||||
"i",
|
||||
),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match) return decodeHtmlEntities(match[1]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isValidHttpUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRichContent(
|
||||
url: string,
|
||||
): Promise<RichContent | undefined> {
|
||||
try {
|
||||
const provider = providers.find((p) => p.matches(url))!;
|
||||
return await provider.fetch(url);
|
||||
} catch (err) {
|
||||
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export async function createUser(
|
||||
|
||||
export function getUserById(userId: string): User {
|
||||
const userRow = db.prepare(
|
||||
`SELECT id, username, password_hash, is_admin, created_at
|
||||
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
|
||||
FROM users WHERE id = ?`,
|
||||
).get(userId);
|
||||
|
||||
@@ -64,7 +64,7 @@ export function getUserById(userId: string): User {
|
||||
|
||||
export function getUserByUsername(username: string): User {
|
||||
const userRow = db.prepare(
|
||||
`SELECT id, username, password_hash, is_admin, created_at
|
||||
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
|
||||
FROM users WHERE username = ?`,
|
||||
).get(username);
|
||||
|
||||
@@ -77,7 +77,7 @@ export function getUserByUsername(username: string): User {
|
||||
|
||||
export function listUsers(): User[] {
|
||||
const userRows = db.prepare(
|
||||
`SELECT id, username, password_hash, is_admin, created_at FROM users`,
|
||||
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users`,
|
||||
).all();
|
||||
|
||||
if (!userRows || !userRows.every(isUserRow)) {
|
||||
@@ -119,6 +119,16 @@ export async function updateUser(
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
export function updateUserAvatar(userId: string, mime: string): void {
|
||||
const result = db.prepare(
|
||||
`UPDATE users SET avatar_mime = ? WHERE id = ?`,
|
||||
).run(mime, userId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteUser(userId: string): void {
|
||||
const result = db.prepare(
|
||||
`DELETE FROM users WHERE id = ?;`,
|
||||
|
||||
57
api/services/vote-service.ts
Normal file
57
api/services/vote-service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { db } from "../model/db.ts";
|
||||
|
||||
export function castVote(dumpId: string, userId: string): number {
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
db.prepare(
|
||||
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(dumpId, userId, new Date().toISOString());
|
||||
db.prepare(
|
||||
`UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`,
|
||||
).run(dumpId);
|
||||
const row = db.prepare(
|
||||
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as { vote_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return row?.vote_count ?? 0;
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK;");
|
||||
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 409, "Already voted");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeVote(dumpId: string, userId: string): number {
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
const result = db.prepare(
|
||||
`DELETE FROM votes WHERE dump_id = ? AND user_id = ?;`,
|
||||
).run(dumpId, userId);
|
||||
if (result.changes === 0) {
|
||||
db.exec("ROLLBACK;");
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Vote not found");
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
|
||||
).run(dumpId);
|
||||
const row = db.prepare(
|
||||
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as { vote_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return row?.vote_count ?? 0;
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
db.exec("ROLLBACK;");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserVotes(userId: string): string[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT dump_id FROM votes WHERE user_id = ?;`,
|
||||
).all(userId) as { dump_id: string }[];
|
||||
return rows.map((r) => r.dump_id);
|
||||
}
|
||||
87
api/services/ws-service.ts
Normal file
87
api/services/ws-service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Dump, OnlineUser } from "../model/interfaces.ts";
|
||||
|
||||
export interface WsClient {
|
||||
socket: WebSocket;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
avatarMime?: string;
|
||||
}
|
||||
|
||||
const clients = new Set<WsClient>();
|
||||
|
||||
export function register(client: WsClient): void {
|
||||
clients.add(client);
|
||||
}
|
||||
|
||||
export function unregister(client: WsClient): void {
|
||||
clients.delete(client);
|
||||
}
|
||||
|
||||
export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId) {
|
||||
client.avatarMime = avatarMime;
|
||||
}
|
||||
}
|
||||
broadcastPresence();
|
||||
}
|
||||
|
||||
export function getOnlineUsers(): OnlineUser[] {
|
||||
const seen = new Map<string, OnlineUser>();
|
||||
for (const client of clients) {
|
||||
if (client.userId && !seen.has(client.userId)) {
|
||||
seen.set(client.userId, {
|
||||
userId: client.userId,
|
||||
username: client.username!,
|
||||
hasAvatar: !!client.avatarMime,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
function send(socket: WebSocket, data: unknown): void {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastPresence(): void {
|
||||
const users = getOnlineUsers();
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "presence_update", users });
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastNewDump(dump: Dump): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "dump_created", dump });
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastDumpDeleted(dumpId: string): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "dump_deleted", dumpId });
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastVoteUpdate(dumpId: string, voteCount: number): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "votes_update", dumpId, voteCount });
|
||||
}
|
||||
}
|
||||
|
||||
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
||||
const PING_INTERVAL = 30_000;
|
||||
const PONG_TIMEOUT = 5_000;
|
||||
|
||||
setInterval(() => {
|
||||
for (const client of clients) {
|
||||
if (client.socket.readyState !== WebSocket.OPEN) {
|
||||
clients.delete(client);
|
||||
continue;
|
||||
}
|
||||
send(client.socket, { type: "ping" });
|
||||
// Schedule removal if no pong (tracked via heartbeat flag)
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
Reference in New Issue
Block a user