vibe coded v1

This commit is contained in:
khannurien
2026-03-16 07:34:49 +00:00
parent 6207a7549f
commit e88fed4e98
48 changed files with 4303 additions and 595 deletions

View File

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

View 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"),
};
},
};

View 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"),
};
},
};

View 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"),
};
},
};

View 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 };
},
};

View 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(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&apos;/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;
}
}

View File

@@ -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 = ?;`,

View 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);
}

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