From a36a4cac6bcd1692487cc526942a296598315053 Mon Sep 17 00:00:00 2001 From: khannurien Date: Tue, 29 Sep 2026 19:35:53 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20fix=20the=20backend's=20correctness=20fin?= =?UTF-8?q?dings=20=E2=80=94=20each=20by=20a=20rule=20shared=20across=20th?= =?UTF-8?q?e=20code=20rather=20than=20one=20more=20special=20case.=20Link?= =?UTF-8?q?=20previews:=20readTextCapped=20decoded=20every=20page=20as=20U?= =?UTF-8?q?TF-8,=20so=20a=20Latin-1=20page=20came=20out=20garbled=20unless?= =?UTF-8?q?=20it=20went=20through=20the=20curl=20fallback,=20which=20re-en?= =?UTF-8?q?coded=20it=20on=20its=20own;=20pages=20are=20now=20decoded=20in?= =?UTF-8?q?=20the=20charset=20their=20Content-Type=20or=20=20declare?= =?UTF-8?q?s,=20and=20the=20curl=20fallback=20hands=20the=20bytes=20over?= =?UTF-8?q?=20as=20they=20are.=20The=20image=20proxy=20served=20and=20cach?= =?UTF-8?q?ed=20for=20a=20day=20an=20error=20answer=20that=20claimed=20an?= =?UTF-8?q?=20image=20type,=20and=20read=20a=20body=20without=20Content-Le?= =?UTF-8?q?ngth=20whole=20before=20checking=20its=20size;=20it=20now=20ref?= =?UTF-8?q?uses=20an=20error=20status=20and=20reads=20through=20the=20same?= =?UTF-8?q?=20capped=20reader.=20The=20icons=20task's=20Twemoji=20fetch=20?= =?UTF-8?q?gives=20up=20after=2015=20s=20instead=20of=20holding=20the=20st?= =?UTF-8?q?art=20up,=20and=20extractLinkHref,=20which=20only=20its=20tests?= =?UTF-8?q?=20used,=20is=20gone.=20Backlinks=20matched=20a=20linked=20URL?= =?UTF-8?q?=20against=20the=20raw=20url=20column,=20so=20https://x.com/=20?= =?UTF-8?q?missed=20a=20dump=20posted=20as=20https://x.com;=20they=20now?= =?UTF-8?q?=20compare=20canonical=20URLs,=20as=20reposts=20do.=20The=20fou?= =?UTF-8?q?r=20LIKE=20searches=20put=20the=20query=20in=20raw,=20so=20%=20?= =?UTF-8?q?and=20=5F=20were=20wildcards=20and=20the=20mention=20search=20f?= =?UTF-8?q?or=20"a=5Fb"=20found=20"axb";=20lib/like.ts=20escapes=20them.?= =?UTF-8?q?=20Every=20list=20query=20now=20treats=20a=20malformed=20row=20?= =?UTF-8?q?the=20same=20way,=20a=20500=20through=20model/rows.ts's=20parse?= =?UTF-8?q?Rows,=20where=20ten=20of=20them=20silently=20dropped=20it;=20th?= =?UTF-8?q?e=20playlist=20dumps=20query=20uses=20the=20model's=20column=20?= =?UTF-8?q?list=20rather=20than=20a=20hand-written=20copy,=20and=20the=20a?= =?UTF-8?q?liased=20list=20is=20derived=20from=20the=20plain=20one.=20The?= =?UTF-8?q?=20vote=20and=20like=20services=20commit=20through=20transactio?= =?UTF-8?q?n()=20instead=20of=20their=20own=20BEGIN=20and=20ROLLBACK,=20wh?= =?UTF-8?q?ich=20would=20have=20rolled=20back=20a=20caller's=20transaction?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500. Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass. Co-Authored-By: Claude Opus 5.5 --- api/lib/like.ts | 24 +++++++ api/lib/reserved-slugs.ts | 17 ++--- api/middleware/auth.ts | 17 ----- api/model/dump.ts | 12 ++-- api/model/rows.ts | 25 ++++++++ api/remote/fetch.ts | 62 ++++++++++++------- api/remote/html.ts | 33 ---------- api/remote/image-proxy.ts | 12 +++- api/remote/twemoji.ts | 6 +- api/routes/invites.ts | 3 - api/routes/notifications.ts | 19 ------ api/routes/users.ts | 20 ++---- api/services/backlink-service.ts | 26 +++----- api/services/category-service.ts | 5 +- api/services/comment-like-service.ts | 44 +++++-------- api/services/comment-service.ts | 5 +- api/services/dump-service.ts | 10 +-- api/services/email-service.ts | 12 ---- api/services/follow-service.ts | 11 +++- api/services/notification-service.ts | 18 +----- api/services/playlist-service.ts | 42 +++++++------ api/services/user-service.ts | 48 +++----------- api/services/vote-service.ts | 46 ++++++-------- api/services/ws-service.ts | 13 ---- api/test/lib/reserved-slugs.test.ts | 11 +--- api/test/remote/fetch.test.ts | 21 +++++++ api/test/remote/html.test.ts | 5 -- api/test/routes/dumps.test.ts | 6 +- api/test/routes/follows.test.ts | 8 ++- api/test/routes/notifications.test.ts | 34 +--------- api/test/routes/preview.test.ts | 19 ++++++ api/test/routes/users.test.ts | 8 +++ .../services/notification-service.test.ts | 20 ++++++ api/test/validators/dump.test.ts | 7 ++- api/validators/dump.ts | 10 +-- api/validators/playlist.ts | 24 +++---- api/validators/user.ts | 18 ++---- shared/mention.ts | 26 ++++++-- shared/validation.ts | 15 +++++ shared/ws.ts | 9 --- src/contexts/ws/WSProvider.tsx | 10 +-- src/hooks/editor/useMentionAutocomplete.ts | 3 +- src/pages/UserRegister.tsx | 6 +- src/test/contexts/ws/WSProvider.test.tsx | 7 --- src/utils/chat/text.ts | 8 +-- 45 files changed, 363 insertions(+), 442 deletions(-) create mode 100644 api/lib/like.ts create mode 100644 api/model/rows.ts diff --git a/api/lib/like.ts b/api/lib/like.ts new file mode 100644 index 00000000..af3303bb --- /dev/null +++ b/api/lib/like.ts @@ -0,0 +1,24 @@ +/** + * Builds the patterns of the search queries' `LIKE`, for the dump, playlist + * and user searches. A query's `%` and `_` are matched as themselves rather + * than as wildcards: usernames hold underscores, and "a_b" must not find + * "axb". + * + * @module + */ + +/** + * Returns the pattern matching text that contains `query`, or that starts + * with it when `where` is `"prefix"`. The SQL must read `LIKE ? ESCAPE '\'`, + * which the {@link LIKE} fragment spells. + */ +export function likePattern( + query: string, + where: "anywhere" | "prefix" = "anywhere", +): string { + const escaped = query.replace(/[\\%_]/g, "\\$&"); + return where === "prefix" ? `${escaped}%` : `%${escaped}%`; +} + +/** Spells `LIKE ? ESCAPE '\'`, for a {@link likePattern} parameter. */ +export const LIKE = "LIKE ? ESCAPE '\\'"; diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index ae07dbb9..23a33d42 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -8,9 +8,10 @@ /** * The top-level path segments a category slug may not take. They are the SPA's - * own routes (`src/App.tsx`), `api` and `assets`, which the server answers - * before the SPA, and `manage`, which no route uses any more. Keep it in step - * with the route table. + * own routes (`src/App.tsx`); `api`, `ws`, `assets` and `emojibase`, which the + * server answers before the SPA; and `manage`, a former route whose old links + * may still be about. Keep it in step with the route table and the build's + * top-level folders. */ export const RESERVED_SLUGS: readonly string[] = [ "~", @@ -24,7 +25,9 @@ export const RESERVED_SLUGS: readonly string[] = [ "reset-password", "manage", "api", + "ws", "assets", + "emojibase", ]; /** @@ -42,11 +45,3 @@ export function isValidSlugFormat(slug: string): boolean { export function isReservedSlug(slug: string): boolean { return RESERVED_SLUGS.includes(slug); } - -/** - * Checks whether `slug` is well-formed and not reserved. The category service - * runs the two checks apart, to give each failure its own message. - */ -export function isSlugAvailable(slug: string): boolean { - return isValidSlugFormat(slug) && !isReservedSlug(slug); -} diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index 945c2545..56e4f4bd 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -63,23 +63,6 @@ export async function authMiddleware( await next(); } -/** - * Requires the caller to be an admin. Must run after {@link authMiddleware}. - * No route uses it: they gate on a permission with {@link requirePermission}. - * - * @throws {APIException} 403 when the caller isn't an admin. - */ -export async function adminOnlyMiddleware( - ctx: AuthContext, - next: Next, -): Promise { - if (ctx.state.user.role !== "admin") { - throw new APIException(403, "Admin access required"); - } - - await next(); -} - /** * Returns a middleware that requires the caller to hold `permission`. It must * run after {@link authMiddleware}, as in diff --git a/api/model/dump.ts b/api/model/dump.ts index 91888bd5..b26e2dbd 100644 --- a/api/model/dump.ts +++ b/api/model/dump.ts @@ -41,18 +41,14 @@ export interface DumpRow { /** * Lists the stored columns of {@link DumpRow}. A column added to the table must - * be added here, to {@link DUMP_COLUMNS_ALIASED}, to {@link DumpRow} and to - * {@link isDumpRow}. + * be added here, to {@link DumpRow} and to {@link isDumpRow}. */ export const DUMP_COLUMNS = "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime"; -/** - * Lists {@link DUMP_COLUMNS} for a query that aliases `dumps` as `d`. The - * playlist service selects with it and counts the comments itself. - */ -export const DUMP_COLUMNS_ALIASED = - "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime"; +// The same columns, for a query that aliases `dumps` as `d`. +const DUMP_COLUMNS_ALIASED = DUMP_COLUMNS.split(", ").map((c) => `d.${c}`) + .join(", "); /** * Selects a whole {@link DumpRow}, the stored columns and the computed diff --git a/api/model/rows.ts b/api/model/rows.ts new file mode 100644 index 00000000..6e50c6d6 --- /dev/null +++ b/api/model/rows.ts @@ -0,0 +1,25 @@ +/** + * Checks the rows of a list query, for the services. Every service answers a + * row that doesn't have its table's shape with a 500 rather than leaving it + * out, so that damage shows instead of making items silently vanish. + * + * @module + */ + +import { APIException } from "../lib/api-exception.ts"; + +/** + * Returns `rows` turned by `toApi`, once `isRow` has accepted each. + * + * @throws {APIException} 500 when a row doesn't pass `isRow`. + */ +export function parseRows( + rows: unknown[], + isRow: (row: unknown) => row is R, + toApi: (row: R) => T, +): T[] { + if (!rows.every(isRow)) { + throw new APIException(500, "Malformed data in the database"); + } + return rows.map(toApi); +} diff --git a/api/remote/fetch.ts b/api/remote/fetch.ts index 29a6eea8..094d62c6 100644 --- a/api/remote/fetch.ts +++ b/api/remote/fetch.ts @@ -77,20 +77,7 @@ async function fetchViaCurl( if (contentType) headers.set("content-type", contentType); const bytes = await Deno.readFile(tmpPath); - if (!contentType.startsWith("text/")) { - return new Response(bytes, { status: statusCode, headers }); - } - // readTextCapped decodes as UTF-8, so a page in another charset is - // re-encoded here. - const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8"; - let bodyText: string; - try { - bodyText = new TextDecoder(charset, { fatal: false }).decode(bytes); - } catch { - bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes); - } - headers.set("content-type", contentType.replace(/;.*$/, "; charset=utf-8")); - return new Response(bodyText, { status: statusCode, headers }); + return new Response(bytes, { status: statusCode, headers }); } catch { return null; } finally { @@ -137,15 +124,14 @@ function withDeadline( } /** - * Reads the body of `res` as UTF-8 text, up to `maxBytes`; the rest is - * cancelled, never downloaded. The providers and the Bandcamp and YouTube - * resolvers read pages through it. + * Reads the body of `res` up to `maxBytes`; the rest is cancelled, never + * downloaded. The image proxy reads through it, and {@link readTextCapped}. */ -export async function readTextCapped( +export async function readBytesCapped( res: Response, - maxBytes = MAX_PAGE_BYTES, -): Promise { - if (!res.body) return ""; + maxBytes: number, +): Promise { + if (!res.body) return new Uint8Array(); const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; @@ -167,7 +153,39 @@ export async function readTextCapped( offset += part.byteLength; if (offset >= bytes.byteLength) break; } - return new TextDecoder().decode(bytes); + return bytes; +} + +/** + * Returns the charset a page declares, in its `Content-Type` header or else in + * a `` near its top, as HTML allows; UTF-8 when it declares none. + */ +function declaredCharset(contentType: string, bytes: Uint8Array): string { + const fromHeader = /charset=["']?([\w-]+)/i.exec(contentType)?.[1]; + if (fromHeader) return fromHeader; + // Latin-1 maps every byte to a character, so the ASCII of the tag reads + // right whatever the page's encoding. + const head = new TextDecoder("latin1").decode(bytes.subarray(0, 1024)); + return /]+charset=["']?([\w-]+)/i.exec(head)?.[1] ?? "utf-8"; +} + +/** + * Reads the body of `res` as text, up to `maxBytes`, in the charset the page + * declares; the rest is cancelled, never downloaded. The providers and the + * Bandcamp and YouTube resolvers read pages through it. + */ +export async function readTextCapped( + res: Response, + maxBytes = MAX_PAGE_BYTES, +): Promise { + const bytes = await readBytesCapped(res, maxBytes); + const charset = declaredCharset(res.headers.get("content-type") ?? "", bytes); + try { + return new TextDecoder(charset).decode(bytes); + } catch { + // An unknown label: TextDecoder throws rather than guess. + return new TextDecoder().decode(bytes); + } } /** diff --git a/api/remote/html.ts b/api/remote/html.ts index d82d36da..ea7f38ec 100644 --- a/api/remote/html.ts +++ b/api/remote/html.ts @@ -393,36 +393,3 @@ export function extractFirstContentImage( } return undefined; } - -/** - * Returns the absolute URL of the first `` whose `rel` contains - * `relFragment`. No provider calls it; only its tests do. - */ -export function extractLinkHref( - html: string, - relFragment: string, - baseUrl: string, -): string | undefined { - const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const patterns = [ - new RegExp( - `]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`, - "i", - ), - new RegExp( - `]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`, - "i", - ), - ]; - for (const pattern of patterns) { - const match = html.match(pattern); - if (match) { - try { - return new URL(match[1], baseUrl).toString(); - } catch { - return undefined; - } - } - } - return undefined; -} diff --git a/api/remote/image-proxy.ts b/api/remote/image-proxy.ts index 442784ea..b11cef12 100644 --- a/api/remote/image-proxy.ts +++ b/api/remote/image-proxy.ts @@ -7,7 +7,7 @@ * @module */ -import { fetchWithTimeout } from "./fetch.ts"; +import { fetchWithTimeout, readBytesCapped } from "./fetch.ts"; /** * Returned to `GET /api/proxy-image` by {@link fetchProxiedImage}, which sends @@ -87,7 +87,8 @@ export function clearImageCache(): void { /** * Returns the image at `url`, from the cache or else from its host; null when - * the host answers with anything but a raster image of 5 MB at most. The route + * the host answers with an error, or with anything but a raster image of 5 MB + * at most. The route * checks `url` with `isValidHttpUrl` first. * * @throws {Error} When the host can't be reached, as @@ -100,6 +101,10 @@ export async function fetchProxiedImage( if (cached) return cached; const res = await fetchWithTimeout(url, 8000); + if (!res.ok) { + await res.body?.cancel(); + return null; + } // Raster formats only: the bytes go out under this site's origin, where an // SVG, which can carry script, would run if opened directly. const contentType = (res.headers.get("content-type") ?? "") @@ -116,7 +121,8 @@ export async function fetchProxiedImage( return null; } - const bytes = new Uint8Array(await res.arrayBuffer()); + // Without a Content-Length, the cap is found by reading one byte past it. + const bytes = await readBytesCapped(res, MAX_SIZE + 1); if (bytes.length > MAX_SIZE) return null; writeImageCache(url, bytes, contentType); diff --git a/api/remote/twemoji.ts b/api/remote/twemoji.ts index 23fa1a0f..13297550 100644 --- a/api/remote/twemoji.ts +++ b/api/remote/twemoji.ts @@ -17,7 +17,7 @@ const glyphCache = new Map(); /** * Returns Twemoji's SVG for `emoji`, whose file name `cp` comes from * `emojiToCodepoint`. `emoji` only serves the error message. It fetches with - * plain `fetch`, since the host is fixed. + * plain `fetch`, since the host is fixed, and gives up after 15 s. * * @throws {Error} When the glyph can't be fetched. */ @@ -31,7 +31,9 @@ export async function fetchTwemojiSvg( `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`; let res: Response; try { - res = await fetch(url); + // The icons task runs before the server starts, so a stalled CDN + // mustn't hold the start up forever. + res = await fetch(url, { signal: AbortSignal.timeout(15_000) }); } catch (cause) { throw new Error( `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`, diff --git a/api/routes/invites.ts b/api/routes/invites.ts index a1e97e1b..822eaf6d 100644 --- a/api/routes/invites.ts +++ b/api/routes/invites.ts @@ -23,9 +23,6 @@ import { createInvite, validateInvite } from "../services/invite-service.ts"; const router: Router = new Router({ prefix: "/api/invites" }); router.post("/", authMiddleware, (ctx: AuthContext) => { - if (!ctx.state.user) { - throw new APIException(401, "Not authenticated"); - } const responseBody: APIResponse = { success: true, data: { token: createInvite(ctx.state.user.userId) }, diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 5f842916..0d1bcb87 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -6,22 +6,18 @@ * - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them, * newest first. * - `POST /api/notifications/read-all` — signed in. Marks them all read. - * - `PATCH /api/notifications/:id/read` — signed in. Marks one of them read. - * The client doesn't use it. * * @module */ import { Router } from "@oak/oak"; import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; -import { APIException } from "../lib/api-exception.ts"; import type { Notification } from "../../shared/notification.ts"; import { parsePagination } from "../lib/pagination.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { getNotificationsForUser, markAllRead, - markOneRead, } from "../services/notification-service.ts"; /** @@ -31,9 +27,6 @@ import { const router: Router = new Router({ prefix: "/api/notifications" }); router.get("/", authMiddleware, (ctx: AuthContext) => { - if (!ctx.state.user) { - throw new APIException(401, "Not authenticated"); - } const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, hasMore } = getNotificationsForUser( ctx.state.user.userId, @@ -48,20 +41,8 @@ router.get("/", authMiddleware, (ctx: AuthContext) => { }); router.post("/read-all", authMiddleware, (ctx: AuthContext) => { - if (!ctx.state.user) { - throw new APIException(401, "Not authenticated"); - } markAllRead(ctx.state.user.userId); ctx.response.status = 204; }); -router.patch("/:id/read", authMiddleware, (ctx) => { - const user = ctx.state.user; - if (!user) { - throw new APIException(401, "Not authenticated"); - } - markOneRead(ctx.params.id, user.userId); - ctx.response.status = 204; -}); - export default router; diff --git a/api/routes/users.ts b/api/routes/users.ts index c9abf274..971b84ba 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -215,21 +215,11 @@ router.post("/login", async (ctx) => { }); router.get("/me", authMiddleware, (ctx: AuthContext) => { - try { - if (!ctx.state.user) { - throw new APIException(401, "Not authenticated"); - } - - const responseBody: APIResponse = { - success: true, - data: toSelfUser(getUserById(ctx.state.user.userId)), - }; - ctx.response.body = responseBody; - } catch (err) { - console.error(err); - - throw new APIException(500, "Failed to fetch user profile"); - } + const responseBody: APIResponse = { + success: true, + data: toSelfUser(getUserById(ctx.state.user.userId)), + }; + ctx.response.body = responseBody; }); router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index 4a7909d2..634aa34f 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -14,6 +14,8 @@ import { isDumpRow, } from "../model/dump.ts"; import { UUID_RE } from "../lib/slugify.ts"; +import { canonicalizeUrl } from "../lib/canonical-url.ts"; +import { parseRows } from "../model/rows.ts"; /** * Matches an http(s) URL in Markdown. It stops at the delimiters of @@ -28,15 +30,6 @@ const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; */ const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; -/** Returns `url` as the URL parser normalizes it, or null when it isn't one. */ -function canonicalize(url: string): string | null { - try { - return new URL(url).toString(); - } catch { - return null; - } -} - /** Returns the id of the dump with id or slug `idOrSlug`, or null. */ function resolveDumpPathId(idOrSlug: string): string | null { const row = UUID_RE.test(idOrSlug) @@ -48,8 +41,8 @@ function resolveDumpPathId(idOrSlug: string): string | null { /** * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one * of its comments (`comment`), with the links its current `text` holds. A link - * counts when it is a dump's page on this site or the exact URL a dump was - * posted with. The dump and comment services call it on every save, and with + * counts when it is a dump's page on this site, or when it is the URL a dump + * was posted with, compared by `canonicalizeUrl` as reposts are. The dump and comment services call it on every save, and with * an empty `text` when a comment is deleted. */ export function relinkBacklinks( @@ -62,11 +55,9 @@ export function relinkBacklinks( const externalUrls = new Set(); for (const match of text.matchAll(URL_RE)) { - const canonical = canonicalize(match[0]); - if (!canonical) continue; let parsed: URL; try { - parsed = new URL(canonical); + parsed = new URL(match[0]); } catch { continue; } @@ -75,14 +66,15 @@ export function relinkBacklinks( const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1])); if (resolved) targetIds.add(resolved); } else { - externalUrls.add(canonical); + const canonical = canonicalizeUrl(match[0]); + if (canonical) externalUrls.add(canonical); } } if (externalUrls.size > 0) { const placeholders = [...externalUrls].map(() => "?").join(", "); const rows = db.prepare( - `SELECT id FROM dumps WHERE url IN (${placeholders});`, + `SELECT id FROM dumps WHERE url_canonical IN (${placeholders});`, ).all(...externalUrls) as { id: string }[]; for (const row of rows) targetIds.add(row.id); } @@ -122,5 +114,5 @@ export function getRelatedDumps( ORDER BY created_at DESC LIMIT 20;`, ).all(dumpId, dumpId, requestingUserId ?? ""); - return rows.filter(isDumpRow).map(dumpRowToApi); + return parseRows(rows, isDumpRow, dumpRowToApi); } diff --git a/api/services/category-service.ts b/api/services/category-service.ts index 6502b1ec..80c1f99d 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -23,6 +23,7 @@ import { import { type Page, pageOf } from "../lib/pagination.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { VALIDATION } from "../../shared/validation.ts"; +import { parseRows } from "../model/rows.ts"; /** * Throws a 400 unless `slug` is well-formed, short enough and not reserved, @@ -120,7 +121,7 @@ export function listCategories(): Category[] { const rows = db.prepare( `SELECT * FROM categories ORDER BY position ASC, slug ASC;`, ).all(); - return rows.filter(isCategoryRow).map(categoryRowToApi); + return parseRows(rows, isCategoryRow, categoryRowToApi); } /** @@ -317,7 +318,7 @@ export function listDumpsByCategory( ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, ).all(category.id, ...privacyParams, limit + 1, offset); - const items = rows.filter(isDumpRow).map(dumpRowToApi); + const items = parseRows(rows, isDumpRow, dumpRowToApi); attachCategoryIds(items); return pageOf(items, limit); } diff --git a/api/services/comment-like-service.ts b/api/services/comment-like-service.ts index c0cc892f..14e54111 100644 --- a/api/services/comment-like-service.ts +++ b/api/services/comment-like-service.ts @@ -10,7 +10,7 @@ import { APIException } from "../lib/api-exception.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; -import { db } from "../db/connection.ts"; +import { db, transaction } from "../db/connection.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts"; @@ -54,22 +54,19 @@ export function castCommentLike( const isPrivate = commentVisibility(commentId, userId); let likeCount: number; try { - db.exec("BEGIN;"); - db.prepare( - `INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`, - ).run(commentId, userId, new Date().toISOString()); - db.prepare( - `UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`, - ).run(commentId); - const row = db.prepare( - `SELECT like_count FROM comments WHERE id = ?;`, - ).get(commentId) as { like_count: number } | undefined; - db.exec("COMMIT;"); - likeCount = row?.like_count ?? 0; + likeCount = transaction(() => { + db.prepare( + `INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`, + ).run(commentId, userId, new Date().toISOString()); + db.prepare( + `UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`, + ).run(commentId); + const row = db.prepare( + `SELECT like_count FROM comments WHERE id = ?;`, + ).get(commentId) as { like_count: number }; + return row.like_count; + }); } catch (err) { - try { - db.exec("ROLLBACK;"); - } catch { /* ignore if no active transaction */ } if (err instanceof Error && err.message.includes("UNIQUE constraint")) { throw new APIException(409, "Already liked"); } @@ -98,13 +95,11 @@ export function removeCommentLike( WHERE c.id = ?;`, ).get(commentId) as { is_private: number } | undefined; const isPrivate = Boolean(dumpRow?.is_private); - try { - db.exec("BEGIN;"); + return transaction(() => { const result = db.prepare( `DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`, ).run(commentId, userId); if (result.changes === 0) { - db.exec("ROLLBACK;"); throw new APIException(404, "Like not found"); } db.prepare( @@ -112,14 +107,9 @@ export function removeCommentLike( ).run(commentId); const row = db.prepare( `SELECT like_count FROM comments WHERE id = ?;`, - ).get(commentId) as { like_count: number } | undefined; - db.exec("COMMIT;"); - return { likeCount: row?.like_count ?? 0, isPrivate }; - } catch (err) { - if (err instanceof APIException) throw err; - db.exec("ROLLBACK;"); - throw err; - } + ).get(commentId) as { like_count: number }; + return { likeCount: row.like_count, isPrivate }; + }); } /** diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index 7f60cf85..d42e8279 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -8,7 +8,6 @@ import { APIException } from "../lib/api-exception.ts"; import type { Comment } from "../../shared/comment.ts"; -import { type SQLOutputValue } from "node:sqlite"; import { commentRowToApi, isCommentRow } from "../model/comment.ts"; import { db } from "../db/connection.ts"; import { @@ -27,9 +26,7 @@ function fetchComment(commentId: string): Comment { const row = db.prepare( `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, ).get(commentId); - if (!row || !isCommentRow(row as Record)) { - throw new APIException(404, "Comment not found"); - } + if (!row) throw new APIException(404, "Comment not found"); if (!isCommentRow(row)) { throw new APIException(500, "Malformed comment data"); } diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 6c410d50..f4383753 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -54,6 +54,8 @@ import { attachCategoryIds, setDumpCategories, } from "./category-service.ts"; +import { LIKE, likePattern } from "../lib/like.ts"; +import { parseRows } from "../model/rows.ts"; function isAllowedMime(mime: string): boolean { return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || @@ -327,11 +329,11 @@ export function searchDumps( requestingUserId?: string, ): { items: Dump[]; total: number } { if (!query.trim()) return { items: [], total: 0 }; - const pattern = `%${query}%`; + const pattern = likePattern(query); const offset = (page - 1) * limit; const searchClause = - `(title LIKE ? OR comment LIKE ? OR json_extract(rich_content,'$.title') LIKE ? OR json_extract(rich_content,'$.description') LIKE ?)`; + `(title ${LIKE} OR comment ${LIKE} OR json_extract(rich_content,'$.title') ${LIKE} OR json_extract(rich_content,'$.description') ${LIKE})`; const searchParams = [pattern, pattern, pattern, pattern] as const; if (requestingUserId) { @@ -341,7 +343,7 @@ export function searchDumps( const totalRow = db.prepare( `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`, ).get(requestingUserId, ...searchParams) as { count: number } | undefined; - const items = rows.filter(isDumpRow).map(dumpRowToApi); + const items = parseRows(rows, isDumpRow, dumpRowToApi); attachCategoryIds(items); return { items, total: totalRow?.count ?? 0 }; } else { @@ -351,7 +353,7 @@ export function searchDumps( const totalRow = db.prepare( `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`, ).get(...searchParams) as { count: number } | undefined; - const items = rows.filter(isDumpRow).map(dumpRowToApi); + const items = parseRows(rows, isDumpRow, dumpRowToApi); attachCategoryIds(items); return { items, total: totalRow?.count ?? 0 }; } diff --git a/api/services/email-service.ts b/api/services/email-service.ts index 49ac893f..1da82761 100644 --- a/api/services/email-service.ts +++ b/api/services/email-service.ts @@ -78,18 +78,6 @@ function getTransporter(): Transporter { return transporter; } -/** - * Checks the connection to the SMTP server, or resolves false when email is - * disabled. Nothing calls it yet. - * - * @throws {Error} When the server can't be reached or refuses the login. - */ -export async function verifyEmailTransport(): Promise { - if (!isEmailEnabled()) return false; - await getTransporter().verify(); - return true; -} - /** * Sends `message` and resolves once the SMTP server has accepted it. The * transport is created on the first send. diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index bdd7a4f0..b3a5af4e 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -30,12 +30,16 @@ import { attachCategoryIds } from "./category-service.ts"; * Makes `followerId` follow `followedUserId`, and notifies the followed user. * Following again changes nothing. * - * @throws {APIException} 400 when a user tries to follow themselves. + * @throws {APIException} 400 when a user tries to follow themselves, 404 when + * there is no such user. */ export function followUser(followerId: string, followedUserId: string): void { if (followerId === followedUserId) { throw new APIException(400, "Cannot follow yourself"); } + if (!db.prepare(`SELECT 1 FROM users WHERE id = ?;`).get(followedUserId)) { + throw new APIException(404, "User not found"); + } // The unique index answers "already following" without a race. let isNew = true; try { @@ -71,7 +75,7 @@ export function unfollowUser(followerId: string, followedUserId: string): void { * Following again changes nothing. A follow outlives the playlist going * private, but the feeds and lists skip it while it is. * - * @throws {APIException} 404 when there is no such playlist, 403 when it is + * @throws {APIException} 404 when there is no such playlist, or when it is * private. */ export function followPlaylist(followerId: string, playlistId: string): void { @@ -82,8 +86,9 @@ export function followPlaylist(followerId: string, playlistId: string): void { if (!row) { throw new APIException(404, "Playlist not found"); } + // Private is answered as missing, so as not to confirm that it exists. if (!row.is_public) { - throw new APIException(403, "Cannot follow a private playlist"); + throw new APIException(404, "Playlist not found"); } let isNew = true; diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 4f84f80b..c9dc8d58 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -22,7 +22,7 @@ import { } from "../model/notification.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { sendToUser } from "./ws-service.ts"; -import { MENTION_RE } from "../../shared/mention.ts"; +import { mentionedUsernames } from "../../shared/mention.ts"; /** * Stores a notification for `userId` and pushes it live. With a `sourceKey`, @@ -113,16 +113,6 @@ export function markAllRead(userId: string): void { ); } -/** - * Marks one of `userId`'s notifications read. An id that isn't theirs changes - * nothing, and says nothing. - */ -export function markOneRead(notificationId: string, userId: string): void { - db.prepare( - `UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`, - ).run(notificationId, userId); -} - /** * Notifies `followedUserId` that `followerId` follows them, once however often * they unfollow and follow again. `followUser` calls it. @@ -301,11 +291,7 @@ export function notifyMentions( ).get(mentionerUserId) as { username: string } | undefined; if (!mentionerRow) return; - const usernames = [ - ...new Set( - [...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase()), - ), - ]; + const usernames = mentionedUsernames(body); for (const username of usernames) { const mentionedRow = db.prepare( diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 901297e3..a4303d98 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -18,7 +18,7 @@ import type { import type { Dump } from "../../shared/dump.ts"; import { db, transaction } from "../db/connection.ts"; import { - DUMP_COLUMNS_ALIASED, + DUMP_SELECT_COLUMNS_ALIASED, dumpRowToApi, isDumpRow, } from "../model/dump.ts"; @@ -40,6 +40,8 @@ import { isMemberVisibleTo, type PlaylistMember, } from "../lib/playlist-members.ts"; +import { LIKE, likePattern } from "../lib/like.ts"; +import { parseRows } from "../model/rows.ts"; /** * The select list and `FROM` clause of a playlist query, adding the owner's @@ -78,14 +80,15 @@ export function createPlaylist( ): Playlist { const id = crypto.randomUUID(); const createdAt = new Date(); - const slug = makeSlug(req.title, id); + const title = req.title.trim(); + const slug = makeSlug(title, id); db.prepare( `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( id, userId, - req.title, + title, slug, req.description ?? null, req.isPublic ? 1 : 0, @@ -94,7 +97,7 @@ export function createPlaylist( const playlist: Playlist = { id, userId, - title: req.title, + title, slug, description: req.description, isPublic: req.isPublic, @@ -103,7 +106,7 @@ export function createPlaylist( if (req.description) { // A private playlist's mentions are notified if it goes public. if (req.isPublic) { - notifyMentions(userId, req.description, "playlist", id, req.title); + notifyMentions(userId, req.description, "playlist", id, title); } linkAttachments(req.description, "playlist", id); } @@ -130,8 +133,7 @@ export function getPlaylist( } const rows = db.prepare( - `SELECT ${DUMP_COLUMNS_ALIASED}, - (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count + `SELECT ${DUMP_SELECT_COLUMNS_ALIASED} FROM dumps d INNER JOIN playlist_dumps pd ON d.id = pd.dump_id WHERE pd.playlist_id = ? @@ -139,7 +141,7 @@ export function getPlaylist( ORDER BY pd.position ASC;`, ).all(playlist.id, requestingUserId ?? ""); - const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi); + const dumps: Dump[] = parseRows(rows, isDumpRow, dumpRowToApi); const visibleDumps = dumps.filter((d) => isMemberVisibleTo(d, playlist, requestingUserId) ); @@ -158,8 +160,8 @@ export function searchPlaylists( requestingUserId?: string, ): Playlist[] { if (!query.trim()) return []; - const pattern = `%${query}%`; - const searchClause = `(p.title LIKE ? OR p.description LIKE ?)`; + const pattern = likePattern(query); + const searchClause = `(p.title ${LIKE} OR p.description ${LIKE})`; const rows = requestingUserId ? db.prepare( @@ -169,7 +171,7 @@ export function searchPlaylists( `SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, ).all(pattern, pattern, limit); - return rows.filter(isPlaylistRow).map(playlistRowToApi); + return parseRows(rows, isPlaylistRow, playlistRowToApi); } /** @@ -191,7 +193,7 @@ export function listPlaylistsByUser( : `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`; const rows = db.prepare(sql).all(userId, limit + 1, offset); - return pageOf(rows.filter(isPlaylistRow).map(playlistRowToApi), limit); + return pageOf(parseRows(rows, isPlaylistRow, playlistRowToApi), limit); } /** @@ -213,10 +215,10 @@ export function updatePlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(403, "Forbidden"); + throw new APIException(403, "Only its owner or a moderator may do that"); } - const newTitle = req.title ?? playlist.title; + const newTitle = req.title?.trim() ?? playlist.title; const newDescription = "description" in req ? (req.description ?? null) : (playlist.description ?? null); @@ -276,7 +278,7 @@ export function deletePlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(403, "Forbidden"); + throw new APIException(403, "Only its owner or a moderator may do that"); } db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id); @@ -298,7 +300,7 @@ export function getEditablePlaylist( ): Playlist { const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(403, "Forbidden"); + throw new APIException(403, "Only its owner or a moderator may do that"); } return playlist; } @@ -352,7 +354,7 @@ export function addDumpToPlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(403, "Forbidden"); + throw new APIException(403, "Only its owner or a moderator may do that"); } // Else someone else's private dump would land in the playlist, and its title @@ -416,7 +418,7 @@ export function removeDumpFromPlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(403, "Forbidden"); + throw new APIException(403, "Only its owner or a moderator may do that"); } db.prepare( @@ -444,7 +446,7 @@ export function reorderPlaylist( const playlist = getPlaylistById(playlistId); if (playlist.userId !== requestingUserId && !canModerate) { - throw new APIException(403, "Forbidden"); + throw new APIException(403, "Only its owner or a moderator may do that"); } // The requester can only name the dumps GET showed them. A hidden one @@ -530,7 +532,7 @@ export function getPlaylistsForDump( ORDER BY p.created_at DESC LIMIT 20;`, ).all(dumpId, requestingUserId ?? ""); - return rows.filter(isPlaylistRow).map(playlistRowToApi); + return parseRows(rows, isPlaylistRow, playlistRowToApi); } /** diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 4578ef77..519cf7ac 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -24,10 +24,12 @@ import { userRowToApi, } from "../model/user.ts"; import { db } from "../db/connection.ts"; -import { closeUserSockets, disconnectUser } from "./ws-service.ts"; +import { closeUserSockets } from "./ws-service.ts"; import { hashPassword } from "../lib/password.ts"; import { linkAttachments } from "./attachment-service.ts"; +import { LIKE, likePattern } from "../lib/like.ts"; +import { parseRows } from "../model/rows.ts"; /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ const USER_SELECT = @@ -194,8 +196,8 @@ export function searchUsers( ): UserSuggestion[] { if (!query) return []; const rows = db.prepare( - `SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`, - ).all(`${query}%`, limit) as { + `SELECT id, username, avatar_mime FROM users WHERE username ${LIKE} ORDER BY username LIMIT ?;`, + ).all(likePattern(query, "prefix"), limit) as { id: string; username: string; avatar_mime: string | null; @@ -213,28 +215,11 @@ export function searchUsers( */ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { if (!query.trim()) return []; - const pattern = `%${query}%`; + const pattern = likePattern(query); const rows = db.prepare( - `${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`, + `${USER_SELECT} WHERE (u.username ${LIKE} OR u.description ${LIKE}) ORDER BY u.username LIMIT ?;`, ).all(pattern, pattern, limit); - return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser); -} - -/** - * Returns every user. Nothing calls it. - * - * @throws {APIException} 404 when a row doesn't parse as a user. - */ -export function listUsers(): User[] { - const userRows = db.prepare( - `${USER_SELECT}`, - ).all(); - - if (!userRows || !userRows.every(isUserRow)) { - throw new APIException(404, "No user found"); - } - - return userRows.map(userRowToApi); + return parseRows(rows, isUserRow, userRowToApi).map(toPublicUser); } /** @@ -357,20 +342,3 @@ export function getInviteTree(userId: string): Invitee[] { createdAt: new Date(r.created_at), })); } - -/** - * Deletes user `userId` and disconnects their sockets. Nothing calls it. - * - * @throws {APIException} 404 when there is no such user. - */ -export function deleteUser(userId: string): void { - disconnectUser(userId); - - const result = db.prepare( - `DELETE FROM users WHERE id = ?;`, - ).run(userId); - - if (result.changes === 0) { - throw new APIException(404, "User not found"); - } -} diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index 41811ec6..3f7de3b9 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -10,7 +10,7 @@ import { APIException } from "../lib/api-exception.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; -import { db } from "../db/connection.ts"; +import { db, transaction } from "../db/connection.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts"; @@ -49,22 +49,19 @@ export function castVote(dumpId: string, userId: string): VoteResult { const { isPrivate } = visibleDump(dumpId, userId); let voteCount: number; try { - db.exec("BEGIN;"); - db.prepare( - `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, - ).run(dumpId, userId, new Date().toISOString()); - db.prepare( - `UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`, - ).run(dumpId); - const row = db.prepare( - `SELECT vote_count FROM dumps WHERE id = ?;`, - ).get(dumpId) as { vote_count: number } | undefined; - db.exec("COMMIT;"); - voteCount = row?.vote_count ?? 0; + voteCount = transaction(() => { + db.prepare( + `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, + ).run(dumpId, userId, new Date().toISOString()); + db.prepare( + `UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`, + ).run(dumpId); + const row = db.prepare( + `SELECT vote_count FROM dumps WHERE id = ?;`, + ).get(dumpId) as { vote_count: number }; + return row.vote_count; + }); } catch (err) { - try { - db.exec("ROLLBACK;"); - } catch { /* ignore if no active transaction */ } if (err instanceof Error && err.message.includes("UNIQUE constraint")) { throw new APIException(409, "Already voted"); } @@ -89,28 +86,21 @@ export function removeVote(dumpId: string, userId: string): VoteResult { dumpId, ) as { is_private: number } | undefined; const isPrivate = Boolean(row?.is_private); - try { - db.exec("BEGIN;"); + return transaction(() => { const result = db.prepare( `DELETE FROM votes WHERE dump_id = ? AND user_id = ?;`, ).run(dumpId, userId); if (result.changes === 0) { - db.exec("ROLLBACK;"); throw new APIException(404, "Vote not found"); } db.prepare( `UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`, ).run(dumpId); - const count = db.prepare( + const row = db.prepare( `SELECT vote_count FROM dumps WHERE id = ?;`, - ).get(dumpId) as { vote_count: number } | undefined; - db.exec("COMMIT;"); - return { voteCount: count?.vote_count ?? 0, isPrivate }; - } catch (err) { - if (err instanceof APIException) throw err; - db.exec("ROLLBACK;"); - throw err; - } + ).get(dumpId) as { vote_count: number }; + return { voteCount: row.vote_count, isPrivate }; + }); } /** diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 846ace3f..5a6a055e 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -135,19 +135,6 @@ export function sendToUser(userId: string, data: ServerToClientMessage): void { broadcast(data, (client) => client.userId === userId); } -/** - * Sends `force_logout` to every socket of `userId`, then closes them. The user - * service calls it before deleting the account, so every tab signs out. - */ -export function disconnectUser(userId: string): void { - for (const client of clients) { - if (client.userId === userId) { - send(client.socket, { type: "force_logout" }); - client.socket.close(1000, "Account deleted"); - } - } -} - /** * Closes every socket of `userId` without signing anyone out. The user service * calls it on a password change. Each tab reconnects with the token it holds: diff --git a/api/test/lib/reserved-slugs.test.ts b/api/test/lib/reserved-slugs.test.ts index 95b922a9..ee4f6c36 100644 --- a/api/test/lib/reserved-slugs.test.ts +++ b/api/test/lib/reserved-slugs.test.ts @@ -1,10 +1,6 @@ import "../setup.ts"; import { assert, assertFalse } from "@std/assert"; -import { - isReservedSlug, - isSlugAvailable, - isValidSlugFormat, -} from "../../lib/reserved-slugs.ts"; +import { isReservedSlug, isValidSlugFormat } from "../../lib/reserved-slugs.ts"; Deno.test("category slugs: format and reserved words", () => { assert(isValidSlugFormat("music")); @@ -14,8 +10,7 @@ Deno.test("category slugs: format and reserved words", () => { } assert(isReservedSlug("dumps")); assert(isReservedSlug("api")); + assert(isReservedSlug("ws")); + assert(isReservedSlug("emojibase")); assertFalse(isReservedSlug("music")); - assert(isSlugAvailable("music")); - assertFalse(isSlugAvailable("users")); - assertFalse(isSlugAvailable("Nope")); }); diff --git a/api/test/remote/fetch.test.ts b/api/test/remote/fetch.test.ts index 5dc15806..b8281629 100644 --- a/api/test/remote/fetch.test.ts +++ b/api/test/remote/fetch.test.ts @@ -89,3 +89,24 @@ Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () => "Unsupported protocol", ); }); + +Deno.test("readTextCapped: decodes the charset the header or a declares", async () => { + const cafe = Uint8Array.from([0x63, 0x61, 0x66, 0xe9]); // "café" in Latin-1 + const page = (contentType: string, head = "") => + new Response( + new Uint8Array([...new TextEncoder().encode(head), ...cafe]), + { headers: { "content-type": contentType } }, + ); + assertEquals( + await readTextCapped(page("text/html; charset=ISO-8859-1")), + "café", + ); + assertEquals( + await readTextCapped(page("text/html", '')), + 'café', + ); + assertEquals( + await readTextCapped(page("text/html; charset=nonsense")), + "caf�", + ); +}); diff --git a/api/test/remote/html.test.ts b/api/test/remote/html.test.ts index c5f74561..8479a46d 100644 --- a/api/test/remote/html.test.ts +++ b/api/test/remote/html.test.ts @@ -5,7 +5,6 @@ import { extractFirstContentImage, extractJsonLd, extractLargeImage, - extractLinkHref, extractMaskIconColor, extractMetaName, extractOgTag, @@ -156,8 +155,4 @@ Deno.test("extractBestIcon: SVG beats everything, else the largest", () => { "https://example.com/icon", ); assertEquals(extractBestIcon("

