vibe coded v1
This commit is contained in:
20
api/main.ts
20
api/main.ts
@@ -2,7 +2,11 @@ import { Application } from "@oak/oak";
|
||||
import { oakCors } from "@tajpouria/cors";
|
||||
|
||||
import dumpsRouter from "./routes/dumps.ts";
|
||||
import filesRouter from "./routes/files.ts";
|
||||
import usersRouter from "./routes/users.ts";
|
||||
import avatarsRouter from "./routes/avatars.ts";
|
||||
import wsRouter from "./routes/ws.ts";
|
||||
import previewRouter from "./routes/preview.ts";
|
||||
|
||||
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
||||
import { errorMiddleware } from "./middleware/error.ts";
|
||||
@@ -16,10 +20,26 @@ app.use(
|
||||
dumpsRouter.routes(),
|
||||
dumpsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
filesRouter.routes(),
|
||||
filesRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
usersRouter.routes(),
|
||||
usersRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
avatarsRouter.routes(),
|
||||
avatarsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
wsRouter.routes(),
|
||||
wsRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
previewRouter.routes(),
|
||||
previewRouter.allowedMethods(),
|
||||
);
|
||||
app.use(routeStaticFilesFrom([
|
||||
`${Deno.cwd()}/dist`,
|
||||
`${Deno.cwd()}/public`,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||
import { Dump, type User } from "./interfaces.ts";
|
||||
import { Dump, type RichContent, type User } from "./interfaces.ts";
|
||||
|
||||
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
/**
|
||||
* Database Row Types
|
||||
@@ -9,10 +11,17 @@ export const db = new DatabaseSync("api/sql/gerbeur.db");
|
||||
|
||||
export interface DumpRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
comment: string | null;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
url: string | null;
|
||||
rich_content: string | null;
|
||||
file_name: string | null;
|
||||
file_mime: string | null;
|
||||
file_size: number | null;
|
||||
vote_count: number;
|
||||
[key: string]: SQLOutputValue; // Index signature
|
||||
}
|
||||
|
||||
@@ -22,6 +31,7 @@ export interface UserRow {
|
||||
password_hash: string;
|
||||
is_admin: number;
|
||||
created_at: string;
|
||||
avatar_mime: string | null;
|
||||
[key: string]: SQLOutputValue; // Index signature
|
||||
}
|
||||
|
||||
@@ -33,11 +43,22 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
"kind" in obj && typeof obj.kind === "string" &&
|
||||
"title" in obj && typeof obj.title === "string" &&
|
||||
"description" in obj &&
|
||||
(typeof obj.description === "string" || obj.description === null) &&
|
||||
"comment" in obj &&
|
||||
(typeof obj.comment === "string" || obj.comment === null) &&
|
||||
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||
"created_at" in obj && typeof obj.created_at === "string";
|
||||
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||
"url" in obj && (typeof obj.url === "string" || obj.url === null) &&
|
||||
"rich_content" in obj &&
|
||||
(typeof obj.rich_content === "string" || obj.rich_content === null) &&
|
||||
"file_name" in obj &&
|
||||
(typeof obj.file_name === "string" || obj.file_name === null) &&
|
||||
"file_mime" in obj &&
|
||||
(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";
|
||||
}
|
||||
|
||||
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
||||
@@ -47,32 +68,48 @@ export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
||||
"username" in obj && typeof obj.username === "string" &&
|
||||
"password_hash" in obj && typeof obj.password_hash === "string" &&
|
||||
"is_admin" in obj && typeof obj.is_admin === "number" &&
|
||||
"created_at" in obj && typeof obj.created_at === "string";
|
||||
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||
"avatar_mime" in obj &&
|
||||
(typeof obj.avatar_mime === "string" || obj.avatar_mime === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversion Helpers
|
||||
*/
|
||||
|
||||
export function dumpRowToApi(
|
||||
row: DumpRow,
|
||||
): Dump {
|
||||
export function dumpRowToApi(row: DumpRow): Dump {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as "url" | "file",
|
||||
title: row.title,
|
||||
description: row.description ?? undefined,
|
||||
comment: row.comment ?? undefined,
|
||||
userId: row.user_id,
|
||||
createdAt: new Date(row.created_at),
|
||||
url: row.url ?? undefined,
|
||||
richContent: row.rich_content
|
||||
? (JSON.parse(row.rich_content) as RichContent)
|
||||
: undefined,
|
||||
fileName: row.file_name ?? undefined,
|
||||
fileMime: row.file_mime ?? undefined,
|
||||
fileSize: row.file_size ?? undefined,
|
||||
voteCount: row.vote_count,
|
||||
};
|
||||
}
|
||||
|
||||
export function dumpApiToRow(dump: Dump): DumpRow {
|
||||
return {
|
||||
id: dump.id,
|
||||
kind: dump.kind,
|
||||
title: dump.title,
|
||||
description: dump.description ?? null,
|
||||
comment: dump.comment ?? null,
|
||||
user_id: dump.userId,
|
||||
created_at: dump.createdAt.toISOString(),
|
||||
url: dump.url ?? null,
|
||||
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
|
||||
file_name: dump.fileName ?? null,
|
||||
file_mime: dump.fileMime ?? null,
|
||||
file_size: dump.fileSize ?? null,
|
||||
vote_count: dump.voteCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +120,7 @@ export function userRowToApi(row: UserRow): User {
|
||||
passwordHash: row.password_hash,
|
||||
isAdmin: Boolean(row.is_admin),
|
||||
createdAt: new Date(row.created_at),
|
||||
avatarMime: row.avatar_mime ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,5 +131,6 @@ export function userApiToRow(user: User): UserRow {
|
||||
password_hash: user.passwordHash,
|
||||
is_admin: user.isAdmin ? 1 : 0,
|
||||
created_at: user.createdAt.toISOString(),
|
||||
avatar_mime: user.avatarMime ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,29 @@
|
||||
* Backend
|
||||
*/
|
||||
|
||||
export interface RichContent {
|
||||
type: string;
|
||||
url: string;
|
||||
siteName?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
thumbnailUrl?: string;
|
||||
videoId?: string;
|
||||
}
|
||||
|
||||
export interface Dump {
|
||||
id: string;
|
||||
kind: "url" | "file";
|
||||
title: string;
|
||||
description?: string;
|
||||
comment?: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
url?: string;
|
||||
richContent?: RichContent;
|
||||
fileName?: string;
|
||||
fileMime?: string;
|
||||
fileSize?: number;
|
||||
voteCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +37,7 @@ export interface User {
|
||||
passwordHash: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
avatarMime?: string;
|
||||
}
|
||||
|
||||
export interface LoginUserRequest {
|
||||
@@ -127,28 +145,94 @@ export class APIException extends Error {
|
||||
* Request DTOs
|
||||
*/
|
||||
|
||||
export interface CreateDumpRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export function isCreateDumpRequest(obj: unknown): obj is CreateDumpRequest {
|
||||
export function isCreateUrlDumpRequest(
|
||||
obj: unknown,
|
||||
): obj is CreateUrlDumpRequest {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
"title" in obj && typeof obj.title === "string" &&
|
||||
(!("description" in obj) ||
|
||||
(typeof obj.description === "string" || obj.description === null));
|
||||
"url" in obj && typeof obj.url === "string" &&
|
||||
(!("comment" in obj) ||
|
||||
typeof obj.comment === "string" || obj.comment === null);
|
||||
}
|
||||
|
||||
export interface UpdateDumpRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
||||
return !!obj &&
|
||||
typeof obj === "object" &&
|
||||
(!("title" in obj) || typeof obj.title === "string") &&
|
||||
(!("description" in obj) ||
|
||||
(typeof obj.description === "string" || obj.description === null));
|
||||
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) &&
|
||||
(!("comment" in obj) ||
|
||||
typeof obj.comment === "string" || obj.comment === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSockets
|
||||
*/
|
||||
|
||||
export interface VoteCastMessage {
|
||||
type: "vote_cast";
|
||||
dumpId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface VoteAckMessageFailure {
|
||||
type: "vote_ack";
|
||||
dumpId: string;
|
||||
success: false;
|
||||
error: APIError;
|
||||
}
|
||||
|
||||
export interface VoteAckMessageSuccess {
|
||||
type: "vote_ack";
|
||||
dumpId: string;
|
||||
action: "cast" | "remove";
|
||||
success: true;
|
||||
voteCount: number;
|
||||
error?: never;
|
||||
}
|
||||
|
||||
export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure;
|
||||
|
||||
export interface VoteRemoveMessage {
|
||||
type: "vote_remove";
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
export interface VotesUpdateMessage {
|
||||
type: "votes_update";
|
||||
dumpId: string;
|
||||
voteCount: number;
|
||||
}
|
||||
|
||||
export interface OnlineUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
hasAvatar: boolean;
|
||||
}
|
||||
|
||||
export interface WelcomeMessage {
|
||||
type: "welcome";
|
||||
users: OnlineUser[];
|
||||
myVotes: string[];
|
||||
}
|
||||
|
||||
export interface PresenceUpdateMessage {
|
||||
type: "presence_update";
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
export interface PingMessage {
|
||||
type: "ping";
|
||||
}
|
||||
|
||||
export interface PongMessage {
|
||||
type: "pong";
|
||||
}
|
||||
|
||||
110
api/routes/avatars.ts
Normal file
110
api/routes/avatars.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { getUserById, updateUserAvatar } from "../services/user-service.ts";
|
||||
import { updateClientAvatar } from "../services/ws-service.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
|
||||
const AVATARS_DIR = "api/uploads/avatars";
|
||||
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const ALLOWED_AVATAR_MIMES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
// Magic bytes for image validation
|
||||
const MAGIC: Array<{ mime: string; bytes: number[]; offset?: number }> = [
|
||||
{ mime: "image/jpeg", bytes: [0xFF, 0xD8, 0xFF] },
|
||||
{ mime: "image/png", bytes: [0x89, 0x50, 0x4E, 0x47] },
|
||||
{ mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] },
|
||||
{ mime: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF
|
||||
];
|
||||
|
||||
function checkMagicBytes(data: Uint8Array, mime: string): boolean {
|
||||
if (mime === "image/webp") {
|
||||
// RIFF....WEBP
|
||||
return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 &&
|
||||
data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 &&
|
||||
data[10] === 0x42 && data[11] === 0x50;
|
||||
}
|
||||
const entry = MAGIC.find((m) => m.mime === mime);
|
||||
if (!entry) return false;
|
||||
return entry.bytes.every((b, i) => data[i] === b);
|
||||
}
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
const authPayload = ctx.state.user;
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Expected multipart/form-data");
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.formData();
|
||||
const file = body.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
||||
}
|
||||
|
||||
if (!ALLOWED_AVATAR_MIMES.has(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Only JPEG, PNG, GIF, WebP images are allowed",
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_AVATAR_SIZE) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)");
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
if (!checkMagicBytes(data, file.type)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File content does not match declared type");
|
||||
}
|
||||
|
||||
await Deno.mkdir(AVATARS_DIR, { recursive: true });
|
||||
await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data);
|
||||
updateUserAvatar(authPayload.userId, file.type);
|
||||
updateClientAvatar(authPayload.userId, file.type);
|
||||
|
||||
const user = getUserById(authPayload.userId);
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = { success: true, data: user };
|
||||
});
|
||||
|
||||
router.get("/api/avatars/:userId", async (ctx) => {
|
||||
const { userId } = ctx.params;
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = getUserById(userId);
|
||||
} catch {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.avatarMime) {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
let data: Uint8Array;
|
||||
try {
|
||||
data = await Deno.readFile(`${AVATARS_DIR}/${userId}`);
|
||||
} catch {
|
||||
ctx.response.status = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.headers.set("Content-Type", user.avatarMime);
|
||||
ctx.response.headers.set("Content-Disposition", "inline");
|
||||
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
|
||||
ctx.response.body = data;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type Dump,
|
||||
isCreateDumpRequest,
|
||||
isCreateUrlDumpRequest,
|
||||
isUpdateDumpRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import {
|
||||
createDump,
|
||||
createFileDump,
|
||||
createUrlDump,
|
||||
deleteDump,
|
||||
getDump,
|
||||
listDumps,
|
||||
replaceFileDump,
|
||||
updateDump,
|
||||
} from "../services/dump-service.ts";
|
||||
|
||||
@@ -24,106 +26,119 @@ router.post(
|
||||
"/",
|
||||
authMiddleware,
|
||||
async (ctx) => {
|
||||
const createDumpRequest = await ctx.request.body.json();
|
||||
const userId = ctx.state.user.userId;
|
||||
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||
|
||||
if (!isCreateDumpRequest(createDumpRequest)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid dump data",
|
||||
let dump: Dump;
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
const comment = formData.get("comment");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"A file is required",
|
||||
);
|
||||
}
|
||||
|
||||
dump = await createFileDump(
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isCreateUrlDumpRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid dump data",
|
||||
);
|
||||
}
|
||||
|
||||
dump = await createUrlDump(body, userId);
|
||||
}
|
||||
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = createDump(createDumpRequest, userId);
|
||||
|
||||
const responseBody: APIResponse<Dump> = {
|
||||
success: true,
|
||||
data: dump,
|
||||
};
|
||||
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: dump };
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/:dumpId", (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
const responseBody: APIResponse<Dump> = {
|
||||
success: true,
|
||||
data: dump,
|
||||
};
|
||||
|
||||
const dump = getDump(ctx.params.dumpId);
|
||||
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 };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
const responseBody: APIResponse<Dump[]> = {
|
||||
success: true,
|
||||
data: dumps,
|
||||
};
|
||||
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const formData = await ctx.request.body.formData();
|
||||
const file = formData.get("file");
|
||||
const comment = formData.get("comment");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "A file is required");
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
dumpId,
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
const updateDumpRequest = await ctx.request.body.json();
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isUpdateDumpRequest(updateDumpRequest)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
422,
|
||||
"Erroneous user input",
|
||||
);
|
||||
if (!isUpdateDumpRequest(body)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 422, "Erroneous user input");
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump");
|
||||
}
|
||||
|
||||
const updatedDump = updateDump(dumpId, updateDumpRequest);
|
||||
|
||||
const responseBody: APIResponse<Dump> = {
|
||||
success: true,
|
||||
data: updatedDump,
|
||||
};
|
||||
|
||||
const updatedDump = await updateDump(dumpId, body);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.delete("/:dumpId", authMiddleware, (ctx) => {
|
||||
router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user?.userId;
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to update dump",
|
||||
);
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete dump");
|
||||
}
|
||||
|
||||
deleteDump(dumpId);
|
||||
|
||||
const responseBody: APIResponse<null> = {
|
||||
success: true,
|
||||
data: null,
|
||||
};
|
||||
await deleteDump(dumpId);
|
||||
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
|
||||
34
api/routes/files.ts
Normal file
34
api/routes/files.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/files" });
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
// Guard against path traversal (UUIDs are safe, but be explicit)
|
||||
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No file for this dump");
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await Deno.readFile(`api/uploads/${dumpId}`);
|
||||
ctx.response.headers.set("Content-Type", dump.fileMime);
|
||||
ctx.response.headers.set(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${dump.fileName}"`,
|
||||
);
|
||||
ctx.response.body = data;
|
||||
} catch {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
17
api/routes/preview.ts
Normal file
17
api/routes/preview.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { fetchRichContent, isValidHttpUrl } from "../services/rich-content-service.ts";
|
||||
|
||||
const previewRouter = new Router();
|
||||
|
||||
previewRouter.get("/api/preview", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { success: false, error: { message: "Invalid URL" } };
|
||||
return;
|
||||
}
|
||||
const data = await fetchRichContent(url);
|
||||
ctx.response.body = { success: true, data: data ?? null };
|
||||
});
|
||||
|
||||
export default previewRouter;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
} from "../services/user-service.ts";
|
||||
import { getDumpsByUser, getVotedDumpsByUser } from "../services/dump-service.ts";
|
||||
|
||||
// Users router
|
||||
const router = new Router({ prefix: "/api/users" });
|
||||
@@ -129,4 +130,32 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
|
||||
router.get("/by-id/:userId", (ctx) => {
|
||||
const user = getUserById(ctx.params.userId);
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
});
|
||||
|
||||
// Public user profile by username (no passwordHash)
|
||||
router.get("/:username", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
});
|
||||
|
||||
// Dumps posted by user
|
||||
router.get("/:username/dumps", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const dumps = getDumpsByUser(user.id);
|
||||
ctx.response.body = { success: true, data: dumps };
|
||||
});
|
||||
|
||||
// Dumps upvoted by user
|
||||
router.get("/:username/votes", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const dumps = getVotedDumpsByUser(user.id);
|
||||
ctx.response.body = { success: true, data: dumps };
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
132
api/routes/ws.ts
Normal file
132
api/routes/ws.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import {
|
||||
broadcastPresence,
|
||||
broadcastVoteUpdate,
|
||||
getOnlineUsers,
|
||||
register,
|
||||
type WsClient,
|
||||
unregister,
|
||||
} from "../services/ws-service.ts";
|
||||
import { castVote, getUserVotes, removeVote } from "../services/vote-service.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
if (!origin) return false;
|
||||
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
|
||||
}
|
||||
|
||||
router.get("/ws", async (ctx) => {
|
||||
const origin = ctx.request.headers.get("origin") ?? "";
|
||||
if (!isAllowedOrigin(origin)) {
|
||||
ctx.response.status = 403;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.isUpgradable) {
|
||||
ctx.response.status = 426;
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const authPayload = token ? await verifyJWT(token) : null;
|
||||
|
||||
const socket = ctx.upgrade();
|
||||
|
||||
let avatarMime: string | undefined;
|
||||
if (authPayload) {
|
||||
try { avatarMime = getUserById(authPayload.userId).avatarMime; } catch { /* user not found */ }
|
||||
}
|
||||
|
||||
const client: WsClient = {
|
||||
socket,
|
||||
userId: authPayload?.userId,
|
||||
username: authPayload?.username,
|
||||
avatarMime,
|
||||
};
|
||||
|
||||
// Use addEventListener — more reliable than onopen= with Deno.serve
|
||||
socket.addEventListener("open", () => {
|
||||
register(client);
|
||||
broadcastPresence();
|
||||
|
||||
try {
|
||||
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
||||
socket.send(JSON.stringify({
|
||||
type: "welcome",
|
||||
users: getOnlineUsers(),
|
||||
myVotes,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("[ws] welcome send failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
let msg: { type: string; dumpId?: string };
|
||||
try {
|
||||
msg = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
socket.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
case "vote_cast":
|
||||
handleVote(client, msg.dumpId, "cast");
|
||||
break;
|
||||
case "vote_remove":
|
||||
handleVote(client, msg.dumpId, "remove");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
unregister(client);
|
||||
broadcastPresence();
|
||||
});
|
||||
});
|
||||
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
dumpId: string | undefined,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(JSON.stringify({ type: "error", message: "Authentication required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dumpId) {
|
||||
socket.send(JSON.stringify({ type: "error", message: "Missing dumpId" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newCount = action === "cast"
|
||||
? castVote(dumpId, client.userId)
|
||||
: removeVote(dumpId, client.userId);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: "vote_ack",
|
||||
dumpId,
|
||||
action,
|
||||
success: true,
|
||||
voteCount: newCount,
|
||||
}));
|
||||
|
||||
broadcastVoteUpdate(dumpId, newCount);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException ? err.message : "Vote failed";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -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);
|
||||
@@ -1,11 +1,16 @@
|
||||
CREATE TABLE dumps (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -14,5 +19,15 @@ CREATE TABLE users (
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
avatar_mime TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE votes (
|
||||
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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user