v1 review pass: fixed some minor bugs
This commit is contained in:
@@ -3,7 +3,7 @@ import { jwtVerify, SignJWT } from "@panva/jose";
|
||||
|
||||
import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts";
|
||||
|
||||
const JWT_SECRET = "tp-M1-SOR-2026";
|
||||
const JWT_SECRET = "FIXME-gerbeur-dev-env";
|
||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
export async function createJWT(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||
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;");
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,11 @@ 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");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Expected multipart/form-data",
|
||||
);
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.formData();
|
||||
@@ -58,13 +62,21 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
}
|
||||
|
||||
if (file.size > MAX_AVATAR_SIZE) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)");
|
||||
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");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File content does not match declared type",
|
||||
);
|
||||
}
|
||||
|
||||
await Deno.mkdir(AVATARS_DIR, { recursive: true });
|
||||
|
||||
@@ -87,7 +87,11 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
|
||||
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 formData = await ctx.request.body.formData();
|
||||
@@ -95,7 +99,11 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const comment = formData.get("comment");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "A file is required");
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"A file is required",
|
||||
);
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
@@ -113,13 +121,21 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isUpdateDumpRequest(body)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 422, "Erroneous user input");
|
||||
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 = await updateDump(dumpId, body);
|
||||
@@ -133,7 +149,11 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete dump");
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to delete dump",
|
||||
);
|
||||
}
|
||||
|
||||
await deleteDump(dumpId);
|
||||
|
||||
@@ -15,20 +15,62 @@ router.get("/:dumpId", async (ctx) => {
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No file for this dump");
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No file for this dump",
|
||||
);
|
||||
}
|
||||
|
||||
const path = `api/uploads/${dumpId}`;
|
||||
|
||||
let data: Uint8Array;
|
||||
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;
|
||||
data = await Deno.readFile(path);
|
||||
} catch {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
||||
}
|
||||
|
||||
const total = data.byteLength;
|
||||
ctx.response.headers.set("Content-Type", dump.fileMime);
|
||||
ctx.response.headers.set(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${dump.fileName}"`,
|
||||
);
|
||||
ctx.response.headers.set("Accept-Ranges", "bytes");
|
||||
|
||||
const rangeHeader = ctx.request.headers.get("Range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) {
|
||||
ctx.response.status = 416;
|
||||
ctx.response.headers.set("Content-Range", `bytes */${total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = match[1]
|
||||
? parseInt(match[1], 10)
|
||||
: total - parseInt(match[2], 10);
|
||||
const end = match[2]
|
||||
? Math.min(parseInt(match[2], 10), total - 1)
|
||||
: total - 1;
|
||||
|
||||
if (start > end || start >= total) {
|
||||
ctx.response.status = 416;
|
||||
ctx.response.headers.set("Content-Range", `bytes */${total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = data.subarray(start, end + 1);
|
||||
ctx.response.status = 206;
|
||||
ctx.response.headers.set("Content-Range", `bytes ${start}-${end}/${total}`);
|
||||
ctx.response.headers.set("Content-Length", String(chunk.byteLength));
|
||||
ctx.response.body = chunk;
|
||||
} else {
|
||||
ctx.response.headers.set("Content-Length", String(total));
|
||||
ctx.response.body = data;
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { fetchRichContent, isValidHttpUrl } from "../services/rich-content-service.ts";
|
||||
import {
|
||||
fetchRichContent,
|
||||
isValidHttpUrl,
|
||||
} from "../services/rich-content-service.ts";
|
||||
|
||||
const previewRouter = new Router();
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
} from "../services/user-service.ts";
|
||||
import { getDumpsByUser, getVotedDumpsByUser } from "../services/dump-service.ts";
|
||||
import {
|
||||
getDumpsByUser,
|
||||
getVotedDumpsByUser,
|
||||
} from "../services/dump-service.ts";
|
||||
|
||||
// Users router
|
||||
const router = new Router({ prefix: "/api/users" });
|
||||
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
broadcastVoteUpdate,
|
||||
getOnlineUsers,
|
||||
register,
|
||||
type WsClient,
|
||||
unregister,
|
||||
type WsClient,
|
||||
} from "../services/ws-service.ts";
|
||||
import { castVote, getUserVotes, removeVote } from "../services/vote-service.ts";
|
||||
import {
|
||||
castVote,
|
||||
getUserVotes,
|
||||
removeVote,
|
||||
} from "../services/vote-service.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
|
||||
@@ -38,7 +42,9 @@ router.get("/ws", async (ctx) => {
|
||||
|
||||
let avatarMime: string | undefined;
|
||||
if (authPayload) {
|
||||
try { avatarMime = getUserById(authPayload.userId).avatarMime; } catch { /* user not found */ }
|
||||
try {
|
||||
avatarMime = getUserById(authPayload.userId).avatarMime;
|
||||
} catch { /* user not found */ }
|
||||
}
|
||||
|
||||
const client: WsClient = {
|
||||
@@ -100,7 +106,9 @@ function handleVote(
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(JSON.stringify({ type: "error", message: "Authentication required" }));
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,7 +130,7 @@ function handleVote(
|
||||
voteCount: newCount,
|
||||
}));
|
||||
|
||||
broadcastVoteUpdate(dumpId, newCount);
|
||||
broadcastVoteUpdate(dumpId, newCount, client.userId, action);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException ? err.message : "Vote failed";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
|
||||
@@ -69,7 +69,17 @@ export async function createUrlDump(
|
||||
richContent ? JSON.stringify(richContent) : null,
|
||||
);
|
||||
|
||||
const dump: Dump = { id: dumpId, kind: "url", title, comment: request.comment, userId, createdAt, url: request.url, richContent, voteCount: 0 };
|
||||
const dump: Dump = {
|
||||
id: dumpId,
|
||||
kind: "url",
|
||||
title,
|
||||
comment: request.comment,
|
||||
userId,
|
||||
createdAt,
|
||||
url: request.url,
|
||||
richContent,
|
||||
voteCount: 0,
|
||||
};
|
||||
broadcastNewDump(dump);
|
||||
return dump;
|
||||
}
|
||||
@@ -87,7 +97,11 @@ export async function createFileDump(
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File too large (max 50 MB)",
|
||||
);
|
||||
}
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
@@ -153,7 +167,11 @@ export function listDumps(): Dump[] {
|
||||
).all();
|
||||
|
||||
if (!rows || !rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
|
||||
return rows.map(dumpRowToApi);
|
||||
@@ -167,7 +185,12 @@ export async function updateDump(
|
||||
|
||||
// File dumps: only comment is editable
|
||||
if (dump.kind === "file") {
|
||||
const updatedDump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment };
|
||||
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;
|
||||
@@ -190,7 +213,9 @@ export async function updateDump(
|
||||
const updatedDump: Dump = {
|
||||
...dump,
|
||||
title,
|
||||
comment: "comment" in request ? (request.comment ?? undefined) : dump.comment,
|
||||
comment: "comment" in request
|
||||
? (request.comment ?? undefined)
|
||||
: dump.comment,
|
||||
url: newUrl,
|
||||
richContent,
|
||||
};
|
||||
@@ -213,10 +238,18 @@ export async function replaceFileDump(
|
||||
comment: string | undefined,
|
||||
): Promise<Dump> {
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, `File type '${file.type}' is not allowed`);
|
||||
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)");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File too large (max 50 MB)",
|
||||
);
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
@@ -231,7 +264,14 @@ export async function replaceFileDump(
|
||||
`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 };
|
||||
return {
|
||||
...dump,
|
||||
title: file.name,
|
||||
fileName: file.name,
|
||||
fileMime: file.type,
|
||||
fileSize: file.size,
|
||||
comment,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDumpsByUser(userId: string): Dump[] {
|
||||
@@ -239,7 +279,11 @@ export function getDumpsByUser(userId: string): Dump[] {
|
||||
`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");
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
}
|
||||
@@ -253,7 +297,11 @@ export function getVotedDumpsByUser(userId: string): Dump[] {
|
||||
ORDER BY v.created_at DESC;`,
|
||||
).all(userId);
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ export const youtubeProvider: RichContentProvider = {
|
||||
// oembed failed — thumbnail still works
|
||||
}
|
||||
|
||||
return { type: "youtube", siteName: "YouTube", url, videoId, title, thumbnailUrl };
|
||||
return {
|
||||
type: "youtube",
|
||||
siteName: "YouTube",
|
||||
url,
|
||||
videoId,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,8 +33,10 @@ export async function fetchWithTimeout(
|
||||
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",
|
||||
"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",
|
||||
},
|
||||
});
|
||||
@@ -51,7 +53,10 @@ function decodeHtmlEntities(str: string): string {
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)));
|
||||
.replace(
|
||||
/&#x([0-9a-f]+);/gi,
|
||||
(_, hex) => String.fromCodePoint(parseInt(hex, 16)),
|
||||
);
|
||||
}
|
||||
|
||||
export function extractOgTag(
|
||||
|
||||
@@ -18,7 +18,11 @@ export function castVote(dumpId: string, userId: string): number {
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK;");
|
||||
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 409, "Already voted");
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
"Already voted",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -65,15 +65,26 @@ export function broadcastDumpDeleted(dumpId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastVoteUpdate(dumpId: string, voteCount: number): void {
|
||||
export function broadcastVoteUpdate(
|
||||
dumpId: string,
|
||||
voteCount: number,
|
||||
voterId: string,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "votes_update", dumpId, voteCount });
|
||||
send(client.socket, {
|
||||
type: "votes_update",
|
||||
dumpId,
|
||||
voteCount,
|
||||
voterId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
||||
const PING_INTERVAL = 30_000;
|
||||
const PONG_TIMEOUT = 5_000;
|
||||
const _PONG_TIMEOUT = 5_000;
|
||||
|
||||
setInterval(() => {
|
||||
for (const client of clients) {
|
||||
|
||||
Reference in New Issue
Block a user