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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user