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

110
api/routes/avatars.ts Normal file
View 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;

View File

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

View File

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