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:
@@ -252,8 +252,11 @@ export function markChatRead(userId: string, at: string): ChatReadState {
|
||||
const newest = db.prepare(
|
||||
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
|
||||
).get() as { newest: string | null };
|
||||
// No messages yet: there's nothing to have read, and no newest message to
|
||||
// cap a bogus far-future marker at — which could never be moved back.
|
||||
if (!newest.newest) return getChatReadState(userId);
|
||||
const iso = parsed.toISOString();
|
||||
const target = newest.newest && iso > newest.newest ? newest.newest : iso;
|
||||
const target = iso > newest.newest ? newest.newest : iso;
|
||||
db.prepare(
|
||||
`UPDATE users SET chat_read_at = ?
|
||||
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
|
||||
|
||||
@@ -2,7 +2,29 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||
import { notifyCommentOwnerLike } from "./notification-service.ts";
|
||||
|
||||
export function castCommentLike(commentId: string, userId: string): number {
|
||||
export interface LikeResult {
|
||||
likeCount: number;
|
||||
/** Likes on a private dump's comments go to the dump's owner only. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
// Visibility of the comment's dump, or a 404 when the user can't see it.
|
||||
function commentVisibility(commentId: string, userId: string): boolean {
|
||||
const row = db.prepare(
|
||||
`SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id
|
||||
WHERE c.id = ?;`,
|
||||
).get(commentId) as { is_private: number; user_id: string } | undefined;
|
||||
if (!row || (row.is_private && row.user_id !== userId)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||
}
|
||||
return Boolean(row.is_private);
|
||||
}
|
||||
|
||||
export function castCommentLike(
|
||||
commentId: string,
|
||||
userId: string,
|
||||
): LikeResult {
|
||||
const isPrivate = commentVisibility(commentId, userId);
|
||||
let likeCount: number;
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
@@ -34,10 +56,20 @@ export function castCommentLike(commentId: string, userId: string): number {
|
||||
try {
|
||||
notifyCommentOwnerLike(userId, commentId);
|
||||
} catch { /* ignore */ }
|
||||
return likeCount;
|
||||
return { likeCount, isPrivate };
|
||||
}
|
||||
|
||||
export function removeCommentLike(commentId: string, userId: string): number {
|
||||
// No visibility check: taking back a like is harmless, and it's the user's own
|
||||
// row.
|
||||
export function removeCommentLike(
|
||||
commentId: string,
|
||||
userId: string,
|
||||
): LikeResult {
|
||||
const dumpRow = db.prepare(
|
||||
`SELECT d.is_private FROM comments c JOIN dumps d ON d.id = c.dump_id
|
||||
WHERE c.id = ?;`,
|
||||
).get(commentId) as { is_private: number } | undefined;
|
||||
const isPrivate = Boolean(dumpRow?.is_private);
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
const result = db.prepare(
|
||||
@@ -54,7 +86,7 @@ export function removeCommentLike(commentId: string, userId: string): number {
|
||||
`SELECT like_count FROM comments WHERE id = ?;`,
|
||||
).get(commentId) as { like_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return row?.like_count ?? 0;
|
||||
return { likeCount: row?.like_count ?? 0, isPrivate };
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
db.exec("ROLLBACK;");
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
DUMP_MAX_FILE_SIZE_BYTES,
|
||||
DUMPS_DIR,
|
||||
THUMBNAILS_DIR,
|
||||
VALIDATION,
|
||||
} from "../config.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import { relinkBacklinks } from "./backlink-service.ts";
|
||||
@@ -46,6 +47,25 @@ function isAllowedMime(mime: string): boolean {
|
||||
DUMP_ALLOWED_MIME_TYPES.has(mime);
|
||||
}
|
||||
|
||||
// The JSON routes validate these in isCreateUrlDumpRequest/isUpdateDumpRequest;
|
||||
// the multipart ones (file create and replace) arrive here unchecked.
|
||||
function assertFileDumpText(title?: string, comment?: string): void {
|
||||
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Title must be at most ${VALIDATION.DUMP_TITLE_MAX} characters`,
|
||||
);
|
||||
}
|
||||
if (comment && comment.length > VALIDATION.DUMP_COMMENT_MAX) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
`Comment must be at most ${VALIDATION.DUMP_COMMENT_MAX} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
@@ -127,6 +147,7 @@ export async function createFileDump(
|
||||
title?: string,
|
||||
categoryIds: string[] = [],
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
@@ -489,6 +510,7 @@ export async function replaceFileDump(
|
||||
title?: string,
|
||||
categoryIds?: string[],
|
||||
): Promise<Dump> {
|
||||
assertFileDumpText(title, comment);
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
|
||||
@@ -60,10 +60,26 @@ export function validateInvite(token: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the token as used. Call this only after the user has been created.
|
||||
* Validates the token and marks it used in one step, returning the inviterId.
|
||||
* Claim before the (async) account creation, not after: checking first and
|
||||
* marking later lets parallel registrations all pass the check on one invite.
|
||||
*/
|
||||
export function redeemInvite(token: string): void {
|
||||
db.prepare(
|
||||
`UPDATE invites SET used_at = ? WHERE token = ?;`,
|
||||
export function claimInvite(token: string): string {
|
||||
const inviterId = validateInvite(token);
|
||||
const result = db.prepare(
|
||||
`UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
||||
).run(new Date().toISOString(), token);
|
||||
if (result.changes === 0) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
"Invite already used",
|
||||
);
|
||||
}
|
||||
return inviterId;
|
||||
}
|
||||
|
||||
/** Hands a claimed invite back when the account it was claimed for failed. */
|
||||
export function releaseInvite(token: string): void {
|
||||
db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
VALIDATION,
|
||||
} from "../config.ts";
|
||||
import { marked } from "marked";
|
||||
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
const RESET_TOKEN_TTL_HOURS = 1;
|
||||
|
||||
@@ -21,7 +22,12 @@ const RESET_TOKEN_TTL_HOURS = 1;
|
||||
* sends the reset link. Always resolves without throwing so callers can return
|
||||
* 200 unconditionally (no user enumeration).
|
||||
*/
|
||||
// Per address: enough to retry a lost email, not enough to flood an inbox.
|
||||
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
|
||||
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
if (!user) return;
|
||||
|
||||
@@ -93,7 +99,12 @@ export async function resetPassword(
|
||||
);
|
||||
}
|
||||
|
||||
if (row.used_at !== null) {
|
||||
// Consume the link before the (async) password hash: checking used_at and
|
||||
// marking it later would let parallel submissions all pass the check.
|
||||
const claimed = db.prepare(
|
||||
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
||||
).run(new Date().toISOString(), token);
|
||||
if (claimed.changes === 0) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
@@ -101,9 +112,11 @@ export async function resetPassword(
|
||||
);
|
||||
}
|
||||
|
||||
await updateUser(payload.userId, { password: newPassword });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`,
|
||||
).run(new Date().toISOString(), token);
|
||||
try {
|
||||
await updateUser(payload.userId, { password: newPassword });
|
||||
} catch (err) {
|
||||
db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`)
|
||||
.run(token);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function updatePlaylist(
|
||||
);
|
||||
linkAttachments(newDescription, playlist.id);
|
||||
}
|
||||
broadcastPlaylistUpdated(updated);
|
||||
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ export function setPlaylistImage(
|
||||
playlist.id,
|
||||
);
|
||||
const updated = getPlaylistById(playlist.id);
|
||||
broadcastPlaylistUpdated(updated);
|
||||
broadcastPlaylistUpdated(updated, playlist.isPublic);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -256,6 +256,17 @@ export function addDumpToPlaylist(
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
||||
}
|
||||
|
||||
// Only a dump the requester can see — someone else's private dump would
|
||||
// otherwise land in the playlist and its title in followers' notifications.
|
||||
const dump = db.prepare(
|
||||
`SELECT title, is_private, user_id FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as
|
||||
| { title: string; is_private: number; user_id: string }
|
||||
| undefined;
|
||||
if (!dump || (dump.is_private && dump.user_id !== requestingUserId)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
|
||||
const minRow = db.prepare(
|
||||
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
||||
).get(playlist.id) as { min_pos: number | null } | undefined;
|
||||
@@ -283,18 +294,14 @@ export function addDumpToPlaylist(
|
||||
const dumpIds = getCurrentDumpIds(playlist.id);
|
||||
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
||||
|
||||
if (playlist.isPublic) {
|
||||
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||
// Followers can't open a private dump, even the owner's own.
|
||||
if (playlist.isPublic && !dump.is_private) {
|
||||
notifyPlaylistFollowersNewDump(
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
dumpId,
|
||||
) as { title: string } | undefined;
|
||||
if (dumpRow) {
|
||||
notifyPlaylistFollowersNewDump(
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
dumpId,
|
||||
dumpRow.title,
|
||||
);
|
||||
}
|
||||
dump.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDump } from "../dump-service.ts";
|
||||
import { getUserByUsername } from "../user-service.ts";
|
||||
import { getPlaylistById } from "../playlist-service.ts";
|
||||
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
||||
import { isSelfUrl } from "../../lib/self-url.ts";
|
||||
|
||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
const USER_RE = /^\/users\/([^/]+)$/;
|
||||
@@ -22,10 +23,7 @@ export const selfProvider: RichContentProvider = {
|
||||
|
||||
matches(url: string): boolean {
|
||||
try {
|
||||
const { pathname } = new URL(url);
|
||||
return DUMP_RE.test(pathname) ||
|
||||
USER_RE.test(pathname) ||
|
||||
PLAYLIST_RE.test(pathname);
|
||||
return isSelfUrl(new URL(url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { bandcampProvider } from "./providers/bandcamp.ts";
|
||||
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
||||
import { selfProvider } from "./providers/self.ts";
|
||||
import { genericProvider } from "./providers/generic.ts";
|
||||
import {
|
||||
isPrivateHostname,
|
||||
resolvePublicAddress,
|
||||
} from "../lib/public-address.ts";
|
||||
import { isSelfUrl } from "../lib/self-url.ts";
|
||||
|
||||
export interface RichContentProvider {
|
||||
name: string;
|
||||
@@ -34,23 +39,35 @@ const FETCH_HEADERS = {
|
||||
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
};
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* Fetch `url` via a `curl --insecure` subprocess and return a minimal Response.
|
||||
* Used as a last resort when Deno's rustls rejects the server's TLS certificate
|
||||
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm).
|
||||
* Returns null if curl is unavailable or exits non-zero.
|
||||
*
|
||||
* `address` pins the connection to the IP already checked by
|
||||
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
|
||||
* back with its Location so fetchWithTimeout can check the next hop too.
|
||||
*/
|
||||
async function fetchViaCurl(
|
||||
url: string,
|
||||
url: URL,
|
||||
address: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response | null> {
|
||||
const tmpPath = await Deno.makeTempFile();
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
const pinned = address.includes(":") ? `[${address}]` : address;
|
||||
try {
|
||||
const { code, stdout } = await new Deno.Command("curl", {
|
||||
args: [
|
||||
"--silent",
|
||||
"--insecure",
|
||||
"--location",
|
||||
"--proto",
|
||||
"=http,https",
|
||||
"--resolve",
|
||||
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
|
||||
"--max-time",
|
||||
String(Math.ceil(timeoutMs / 1000)),
|
||||
"--user-agent",
|
||||
@@ -62,8 +79,8 @@ async function fetchViaCurl(
|
||||
"--output",
|
||||
tmpPath,
|
||||
"--write-out",
|
||||
"%{content_type}",
|
||||
url,
|
||||
"%{http_code}\n%{redirect_url}\n%{content_type}",
|
||||
url.href,
|
||||
],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
@@ -71,8 +88,18 @@ async function fetchViaCurl(
|
||||
|
||||
if (code !== 0) return null;
|
||||
|
||||
const contentType = new TextDecoder().decode(stdout).trim();
|
||||
const [status, location, contentType = ""] = new TextDecoder()
|
||||
.decode(stdout).split("\n").map((l) => l.trim());
|
||||
const statusCode = Number(status);
|
||||
if (!(statusCode >= 200 && statusCode <= 599)) return null;
|
||||
const headers = new Headers();
|
||||
if (location) headers.set("location", location);
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
|
||||
const bytes = await Deno.readFile(tmpPath);
|
||||
if (!contentType.startsWith("text/")) {
|
||||
return new Response(bytes, { status: statusCode, headers });
|
||||
}
|
||||
// Decode using the charset declared in the Content-Type header so that
|
||||
// pages served in ISO-8859-1, windows-1252, etc. are read correctly.
|
||||
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
|
||||
@@ -82,7 +109,8 @@ async function fetchViaCurl(
|
||||
} catch {
|
||||
bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
return new Response(bodyText, { headers: { "content-type": "text/html" } });
|
||||
headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8"));
|
||||
return new Response(bodyText, { status: statusCode, headers });
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
@@ -90,25 +118,20 @@ async function fetchViaCurl(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
/** One request, no redirects followed. */
|
||||
async function fetchOnce(
|
||||
url: URL,
|
||||
address: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
async function attempt(): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: FETCH_HEADERS,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await attempt();
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: FETCH_HEADERS,
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof TypeError && err.message.includes("certificate"))) {
|
||||
throw err;
|
||||
@@ -118,10 +141,37 @@ export async function fetchWithTimeout(
|
||||
// or an unsupported signature algorithm) and offers no way to override
|
||||
// verification programmatically. Fall back to a `curl --insecure`
|
||||
// subprocess, which uses its own TLS stack.
|
||||
const curlRes = await fetchViaCurl(url, timeoutMs);
|
||||
const curlRes = await fetchViaCurl(url, address, timeoutMs);
|
||||
if (curlRes) return curlRes;
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user-supplied URL. Every hop — the URL itself and each redirect —
|
||||
* must resolve to a public address (see resolvePublicAddress), so a link or a
|
||||
* redirect can't point the server at its own network.
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<Response> {
|
||||
let current = new URL(url);
|
||||
for (let hop = 0;; hop++) {
|
||||
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
||||
throw new TypeError(`Unsupported protocol ${current.protocol}`);
|
||||
}
|
||||
const address = await resolvePublicAddress(current);
|
||||
const res = await fetchOnce(current, address, timeoutMs);
|
||||
const location = res.headers.get("location");
|
||||
if (res.status < 300 || res.status >= 400 || !location) return res;
|
||||
|
||||
await res.body?.cancel();
|
||||
if (hop >= MAX_REDIRECTS) throw new TypeError("Too many redirects");
|
||||
current = new URL(location, current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,24 +579,14 @@ export function extractLinkHref(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
|
||||
if (hostname === "localhost" || hostname === "::1") return true;
|
||||
return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
|
||||
}
|
||||
|
||||
const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/;
|
||||
|
||||
export function isValidHttpUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
// Allow private hosts for self-referential gerbeur URLs — they are
|
||||
// resolved directly from the DB by selfProvider, no outbound HTTP needed.
|
||||
if (isPrivateHost(u.hostname) && !SELF_PATH_RE.test(u.pathname)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// This site's own pages are fine even on a private host (e.g. localhost in
|
||||
// development): selfProvider reads them from the DB, no HTTP involved.
|
||||
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
|
||||
return !isPrivateHostname(u) || isSelfUrl(u);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type User,
|
||||
} from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
||||
import { disconnectUser } from "./ws-service.ts";
|
||||
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
|
||||
|
||||
import { hashPassword } from "../lib/jwt.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
@@ -36,20 +36,35 @@ export async function createUser(
|
||||
);
|
||||
}
|
||||
|
||||
assertEmailAvailable(request.email);
|
||||
|
||||
const passwordHash = await hashPassword(request.password);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
userId,
|
||||
request.username,
|
||||
passwordHash,
|
||||
"user",
|
||||
createdAt.toISOString(),
|
||||
inviterId,
|
||||
request.email,
|
||||
);
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
userId,
|
||||
request.username,
|
||||
passwordHash,
|
||||
"user",
|
||||
createdAt.toISOString(),
|
||||
inviterId,
|
||||
request.email,
|
||||
);
|
||||
} catch (err) {
|
||||
// The check above ran before the hash yielded, so a parallel registration
|
||||
// can still take the name first; the UNIQUE constraint is the real guard.
|
||||
if (err instanceof Error && err.message.includes("UNIQUE")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Username already exists",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
@@ -85,6 +100,23 @@ export function getUserByUsername(username: string): User {
|
||||
return userRowToApi(userRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Password resets are looked up by email, so an address can belong to one
|
||||
* account only — otherwise which account a reset link resets is up to row order.
|
||||
*/
|
||||
function assertEmailAvailable(email: string, exceptUserId?: string): void {
|
||||
const row = db.prepare(
|
||||
`SELECT id FROM users WHERE lower(email) = lower(?) AND id != ?;`,
|
||||
).get(email, exceptUserId ?? "");
|
||||
if (row) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
"Email already in use",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserByEmail(email: string): User | null {
|
||||
const userRow = db.prepare(
|
||||
`${USER_SELECT} WHERE u.email = ?`,
|
||||
@@ -141,6 +173,9 @@ export async function updateUser(
|
||||
const user = getUserById(userId);
|
||||
|
||||
const { password, description, email, ...requestFields } = request;
|
||||
if (email !== undefined && email !== user.email) {
|
||||
assertEmailAvailable(email, userId);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedUser: User = {
|
||||
@@ -170,6 +205,15 @@ export async function updateUser(
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||
}
|
||||
|
||||
// A new password ends every session opened with the old one.
|
||||
if (password) {
|
||||
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
||||
now.toISOString(),
|
||||
userId,
|
||||
);
|
||||
closeUserSockets(userId, "Password changed");
|
||||
}
|
||||
|
||||
if (updatedUser.description) {
|
||||
linkAttachments(updatedUser.description, userId);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,26 @@ import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
||||
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
||||
|
||||
export function castVote(dumpId: string, userId: string): number {
|
||||
export interface VoteResult {
|
||||
voteCount: number;
|
||||
/** Private dumps' vote counts go to their owner only. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
// Visibility of the dump, or a 404 when the user can't see it — voting must not
|
||||
// confirm that a private dump exists.
|
||||
function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } {
|
||||
const row = db.prepare(
|
||||
`SELECT is_private, user_id FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as { is_private: number; user_id: string } | undefined;
|
||||
if (!row || (row.is_private && row.user_id !== userId)) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||
}
|
||||
return { isPrivate: Boolean(row.is_private) };
|
||||
}
|
||||
|
||||
export function castVote(dumpId: string, userId: string): VoteResult {
|
||||
const { isPrivate } = visibleDump(dumpId, userId);
|
||||
let voteCount: number;
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
@@ -34,10 +53,16 @@ export function castVote(dumpId: string, userId: string): number {
|
||||
try {
|
||||
notifyDumpOwnerUpvote(userId, dumpId);
|
||||
} catch { /* ignore */ }
|
||||
return voteCount;
|
||||
return { voteCount, isPrivate };
|
||||
}
|
||||
|
||||
export function removeVote(dumpId: string, userId: string): number {
|
||||
// No visibility check: taking back a vote on a dump that has since gone
|
||||
// private is harmless, and it's the voter's own row.
|
||||
export function removeVote(dumpId: string, userId: string): VoteResult {
|
||||
const row = db.prepare(`SELECT is_private FROM dumps WHERE id = ?;`).get(
|
||||
dumpId,
|
||||
) as { is_private: number } | undefined;
|
||||
const isPrivate = Boolean(row?.is_private);
|
||||
try {
|
||||
db.exec("BEGIN;");
|
||||
const result = db.prepare(
|
||||
@@ -50,11 +75,11 @@ export function removeVote(dumpId: string, userId: string): number {
|
||||
db.prepare(
|
||||
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
|
||||
).run(dumpId);
|
||||
const row = db.prepare(
|
||||
const count = db.prepare(
|
||||
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as { vote_count: number } | undefined;
|
||||
db.exec("COMMIT;");
|
||||
return row?.vote_count ?? 0;
|
||||
return { voteCount: count?.vote_count ?? 0, isPrivate };
|
||||
} catch (err) {
|
||||
if (err instanceof APIException) throw err;
|
||||
db.exec("ROLLBACK;");
|
||||
|
||||
@@ -83,6 +83,18 @@ export function disconnectUser(userId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the user's sockets without logging anyone out: each reconnects with
|
||||
* whatever token it holds, and /ws turns a revoked one into a force_logout.
|
||||
* Used on a password change, where the tab that made the change has already
|
||||
* swapped in a fresh token and must stay signed in.
|
||||
*/
|
||||
export function closeUserSockets(userId: string, reason: string): void {
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId) client.socket.close(1000, reason);
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastPresence(): void {
|
||||
const users = getOnlineUsers();
|
||||
for (const client of clients) {
|
||||
@@ -108,13 +120,16 @@ export function broadcastDumpDeleted(dumpId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// `onlyUserId`: a private dump's count goes to that user's sockets alone.
|
||||
export function broadcastVoteUpdate(
|
||||
dumpId: string,
|
||||
voteCount: number,
|
||||
voterId: string,
|
||||
action: "cast" | "remove",
|
||||
onlyUserId?: string,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
if (onlyUserId && client.userId !== onlyUserId) continue;
|
||||
send(client.socket, {
|
||||
type: "votes_update",
|
||||
dumpId,
|
||||
@@ -125,13 +140,16 @@ export function broadcastVoteUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
// `onlyUserId`: a like on a private dump's comment goes to that user alone.
|
||||
export function broadcastCommentLikeUpdate(
|
||||
commentId: string,
|
||||
likeCount: number,
|
||||
likerId: string,
|
||||
action: "cast" | "remove",
|
||||
onlyUserId?: string,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
if (onlyUserId && client.userId !== onlyUserId) continue;
|
||||
send(client.socket, {
|
||||
type: "comment_likes_update",
|
||||
commentId,
|
||||
@@ -157,12 +175,17 @@ export function broadcastPlaylistCreated(playlist: Playlist): void {
|
||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
||||
}
|
||||
|
||||
export function broadcastPlaylistUpdated(playlist: Playlist): void {
|
||||
// Broadcast to ALL clients so non-owners can react to visibility changes
|
||||
// (e.g. remove a now-private playlist from their feed).
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "playlist_updated", playlist });
|
||||
}
|
||||
// `wasPublic`: the visibility before this change. A playlist that just went
|
||||
// private still goes to everyone, so non-owners can drop it from their views;
|
||||
// one that was and stays private goes to its owner alone.
|
||||
export function broadcastPlaylistUpdated(
|
||||
playlist: Playlist,
|
||||
wasPublic: boolean,
|
||||
): void {
|
||||
sendToPlaylistAudience(
|
||||
{ isPublic: playlist.isPublic || wasPublic, userId: playlist.userId },
|
||||
{ type: "playlist_updated", playlist },
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastPlaylistDeleted(
|
||||
@@ -305,44 +328,7 @@ export function isUserChatOpen(userId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token buckets keyed by `${kind}:${userId}` — per user rather than per socket,
|
||||
// so opening more tabs doesn't buy more throughput.
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
at: number;
|
||||
}
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
export interface RateLimit {
|
||||
/** Burst size. */
|
||||
capacity: number;
|
||||
/** Tokens regained per second. */
|
||||
refill: number;
|
||||
}
|
||||
|
||||
/** Takes one token for `key`; false when the bucket is empty. */
|
||||
export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
|
||||
bucket.tokens = Math.min(
|
||||
limit.capacity,
|
||||
bucket.tokens + ((now - bucket.at) / 1000) * limit.refill,
|
||||
);
|
||||
bucket.at = now;
|
||||
buckets.set(key, bucket);
|
||||
if (bucket.tokens < 1) return false;
|
||||
bucket.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Full buckets carry no information; drop them so the map tracks only users
|
||||
// who've been active lately.
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 5 * 60_000;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.at < cutoff) buckets.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
export { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
|
||||
Reference in New Issue
Block a user