", BASE), undefined); - assertEquals( - extractLinkHref(``, "alternate", BASE), - "https://example.com/feed", - ); }); diff --git a/api/test/routes/dumps.test.ts b/api/test/routes/dumps.test.ts index 1d8af497..f20e3971 100644 --- a/api/test/routes/dumps.test.ts +++ b/api/test/routes/dumps.test.ts @@ -358,14 +358,14 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones" isPrivate: true, }); using _ = stubNetwork(() => PAGE.clone()); - // A new dump whose comment links the target by its URL and the secret dump by - // its page — and itself, which is ignored. + // A new dump whose comment links the target by another shape of its URL, + // and the secret dump by its page — and itself, which is ignored. const src = await post("/api/dumps", { auth: other.auth, json: { url: "https://example.com/src", comment: - `see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, + `see https://www.x.test/t/?utm_source=feed and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, }, }); const srcId = src.data.id; diff --git a/api/test/routes/follows.test.ts b/api/test/routes/follows.test.ts index 3224824a..f19a8587 100644 --- a/api/test/routes/follows.test.ts +++ b/api/test/routes/follows.test.ts @@ -37,6 +37,12 @@ test("following users: status, idempotence, no self-follow, one notification", a (await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status, 400, ); + assertEquals( + (await post(`/api/follows/users/${crypto.randomUUID()}`, { + auth: me.auth, + })).status, + 404, + ); assertEquals( (await get("/api/follows/status", { auth: me.auth })).data, { followedUserIds: [them.id], followedPlaylistIds: [] }, @@ -76,7 +82,7 @@ test("following playlists: public ones only, and not your own notification", asy ); assertEquals( (await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status, - 403, + 404, ); assertEquals( (await post(`/api/follows/playlists/${crypto.randomUUID()}`, { diff --git a/api/test/routes/notifications.test.ts b/api/test/routes/notifications.test.ts index 10998940..44dba8ff 100644 --- a/api/test/routes/notifications.test.ts +++ b/api/test/routes/notifications.test.ts @@ -1,12 +1,4 @@ -import { - count, - createUser, - get, - insertDump, - patch, - post, - test, -} from "../helpers.ts"; +import { count, createUser, get, insertDump, post, test } from "../helpers.ts"; import type { Notification } from "../../../shared/notification.ts"; import type { PaginatedData } from "../../../shared/api.ts"; import { assertEquals } from "@std/assert"; @@ -36,30 +28,6 @@ test("notifications: listed newest first, marked read one or all, never someone true, ); - const theirs = (await get>("/api/notifications", { - auth: other.auth, - })).data - .items[0].id; - assertEquals( - (await patch(`/api/notifications/${theirs}/read`, { auth: me.auth })) - .status, - 204, - ); - assertEquals( - (await get>("/api/notifications", { - auth: other.auth, - })).data.items[0] - .read, - false, - ); - - const first = list.data.items[0].id; - await patch(`/api/notifications/${first}/read`, { auth: me.auth }); - const after = (await get>("/api/notifications", { - auth: me.auth, - })).data.items; - assertEquals(after.map((n) => n.read), [true, false]); - assertEquals( (await post("/api/notifications/read-all", { auth: me.auth })).status, 204, diff --git a/api/test/routes/preview.test.ts b/api/test/routes/preview.test.ts index da28c868..f5f45579 100644 --- a/api/test/routes/preview.test.ts +++ b/api/test/routes/preview.test.ts @@ -25,3 +25,22 @@ Deno.test("GET /api/proxy-image: rasters only, fetched once for everyone", async ); assertEquals(svg.status, 400); }); + +Deno.test("GET /api/proxy-image: an error answer is never served nor cached", async () => { + let failing = true; + using net = stubNetwork(() => + failing + ? new Response(PNG, { + status: 503, + headers: { "content-type": "image/png" }, + }) + : new Response(PNG, { headers: { "content-type": "image/png" } }) + ); + const path = `/api/proxy-image?url=${ + encodeURIComponent("https://cdn.example.com/flaky.png") + }`; + assertEquals((await get(path)).status, 400); + failing = false; + assertEquals((await get(path)).status, 200); + assertEquals(net.calls.length, 2); +}); diff --git a/api/test/routes/users.test.ts b/api/test/routes/users.test.ts index a9643184..4381bfd1 100644 --- a/api/test/routes/users.test.ts +++ b/api/test/routes/users.test.ts @@ -163,6 +163,14 @@ test("GET /api/users/search: prefix match for mentions, capped at 8", async () = avatarMime: null, }); assertEquals((await get("/api/users/search?q=")).data, []); + // An underscore is a letter of the name, not a wildcard. + await createUser({ username: "zedx9" }); + assertEquals( + (await get("/api/users/search?q=zed_9")).data.map((u) => + u.username + ), + ["zed_9"], + ); }); test("GET /api/users/:username/invitees: the invite tree below a user", async () => { diff --git a/api/test/services/notification-service.test.ts b/api/test/services/notification-service.test.ts index 2bbfd6b0..bddd52be 100644 --- a/api/test/services/notification-service.test.ts +++ b/api/test/services/notification-service.test.ts @@ -46,3 +46,23 @@ test("mentions: notify the named, once per context, never the author or an email 1, ); }); + +test("mentions: code and URLs mention no one", async () => { + const author = await createUser(); + const named = await createUser(); + const { id: dumpId } = insertDump({ userId: author.id }); + const n = named.username; + await post(`/api/dumps/${dumpId}/comments`, { + auth: author.auth, + json: { + body: + `\`@${n}\` and https://mastodon.social/@${n}\n\`\`\`\n@${n}\n\`\`\``, + }, + }); + assertEquals( + count( + `SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned';`, + ), + 0, + ); +}); diff --git a/api/test/validators/dump.test.ts b/api/test/validators/dump.test.ts index 2187ecf7..264cf6e2 100644 --- a/api/test/validators/dump.test.ts +++ b/api/test/validators/dump.test.ts @@ -11,12 +11,14 @@ Deno.test("dump requests", () => { isCreateUrlDumpRequest({ url: "https://x", title: "t", - comment: null, + comment: "c", isPrivate: true, categoryIds: ["a"], }), ); assertFalse(isCreateUrlDumpRequest({})); + assertFalse(isCreateUrlDumpRequest({ url: "https://x", comment: null })); + assertFalse(isCreateUrlDumpRequest({ url: "https://x", title: null })); assertFalse( isCreateUrlDumpRequest({ url: "https://x", title: "x".repeat(201) }), ); @@ -27,7 +29,8 @@ Deno.test("dump requests", () => { assertFalse(isCreateUrlDumpRequest({ url: "https://x", categoryIds: [1] })); assert(isUpdateDumpRequest({})); - assert(isUpdateDumpRequest({ comment: null, url: null })); + assert(isUpdateDumpRequest({ comment: null })); + assertFalse(isUpdateDumpRequest({ url: null })); assertFalse(isUpdateDumpRequest({ title: " " })); assertFalse(isUpdateDumpRequest({ title: "x".repeat(201) })); assertFalse(isUpdateDumpRequest({ categoryIds: "a" })); diff --git a/api/validators/dump.ts b/api/validators/dump.ts index a3bc8130..62079ca4 100644 --- a/api/validators/dump.ts +++ b/api/validators/dump.ts @@ -29,16 +29,12 @@ export function isCreateUrlDumpRequest( !("url" in obj) || typeof obj.url !== "string" ) return false; const o = obj as Record; - if ( - "comment" in o && typeof o.comment !== "string" && o.comment !== null - ) return false; + if ("comment" in o && typeof o.comment !== "string") return false; if ( typeof o.comment === "string" && (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX ) return false; - if ("title" in o && typeof o.title !== "string" && o.title !== null) { - return false; - } + if ("title" in o && typeof o.title !== "string") return false; if ( typeof o.title === "string" && (o.title as string).length > VALIDATION.DUMP_TITLE_MAX @@ -56,7 +52,7 @@ export function isCreateUrlDumpRequest( export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; - if ("url" in o && typeof o.url !== "string" && o.url !== null) return false; + if ("url" in o && typeof o.url !== "string") return false; if ("title" in o) { if (typeof o.title !== "string") return false; const trimmed = (o.title as string).trim(); diff --git a/api/validators/playlist.ts b/api/validators/playlist.ts index 05a630db..5a4650a7 100644 --- a/api/validators/playlist.ts +++ b/api/validators/playlist.ts @@ -11,10 +11,16 @@ import type { } from "../../shared/playlist.ts"; import { VALIDATION } from "../../shared/validation.ts"; +/** Checks that `title` isn't blank and, trimmed, fits the limit. */ +function isValidTitle(title: string): boolean { + const trimmed = title.trim(); + return trimmed.length > 0 && trimmed.length <= VALIDATION.PLAYLIST_TITLE_MAX; +} + /** - * Checks whether `obj` is a {@link CreatePlaylistRequest} with a title, and a - * title and description within the `VALIDATION` limits. A null description - * passes. A title of spaces passes too. + * Checks whether `obj` is a {@link CreatePlaylistRequest} with a title that + * isn't blank, and a title and description within the `VALIDATION` limits. A + * null description passes. */ export function isCreatePlaylistRequest( obj: unknown, @@ -25,10 +31,7 @@ export function isCreatePlaylistRequest( !("isPublic" in obj) || typeof obj.isPublic !== "boolean" ) return false; const o = obj as Record; - if ( - (o.title as string).length === 0 || - (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX - ) return false; + if (!isValidTitle(o.title as string)) return false; if ( "description" in o && typeof o.description !== "string" && o.description !== null @@ -42,7 +45,7 @@ export function isCreatePlaylistRequest( /** * Checks whether `obj` is an {@link UpdatePlaylistRequest} whose title, if - * given, isn't empty, with a title and description within the `VALIDATION` + * given, isn't blank, with a title and description within the `VALIDATION` * limits. */ export function isUpdatePlaylistRequest( @@ -52,10 +55,7 @@ export function isUpdatePlaylistRequest( const o = obj as Record; if ("title" in o) { if (typeof o.title !== "string") return false; - if ( - (o.title as string).length === 0 || - (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX - ) return false; + if (!isValidTitle(o.title as string)) return false; } if ( "description" in o && typeof o.description !== "string" && diff --git a/api/validators/user.ts b/api/validators/user.ts index 479d707d..6d5d349c 100644 --- a/api/validators/user.ts +++ b/api/validators/user.ts @@ -10,7 +10,7 @@ import { type RegisterUserRequest, type UpdateUserRequest, } from "../../shared/user.ts"; -import { VALIDATION } from "../../shared/validation.ts"; +import { EMAIL_RE, USERNAME_RE, VALIDATION } from "../../shared/validation.ts"; /** * Checks whether `obj` is a {@link LoginUserRequest}. It checks the types @@ -57,15 +57,10 @@ export function validateRegisterUserRequest(obj: unknown): string | null { !("email" in obj) || typeof obj.email !== "string" ) return "Invalid request"; const { username, password, email } = obj as RegisterUserRequest; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + if (!EMAIL_RE.test(email)) { return "Invalid email address"; } - if ( - !new RegExp( - `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, - ) - .test(username) - ) { + if (!USERNAME_RE.test(username)) { return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`; } if (password.length < VALIDATION.PASSWORD_MIN) { @@ -80,15 +75,14 @@ export function validateRegisterUserRequest(obj: unknown): string | null { /** * Checks whether `obj` is a valid {@link UpdateUserRequest}, for * `PATCH /api/users/me`. Every field is optional, but each one present must be - * well-formed and within the `VALIDATION` limits. The username's limits here - * are not those of sign-up, which the route never lets it change anyway. + * well-formed and within the `VALIDATION` limits. */ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; if ("username" in o) { if (typeof o.username !== "string") return false; - if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false; + if (!USERNAME_RE.test(o.username as string)) return false; } if ("password" in o) { if (typeof o.password !== "string") return false; @@ -104,7 +98,7 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { ) return false; if ("email" in o) { if (typeof o.email !== "string") return false; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(o.email as string)) return false; + if (!EMAIL_RE.test(o.email as string)) return false; } if ( typeof o.description === "string" && diff --git a/shared/mention.ts b/shared/mention.ts index 3c90c750..a0dee2f2 100644 --- a/shared/mention.ts +++ b/shared/mention.ts @@ -1,6 +1,6 @@ /** * Defines what counts as an @mention, so that the server notifies the same - * users the chat highlights. + * users the chat highlights and the editor suggests. * * @module */ @@ -9,9 +9,25 @@ * Matches an @username, with the name as its first group. The `@` must not * follow a word character, so an email address mentions no one. Nor may it * follow `[` or `(`, which leaves out a Markdown link's text or URL starting - * with `@`, and also a plain `(@name)`. - * `notifyMentions` notifies the users it matches, and the chat's - * `mentionsUser` highlights and chimes by it. `Markdown` links mentions with a - * looser pattern of its own, on text already parsed. + * with `@`, and also a plain `(@name)`. The editor's suggestions match its + * source at the caret; the rest goes through {@link mentionedUsernames}. + * `Markdown` links mentions with a looser pattern of its own, on text already + * parsed, where code and URLs are out of the way. */ export const MENTION_RE: RegExp = /(? m[1].toLowerCase())), + ]; +} diff --git a/shared/validation.ts b/shared/validation.ts index 2ce2a9e6..c185e919 100644 --- a/shared/validation.ts +++ b/shared/validation.ts @@ -28,6 +28,21 @@ export const VALIDATION = { CHAT_MESSAGE_MAX: 2000, } as const; +/** + * Matches a valid username: letters, digits and underscores, within the + * `VALIDATION` limits. Sign-up checks it on both sides; it is the same `\w` + * that a mention's pattern reads a name with. + */ +export const USERNAME_RE: RegExp = new RegExp( + `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, +); + +/** + * Matches an email address well enough to catch a typo; only the reset email + * arriving proves it. + */ +export const EMAIL_RE: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + /** * The largest file a file dump may hold, in bytes. dump-service refuses a * bigger one, and the create form before uploading it. diff --git a/shared/ws.ts b/shared/ws.ts index 304be70f..02d60ef1 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -385,14 +385,6 @@ export interface ErrorMessage { nonce?: string; } -/** - * Sent by the server to every socket of a user whose account is being deleted, - * just before it closes them. `WSProvider` signs the tab out. - */ -export interface ForceLogoutMessage { - type: "force_logout"; -} - /** * Sent by the server to every signed-in socket, the sender's included, when a * chat message is posted. `WSProvider` adds it to the live chat, counts it @@ -473,7 +465,6 @@ export type ServerToClientMessage = | CommentDeletedMessage | NotificationCreatedMessage | ErrorMessage - | ForceLogoutMessage | ChatMessageMessage | ChatMessageUpdatedMessage | ChatMessageDeletedMessage diff --git a/src/contexts/ws/WSProvider.tsx b/src/contexts/ws/WSProvider.tsx index 5819d80a..6fa70785 100644 --- a/src/contexts/ws/WSProvider.tsx +++ b/src/contexts/ws/WSProvider.tsx @@ -128,10 +128,10 @@ function parseWSMessage(data: string): Wire | null { * anonymously, and reconnects with a backoff doubling up to 30 s. A new token * (sign-in, sign-out, another account) clears the live state and reconnects. * A new notification or chat message chimes, and the unread counts go in the - * tab title. A `force_logout` signs the user out. + * tab title. */ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { - const { token, user, logout } = useAuth(); + const { token, user } = useAuth(); const userId = user?.id ?? null; const [wsStatus, setWSStatus] = useState< "connecting" | "connected" | "disconnected" @@ -223,7 +223,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const myLikesRef = useRef(myLikes); const userIdRef = useRef(userId); const usernameRef = useRef(user?.username ?? null); - const onForceLogoutRef = useRef(logout); useLayoutEffect(() => { voteCountsRef.current = voteCounts; myVotesRef.current = myVotes; @@ -231,7 +230,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { myLikesRef.current = myLikes; userIdRef.current = userId; usernameRef.current = user?.username ?? null; - onForceLogoutRef.current = logout; }); const socketRef = useRef(null); @@ -683,10 +681,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { break; } - case "force_logout": - onForceLogoutRef.current(); - break; - case "error": { if (msg.scope === "chat") { const message = msg.message ?? t`Something went wrong`; diff --git a/src/hooks/editor/useMentionAutocomplete.ts b/src/hooks/editor/useMentionAutocomplete.ts index bd131d95..a53ad453 100644 --- a/src/hooks/editor/useMentionAutocomplete.ts +++ b/src/hooks/editor/useMentionAutocomplete.ts @@ -16,6 +16,7 @@ import { import { API_URL } from "../../config/api.ts"; import { insideCode } from "../../utils/editor/text.ts"; import type { UserSuggestion } from "../../../shared/user.ts"; +import { MENTION_RE } from "../../../shared/mention.ts"; interface MentionState { open: boolean; @@ -40,7 +41,7 @@ const CLOSED: MentionState = { * Matches the `@query` ending at the caret by the rule of shared/mention.ts, * which the server notifies by, so that what is offered would notify. */ -const TRIGGER_RE = /(? { expect(ws().connectionEpoch).toBe(1); }); - test("force_logout signs out", () => { - const { socket, hook } = setup(); - socket.welcome(); - socket.receive({ type: "force_logout" }); - expect(hook.result.current.auth.user).toBeNull(); - }); - test("signing out resets live state and reconnects as a guest", () => { const { socket, hook, ws } = setup(); socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 }); diff --git a/src/utils/chat/text.ts b/src/utils/chat/text.ts index 63249bf6..6461829f 100644 --- a/src/utils/chat/text.ts +++ b/src/utils/chat/text.ts @@ -5,7 +5,7 @@ * @module */ -import { MENTION_RE } from "../../../shared/mention.ts"; +import { mentionedUsernames } from "../../../shared/mention.ts"; /** * Checks whether `body` @mentions `username`, ignoring case, with the pattern @@ -13,11 +13,7 @@ import { MENTION_RE } from "../../../shared/mention.ts"; * chime, and `ChatModal` to highlight the message. */ export function mentionsUser(body: string, username: string): boolean { - const target = username.toLowerCase(); - for (const m of body.matchAll(MENTION_RE)) { - if (m[1].toLowerCase() === target) return true; - } - return false; + return mentionedUsernames(body).includes(username.toLowerCase()); } /**