v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,10 @@ import { getPlaylistsForDump } from "../services/playlist-service.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/dumps" });
|
||||
|
||||
// Every route here takes a slug or an id; the services below them key on the
|
||||
// id, so mutations always pass `dump.id` from the resolved dump, never the raw
|
||||
// parameter.
|
||||
|
||||
// Categories arrive on the multipart create path as a JSON-encoded string field.
|
||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||
if (typeof value !== "string" || !value) return [];
|
||||
@@ -193,7 +197,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
dumpId,
|
||||
dump.id,
|
||||
file,
|
||||
typeof comment === "string" && comment ? comment : undefined,
|
||||
typeof title === "string" && title ? title : undefined,
|
||||
@@ -228,7 +232,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
const updatedDump = await updateDump(dumpId, body);
|
||||
const updatedDump = await updateDump(dump.id, body);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
@@ -306,7 +310,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
const updatedDump = await refreshDumpMetadata(dumpId);
|
||||
const updatedDump = await refreshDumpMetadata(dump.id);
|
||||
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
@@ -324,7 +328,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
await deleteDump(dumpId);
|
||||
await deleteDump(dump.id);
|
||||
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import { DUMPS_DIR } from "../lib/upload.ts";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/files" });
|
||||
|
||||
@@ -14,15 +14,31 @@ const router = new Router({ prefix: "/api/files" });
|
||||
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
|
||||
* full UTF-8 name for modern browsers.
|
||||
*/
|
||||
function contentDisposition(name: string): string {
|
||||
function contentDisposition(
|
||||
type: "inline" | "attachment",
|
||||
name: string,
|
||||
): string {
|
||||
const asciiFallback = name
|
||||
.replace(/["\\]/g, "_")
|
||||
.replace(/[^\x20-\x7e]/g, "_");
|
||||
return `inline; filename="${asciiFallback}"; filename*=UTF-8''${
|
||||
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${
|
||||
encodeURIComponent(name)
|
||||
}`;
|
||||
}
|
||||
|
||||
// The MIME type of an upload is whatever the uploader's browser claimed, and
|
||||
// these responses come from the app's own origin — where the session token
|
||||
// lives. Only types a browser renders without running the file's own code are
|
||||
// served inline; anything else (text/html, image/svg+xml, XML, …) downloads.
|
||||
// <img>, <audio> and fetch() ignore Content-Disposition, so previews that embed
|
||||
// or read the file still work.
|
||||
const INLINE_MIME_RE =
|
||||
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
|
||||
|
||||
function isInlineSafe(mime: string): boolean {
|
||||
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
|
||||
}
|
||||
|
||||
router.get("/:dumpId", async (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
|
||||
@@ -35,8 +51,8 @@ router.get("/:dumpId", async (ctx) => {
|
||||
// arrives as a query param (same as the WebSocket route). Needed to authorize
|
||||
// access to private dumps the requester owns.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const payload = token ? await verifyJWT(token) : null;
|
||||
const dump = getDump(dumpId, payload?.userId);
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(
|
||||
@@ -56,11 +72,27 @@ router.get("/:dumpId", async (ctx) => {
|
||||
}
|
||||
|
||||
const total = data.byteLength;
|
||||
const inline = isInlineSafe(dump.fileMime);
|
||||
ctx.response.headers.set("Content-Type", dump.fileMime);
|
||||
ctx.response.headers.set(
|
||||
"Content-Disposition",
|
||||
contentDisposition(dump.fileName),
|
||||
contentDisposition(inline ? "inline" : "attachment", dump.fileName),
|
||||
);
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
// Belt and braces for a file opened directly: no scripts, no plugins, no
|
||||
// same-origin access. Chrome won't render a PDF in a sandboxed document, and
|
||||
// its viewer never runs a PDF's scripts anyway, so PDFs go without.
|
||||
if (dump.fileMime !== "application/pdf") {
|
||||
ctx.response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
|
||||
);
|
||||
}
|
||||
// The URL may carry a media token; don't pass it on to wherever the file
|
||||
// links.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
// A private dump's bytes must not sit in a shared cache.
|
||||
if (dump.isPrivate) ctx.response.headers.set("Cache-Control", "private");
|
||||
ctx.response.headers.set("Accept-Ranges", "bytes");
|
||||
|
||||
const rangeHeader = ctx.request.headers.get("Range");
|
||||
|
||||
@@ -5,10 +5,24 @@ import {
|
||||
tryFetchRichContent,
|
||||
} from "../services/rich-content-service.ts";
|
||||
import { APIErrorCode } from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
const previewRouter = new Router();
|
||||
|
||||
previewRouter.get("/api/preview", async (ctx) => {
|
||||
const PROXYABLE_IMAGE_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/bmp",
|
||||
"image/x-icon",
|
||||
"image/vnd.microsoft.icon",
|
||||
]);
|
||||
|
||||
// Signed-in only: it's the create form's preview, and an open one lets anyone
|
||||
// use the server to fetch pages for them.
|
||||
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
ctx.response.status = 400;
|
||||
@@ -42,8 +56,13 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, 8000);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
// Raster formats only. The bytes go out under this site's origin, so an
|
||||
// SVG (which can carry script) or anything else a browser might execute
|
||||
// would run with the app's origin if opened directly.
|
||||
const contentType = (res.headers.get("content-type") ?? "")
|
||||
.split(";")[0].trim().toLowerCase();
|
||||
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
|
||||
await res.body?.cancel();
|
||||
ctx.response.status = 400;
|
||||
return;
|
||||
}
|
||||
@@ -51,6 +70,7 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const contentLength = Number(res.headers.get("content-length") ?? "0");
|
||||
if (contentLength > MAX_SIZE) {
|
||||
await res.body?.cancel();
|
||||
ctx.response.status = 400;
|
||||
return;
|
||||
}
|
||||
@@ -62,6 +82,11 @@ previewRouter.get("/api/proxy-image", async (ctx) => {
|
||||
}
|
||||
|
||||
ctx.response.headers.set("Content-Type", contentType);
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
ctx.response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"sandbox; default-src 'none'",
|
||||
);
|
||||
ctx.response.headers.set("Cache-Control", "public, max-age=86400");
|
||||
ctx.response.body = bytes;
|
||||
} catch {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts";
|
||||
import { serveUploadedFile } from "../lib/upload.ts";
|
||||
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import { authenticateMedia } from "../lib/auth.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/thumbnails" });
|
||||
|
||||
@@ -28,6 +28,14 @@ async function generateThumbnail(
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
const cmd = new Deno.Command("ffmpeg", {
|
||||
args: [
|
||||
// The upload's type is only what the uploader claimed, and ffmpeg probes
|
||||
// the real format from the bytes — a "video" could be an HLS or concat
|
||||
// playlist pointing ffmpeg at local files or internal URLs. Read nothing
|
||||
// but the file itself, and only as an actual video container.
|
||||
"-protocol_whitelist",
|
||||
"file",
|
||||
"-format_whitelist",
|
||||
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-i",
|
||||
@@ -58,8 +66,14 @@ router.get("/:dumpId", async (ctx) => {
|
||||
// Token via query param (media elements can't send headers) — authorizes the
|
||||
// owner's private dumps and gates the custom-thumbnail branch below too.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const payload = token ? await verifyJWT(token) : null;
|
||||
const dump = getDump(dumpId, payload?.userId);
|
||||
const viewerId = token ? await authenticateMedia(token) : null;
|
||||
const dump = getDump(dumpId, viewerId ?? undefined);
|
||||
// The URL may carry a media token; keep it out of anyone else's logs.
|
||||
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
||||
// A private dump's thumbnail must not sit in a shared cache.
|
||||
const cacheControl = dump.isPrivate
|
||||
? "private, max-age=31536000, immutable"
|
||||
: "public, max-age=31536000, immutable";
|
||||
|
||||
const customThumb = getDumpThumbnailInfo(dumpId);
|
||||
if (customThumb) {
|
||||
@@ -67,6 +81,8 @@ router.get("/:dumpId", async (ctx) => {
|
||||
ctx,
|
||||
`${THUMBNAILS_DIR}/${customThumb.id}-custom`,
|
||||
customThumb.mime,
|
||||
3600,
|
||||
dump.isPrivate,
|
||||
);
|
||||
if (served) return;
|
||||
}
|
||||
@@ -85,10 +101,7 @@ router.get("/:dumpId", async (ctx) => {
|
||||
try {
|
||||
const data = await Deno.readFile(thumbPath);
|
||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||
ctx.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
ctx.response.headers.set("Cache-Control", cacheControl);
|
||||
ctx.response.body = data;
|
||||
return;
|
||||
} catch {
|
||||
@@ -116,10 +129,7 @@ router.get("/:dumpId", async (ctx) => {
|
||||
|
||||
const data = await Deno.readFile(thumbPath);
|
||||
ctx.response.headers.set("Content-Type", "image/jpeg");
|
||||
ctx.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
ctx.response.headers.set("Cache-Control", cacheControl);
|
||||
ctx.response.body = data;
|
||||
});
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
isRole,
|
||||
isUpdateUserRequest,
|
||||
type PaginatedData,
|
||||
type User,
|
||||
validateRegisterUserRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { createJWT, verifyPassword } from "../lib/jwt.ts";
|
||||
import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
import {
|
||||
type AuthContext,
|
||||
authMiddleware,
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
searchUsers,
|
||||
updateUser,
|
||||
} from "../services/user-service.ts";
|
||||
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
||||
import { claimInvite, releaseInvite } from "../services/invite-service.ts";
|
||||
import {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
@@ -53,6 +55,22 @@ import {
|
||||
// Users router
|
||||
const router = new Router({ prefix: "/api/users" });
|
||||
|
||||
// A burst of 10 attempts, then one every 30s.
|
||||
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||
|
||||
// The session token, plus the media-only token that goes in file and
|
||||
// thumbnail URLs (see createMediaToken).
|
||||
async function issueTokens(user: User) {
|
||||
return {
|
||||
token: await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}),
|
||||
mediaToken: await createMediaToken(user.id),
|
||||
};
|
||||
}
|
||||
|
||||
// Register a new user (requires a valid invite token)
|
||||
router.post("/register", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
@@ -62,16 +80,16 @@ router.post("/register", async (ctx) => {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
|
||||
}
|
||||
|
||||
// Validate invite — throws 404/409 if bad
|
||||
const inviterId = validateInvite(body.inviteToken);
|
||||
// Claim the invite up front (throws 404/409 if bad) and hand it back if the
|
||||
// account can't be created, so one invite can't open two accounts.
|
||||
const inviterId = claimInvite(body.inviteToken);
|
||||
|
||||
const user = await createUser(body, inviterId);
|
||||
|
||||
// Mark invite as used only after the user row is committed
|
||||
let user;
|
||||
try {
|
||||
redeemInvite(body.inviteToken);
|
||||
user = await createUser(body, inviterId);
|
||||
} catch (err) {
|
||||
console.error("[register] redeemInvite failed (user created):", err);
|
||||
releaseInvite(body.inviteToken);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send welcome email (fire-and-forget)
|
||||
@@ -88,63 +106,62 @@ router.post("/register", async (ctx) => {
|
||||
}).catch((err) => console.error("[register] welcome email failed:", err));
|
||||
}
|
||||
|
||||
const authToken = await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { token: authToken, user: publicUser },
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
};
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post("/login", async (ctx) => {
|
||||
try {
|
||||
const body = await ctx.request.body.json();
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isLoginUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
const user = getUserByUsername(body.username);
|
||||
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||
|
||||
if (!valid) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Invalid username or password",
|
||||
);
|
||||
}
|
||||
|
||||
const token = await createJWT({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
user: publicUser,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login");
|
||||
if (!isLoginUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
// Per account, so guessing one user's password is slow however many
|
||||
// connections it comes from.
|
||||
if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
429,
|
||||
"Too many login attempts — try again in a minute",
|
||||
);
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = getUserByUsername(body.username);
|
||||
} catch {
|
||||
// Same answer as a wrong password: don't confirm which usernames exist.
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Invalid username or password",
|
||||
);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(body.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
401,
|
||||
"Invalid username or password",
|
||||
);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
};
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
@@ -177,6 +194,14 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
}
|
||||
});
|
||||
|
||||
// A media token for a session that predates them (see createMediaToken).
|
||||
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
|
||||
};
|
||||
});
|
||||
|
||||
// Request a password reset email (unauthenticated; always returns 200)
|
||||
router.post("/request-password-reset", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
@@ -241,8 +266,12 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
);
|
||||
}
|
||||
|
||||
await updateUser(ctx.state.user.userId, { password: newPassword });
|
||||
ctx.response.body = { success: true };
|
||||
// Changing the password revokes every token issued before it, this
|
||||
// session's included, so hand back fresh ones for the client to keep.
|
||||
const updated = await updateUser(ctx.state.user.userId, {
|
||||
password: newPassword,
|
||||
});
|
||||
ctx.response.body = { success: true, data: await issueTokens(updated) };
|
||||
});
|
||||
|
||||
// Update current user profile (description, etc.)
|
||||
@@ -255,10 +284,14 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
// Self-service profile updates must never set the role. Role assignment is
|
||||
// done out-of-band (DB/seed); stripping it here prevents self-promotion.
|
||||
const { role: _role, ...safeBody } = body;
|
||||
const updated = await updateUser(ctx.state.user.userId, safeBody);
|
||||
// Only the profile fields. The role is assigned by an admin, the password
|
||||
// changes through /me/change-password (which asks for the current one), and
|
||||
// the username is fixed — a token alone must not be able to change any of
|
||||
// them.
|
||||
const updated = await updateUser(ctx.state.user.userId, {
|
||||
description: body.description,
|
||||
email: body.email,
|
||||
});
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
broadcastUserUpdated(publicUser);
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
|
||||
215
api/routes/ws.ts
215
api/routes/ws.ts
@@ -1,6 +1,6 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { ALLOWED_ORIGINS } from "../config.ts";
|
||||
import { verifyJWT } from "../lib/jwt.ts";
|
||||
import { authenticate } from "../lib/auth.ts";
|
||||
import {
|
||||
broadcastChatMessage,
|
||||
broadcastChatMessageDeleted,
|
||||
@@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts";
|
||||
import {
|
||||
APIException,
|
||||
type ClientToServerMessage,
|
||||
type User,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
const router = new Router();
|
||||
@@ -64,19 +65,24 @@ router.get("/ws", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// authenticate() refuses a token whose user is gone or which predates a
|
||||
// password change, and reads the live role from the DB. Such a socket is
|
||||
// simply anonymous; the client's next API call gets the 401.
|
||||
const token = ctx.request.url.searchParams.get("token");
|
||||
const authPayload = token ? await verifyJWT(token) : null;
|
||||
const authPayload = token ? await authenticate(token) : null;
|
||||
let user: User | undefined;
|
||||
try {
|
||||
user = authPayload ? getUserById(authPayload.userId) : undefined;
|
||||
} catch {
|
||||
user = undefined;
|
||||
}
|
||||
|
||||
const socket = ctx.upgrade();
|
||||
|
||||
// Read the live user so the role reflects the DB, not the (possibly stale)
|
||||
// role embedded in the 7-day JWT.
|
||||
const user = authPayload ? getUserById(authPayload.userId) : undefined;
|
||||
|
||||
const client: WsClient = {
|
||||
socket,
|
||||
userId: authPayload?.userId,
|
||||
username: authPayload?.username,
|
||||
userId: user?.id,
|
||||
username: user?.username,
|
||||
role: user?.role,
|
||||
avatarMime: user?.avatarMime,
|
||||
};
|
||||
@@ -87,16 +93,10 @@ router.get("/ws", async (ctx) => {
|
||||
broadcastPresence();
|
||||
|
||||
try {
|
||||
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
||||
const myCommentLikes = authPayload
|
||||
? getUserCommentLikes(authPayload.userId)
|
||||
: [];
|
||||
const unreadNotificationCount = authPayload
|
||||
? getUnreadCount(authPayload.userId)
|
||||
: 0;
|
||||
const chatRead = authPayload
|
||||
? getChatReadState(authPayload.userId)
|
||||
: undefined;
|
||||
const myVotes = user ? getUserVotes(user.id) : [];
|
||||
const myCommentLikes = user ? getUserCommentLikes(user.id) : [];
|
||||
const unreadNotificationCount = user ? getUnreadCount(user.id) : 0;
|
||||
const chatRead = user ? getChatReadState(user.id) : undefined;
|
||||
socket.send(JSON.stringify({
|
||||
type: "welcome",
|
||||
users: getOnlineUsers(),
|
||||
@@ -112,56 +112,12 @@ router.get("/ws", async (ctx) => {
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
let msg: ClientToServerMessage;
|
||||
// An exception escaping a WebSocket listener is uncaught in Deno and ends
|
||||
// the process, so nothing a client sends may throw out of here.
|
||||
try {
|
||||
msg = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
socket.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
case "pong":
|
||||
handleClientPong(client);
|
||||
break;
|
||||
case "vote_cast":
|
||||
handleVote(client, msg.dumpId, "cast");
|
||||
break;
|
||||
case "vote_remove":
|
||||
handleVote(client, msg.dumpId, "remove");
|
||||
break;
|
||||
case "comment_like_cast":
|
||||
handleCommentLike(client, msg.commentId, "cast");
|
||||
break;
|
||||
case "comment_like_remove":
|
||||
handleCommentLike(client, msg.commentId, "remove");
|
||||
break;
|
||||
case "chat_send":
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
) {
|
||||
setClientTyping(client, msg.typing === true);
|
||||
}
|
||||
break;
|
||||
case "chat_focus":
|
||||
client.chatOpen = msg.open === true;
|
||||
break;
|
||||
case "chat_edit":
|
||||
handleChatEdit(client, msg.id, msg.body);
|
||||
break;
|
||||
case "chat_delete":
|
||||
handleChatDelete(client, msg.id);
|
||||
break;
|
||||
case "chat_read":
|
||||
handleChatRead(client, msg.at);
|
||||
break;
|
||||
handleMessage(client, event.data);
|
||||
} catch (err) {
|
||||
console.error("[ws] message handler failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -173,70 +129,150 @@ router.get("/ws", async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
function handleMessage(client: WsClient, data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Anything but an object with a string `type` (`null`, a number, an array)
|
||||
// is ignored rather than read from.
|
||||
if (
|
||||
typeof parsed !== "object" || parsed === null ||
|
||||
typeof (parsed as { type?: unknown }).type !== "string"
|
||||
) return;
|
||||
const msg = parsed as ClientToServerMessage;
|
||||
const { socket } = client;
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
send(socket, { type: "pong" });
|
||||
break;
|
||||
case "pong":
|
||||
handleClientPong(client);
|
||||
break;
|
||||
case "vote_cast":
|
||||
handleVote(client, msg.dumpId, "cast");
|
||||
break;
|
||||
case "vote_remove":
|
||||
handleVote(client, msg.dumpId, "remove");
|
||||
break;
|
||||
case "comment_like_cast":
|
||||
handleCommentLike(client, msg.commentId, "cast");
|
||||
break;
|
||||
case "comment_like_remove":
|
||||
handleCommentLike(client, msg.commentId, "remove");
|
||||
break;
|
||||
case "chat_send":
|
||||
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
|
||||
break;
|
||||
case "chat_typing":
|
||||
// Typing is cosmetic: past the limit it's just dropped.
|
||||
if (
|
||||
client.userId &&
|
||||
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
|
||||
) {
|
||||
setClientTyping(client, msg.typing === true);
|
||||
}
|
||||
break;
|
||||
case "chat_focus":
|
||||
client.chatOpen = msg.open === true;
|
||||
break;
|
||||
case "chat_edit":
|
||||
handleChatEdit(client, msg.id, msg.body);
|
||||
break;
|
||||
case "chat_delete":
|
||||
handleChatDelete(client, msg.id);
|
||||
break;
|
||||
case "chat_read":
|
||||
handleChatRead(client, msg.at);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sends only while the socket is open: `send` on a closing socket throws.
|
||||
function send(socket: WebSocket, data: Record<string, unknown>): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function handleVote(
|
||||
client: WsClient,
|
||||
dumpId: string,
|
||||
dumpId: unknown,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
send(socket, { type: "error", message: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
if (typeof dumpId !== "string") return;
|
||||
|
||||
try {
|
||||
const newCount = action === "cast"
|
||||
const { voteCount, isPrivate } = action === "cast"
|
||||
? castVote(dumpId, client.userId)
|
||||
: removeVote(dumpId, client.userId);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
send(socket, {
|
||||
type: "vote_ack",
|
||||
dumpId,
|
||||
action,
|
||||
success: true,
|
||||
voteCount: newCount,
|
||||
}));
|
||||
voteCount,
|
||||
});
|
||||
|
||||
broadcastVoteUpdate(dumpId, newCount, client.userId, action);
|
||||
// Only a private dump's owner can vote on it, so they're its whole
|
||||
// audience.
|
||||
broadcastVoteUpdate(
|
||||
dumpId,
|
||||
voteCount,
|
||||
client.userId,
|
||||
action,
|
||||
isPrivate ? client.userId : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException ? err.message : "Vote failed";
|
||||
socket.send(JSON.stringify({ type: "error", message, dumpId, action }));
|
||||
send(socket, { type: "error", message, dumpId, action });
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentLike(
|
||||
client: WsClient,
|
||||
commentId: string,
|
||||
commentId: unknown,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
send(socket, { type: "error", message: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
if (typeof commentId !== "string") return;
|
||||
|
||||
try {
|
||||
const newCount = action === "cast"
|
||||
const { likeCount, isPrivate } = action === "cast"
|
||||
? castCommentLike(commentId, client.userId)
|
||||
: removeCommentLike(commentId, client.userId);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
send(socket, {
|
||||
type: "comment_like_ack",
|
||||
commentId,
|
||||
action,
|
||||
likeCount: newCount,
|
||||
}));
|
||||
likeCount,
|
||||
});
|
||||
|
||||
broadcastCommentLikeUpdate(commentId, newCount, client.userId, action);
|
||||
broadcastCommentLikeUpdate(
|
||||
commentId,
|
||||
likeCount,
|
||||
client.userId,
|
||||
action,
|
||||
isPrivate ? client.userId : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException ? err.message : "Like failed";
|
||||
socket.send(JSON.stringify({ type: "error", message, commentId, action }));
|
||||
send(socket, { type: "error", message, commentId, action });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,10 +289,7 @@ function sendChatError(
|
||||
message: string,
|
||||
nonce?: string,
|
||||
): void {
|
||||
if (client.socket.readyState !== WebSocket.OPEN) return;
|
||||
client.socket.send(
|
||||
JSON.stringify({ type: "error", scope: "chat", message, nonce }),
|
||||
);
|
||||
send(client.socket, { type: "error", scope: "chat", message, nonce });
|
||||
}
|
||||
|
||||
// Validates the common preconditions of a chat write: signed in and under the
|
||||
|
||||
Reference in New Issue
Block a user