v3: fix the backend's correctness findings — each by a rule shared across the code rather than one more special case. Link previews: readTextCapped decoded every page as UTF-8, so a Latin-1 page came out garbled unless it went through the curl fallback, which re-encoded it on its own; pages are now decoded in the charset their Content-Type or <meta> declares, and the curl fallback hands the bytes over as they are. The image proxy served and cached for a day an error answer that claimed an image type, and read a body without Content-Length whole before checking its size; it now refuses an error status and reads through the same capped reader. The icons task's Twemoji fetch gives up after 15 s instead of holding the start up, and extractLinkHref, which only its tests used, is gone. Backlinks matched a linked URL against the raw url column, so https://x.com/ missed a dump posted as https://x.com; they now compare canonical URLs, as reposts do. The four LIKE searches put the query in raw, so % and _ were wildcards and the mention search for "a_b" found "axb"; lib/like.ts escapes them. Every list query now treats a malformed row the same way, a 500 through model/rows.ts's parseRows, where ten of them silently dropped it; the playlist dumps query uses the model's column list rather than a hand-written copy, and the aliased list is derived from the plain one. The vote and like services commit through transaction() instead of their own BEGIN and ROLLBACK, which would have rolled back a caller's transaction.

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 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 19:35:53 +00:00
parent 14e3ab125b
commit a36a4cac6b
45 changed files with 363 additions and 442 deletions

24
api/lib/like.ts Normal file
View File

@@ -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 '\\'";

View File

@@ -8,9 +8,10 @@
/** /**
* The top-level path segments a category slug may not take. They are the SPA's * 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 * own routes (`src/App.tsx`); `api`, `ws`, `assets` and `emojibase`, which the
* before the SPA, and `manage`, which no route uses any more. Keep it in step * server answers before the SPA; and `manage`, a former route whose old links
* with the route table. * 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[] = [ export const RESERVED_SLUGS: readonly string[] = [
"~", "~",
@@ -24,7 +25,9 @@ export const RESERVED_SLUGS: readonly string[] = [
"reset-password", "reset-password",
"manage", "manage",
"api", "api",
"ws",
"assets", "assets",
"emojibase",
]; ];
/** /**
@@ -42,11 +45,3 @@ export function isValidSlugFormat(slug: string): boolean {
export function isReservedSlug(slug: string): boolean { export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.includes(slug); 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);
}

View File

@@ -63,23 +63,6 @@ export async function authMiddleware<R extends string>(
await next(); 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<R extends string>(
ctx: AuthContext<R>,
next: Next,
): Promise<void> {
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 * Returns a middleware that requires the caller to hold `permission`. It must
* run after {@link authMiddleware}, as in * run after {@link authMiddleware}, as in

View File

@@ -41,18 +41,14 @@ export interface DumpRow {
/** /**
* Lists the stored columns of {@link DumpRow}. A column added to the table must * 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 * be added here, to {@link DumpRow} and to {@link isDumpRow}.
* {@link isDumpRow}.
*/ */
export const DUMP_COLUMNS = 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"; "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";
/** // The same columns, for a query that aliases `dumps` as `d`.
* Lists {@link DUMP_COLUMNS} for a query that aliases `dumps` as `d`. The const DUMP_COLUMNS_ALIASED = DUMP_COLUMNS.split(", ").map((c) => `d.${c}`)
* playlist service selects with it and counts the comments itself. .join(", ");
*/
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";
/** /**
* Selects a whole {@link DumpRow}, the stored columns and the computed * Selects a whole {@link DumpRow}, the stored columns and the computed

25
api/model/rows.ts Normal file
View File

@@ -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<R, T>(
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);
}

View File

@@ -77,20 +77,7 @@ async function fetchViaCurl(
if (contentType) headers.set("content-type", contentType); if (contentType) headers.set("content-type", contentType);
const bytes = await Deno.readFile(tmpPath); const bytes = await Deno.readFile(tmpPath);
if (!contentType.startsWith("text/")) { return new Response(bytes, { status: statusCode, headers });
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 });
} catch { } catch {
return null; return null;
} finally { } finally {
@@ -137,15 +124,14 @@ function withDeadline(
} }
/** /**
* Reads the body of `res` as UTF-8 text, up to `maxBytes`; the rest is * Reads the body of `res` up to `maxBytes`; the rest is cancelled, never
* cancelled, never downloaded. The providers and the Bandcamp and YouTube * downloaded. The image proxy reads through it, and {@link readTextCapped}.
* resolvers read pages through it.
*/ */
export async function readTextCapped( export async function readBytesCapped(
res: Response, res: Response,
maxBytes = MAX_PAGE_BYTES, maxBytes: number,
): Promise<string> { ): Promise<Uint8Array> {
if (!res.body) return ""; if (!res.body) return new Uint8Array();
const reader = res.body.getReader(); const reader = res.body.getReader();
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
let size = 0; let size = 0;
@@ -167,7 +153,39 @@ export async function readTextCapped(
offset += part.byteLength; offset += part.byteLength;
if (offset >= bytes.byteLength) break; 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 `<meta>` 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 /<meta[^>]+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<string> {
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);
}
} }
/** /**

View File

@@ -393,36 +393,3 @@ export function extractFirstContentImage(
} }
return undefined; return undefined;
} }
/**
* Returns the absolute URL of the first `<link>` 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(
`<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`,
"i",
),
new RegExp(
`<link[^>]+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;
}

View File

@@ -7,7 +7,7 @@
* @module * @module
*/ */
import { fetchWithTimeout } from "./fetch.ts"; import { fetchWithTimeout, readBytesCapped } from "./fetch.ts";
/** /**
* Returned to `GET /api/proxy-image` by {@link fetchProxiedImage}, which sends * 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 * 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. * checks `url` with `isValidHttpUrl` first.
* *
* @throws {Error} When the host can't be reached, as * @throws {Error} When the host can't be reached, as
@@ -100,6 +101,10 @@ export async function fetchProxiedImage(
if (cached) return cached; if (cached) return cached;
const res = await fetchWithTimeout(url, 8000); 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 // Raster formats only: the bytes go out under this site's origin, where an
// SVG, which can carry script, would run if opened directly. // SVG, which can carry script, would run if opened directly.
const contentType = (res.headers.get("content-type") ?? "") const contentType = (res.headers.get("content-type") ?? "")
@@ -116,7 +121,8 @@ export async function fetchProxiedImage(
return null; 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; if (bytes.length > MAX_SIZE) return null;
writeImageCache(url, bytes, contentType); writeImageCache(url, bytes, contentType);

View File

@@ -17,7 +17,7 @@ const glyphCache = new Map<string, string>();
/** /**
* Returns Twemoji's SVG for `emoji`, whose file name `cp` comes from * Returns Twemoji's SVG for `emoji`, whose file name `cp` comes from
* `emojiToCodepoint`. `emoji` only serves the error message. It fetches with * `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. * @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`; `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`;
let res: Response; let res: Response;
try { 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) { } catch (cause) {
throw new Error( throw new Error(
`Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`, `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`,

View File

@@ -23,9 +23,6 @@ import { createInvite, validateInvite } from "../services/invite-service.ts";
const router: Router = new Router({ prefix: "/api/invites" }); const router: Router = new Router({ prefix: "/api/invites" });
router.post("/", authMiddleware, (ctx: AuthContext) => { router.post("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(401, "Not authenticated");
}
const responseBody: APIResponse<InviteResponse> = { const responseBody: APIResponse<InviteResponse> = {
success: true, success: true,
data: { token: createInvite(ctx.state.user.userId) }, data: { token: createInvite(ctx.state.user.userId) },

View File

@@ -6,22 +6,18 @@
* - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them, * - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them,
* newest first. * newest first.
* - `POST /api/notifications/read-all` — signed in. Marks them all read. * - `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 * @module
*/ */
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { type APIResponse, type PaginatedData } from "../../shared/api.ts"; import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Notification } from "../../shared/notification.ts"; import type { Notification } from "../../shared/notification.ts";
import { parsePagination } from "../lib/pagination.ts"; import { parsePagination } from "../lib/pagination.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { import {
getNotificationsForUser, getNotificationsForUser,
markAllRead, markAllRead,
markOneRead,
} from "../services/notification-service.ts"; } from "../services/notification-service.ts";
/** /**
@@ -31,9 +27,6 @@ import {
const router: Router = new Router({ prefix: "/api/notifications" }); const router: Router = new Router({ prefix: "/api/notifications" });
router.get("/", authMiddleware, (ctx: AuthContext) => { router.get("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(401, "Not authenticated");
}
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getNotificationsForUser( const { items, hasMore } = getNotificationsForUser(
ctx.state.user.userId, ctx.state.user.userId,
@@ -48,20 +41,8 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
}); });
router.post("/read-all", 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); markAllRead(ctx.state.user.userId);
ctx.response.status = 204; 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; export default router;

View File

@@ -215,21 +215,11 @@ router.post("/login", async (ctx) => {
}); });
router.get("/me", authMiddleware, (ctx: AuthContext) => { router.get("/me", authMiddleware, (ctx: AuthContext) => {
try { const responseBody: APIResponse<SelfUser> = {
if (!ctx.state.user) { success: true,
throw new APIException(401, "Not authenticated"); data: toSelfUser(getUserById(ctx.state.user.userId)),
} };
ctx.response.body = responseBody;
const responseBody: APIResponse<SelfUser> = {
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");
}
}); });
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {

View File

@@ -14,6 +14,8 @@ import {
isDumpRow, isDumpRow,
} from "../model/dump.ts"; } from "../model/dump.ts";
import { UUID_RE } from "../lib/slugify.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 * 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\/([^/?#]+)/; 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. */ /** Returns the id of the dump with id or slug `idOrSlug`, or null. */
function resolveDumpPathId(idOrSlug: string): string | null { function resolveDumpPathId(idOrSlug: string): string | null {
const row = UUID_RE.test(idOrSlug) 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 * 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 * 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 * counts when it is a dump's page on this site, or when it is the URL a dump
* posted with. The dump and comment services call it on every save, and with * 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. * an empty `text` when a comment is deleted.
*/ */
export function relinkBacklinks( export function relinkBacklinks(
@@ -62,11 +55,9 @@ export function relinkBacklinks(
const externalUrls = new Set<string>(); const externalUrls = new Set<string>();
for (const match of text.matchAll(URL_RE)) { for (const match of text.matchAll(URL_RE)) {
const canonical = canonicalize(match[0]);
if (!canonical) continue;
let parsed: URL; let parsed: URL;
try { try {
parsed = new URL(canonical); parsed = new URL(match[0]);
} catch { } catch {
continue; continue;
} }
@@ -75,14 +66,15 @@ export function relinkBacklinks(
const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1])); const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1]));
if (resolved) targetIds.add(resolved); if (resolved) targetIds.add(resolved);
} else { } else {
externalUrls.add(canonical); const canonical = canonicalizeUrl(match[0]);
if (canonical) externalUrls.add(canonical);
} }
} }
if (externalUrls.size > 0) { if (externalUrls.size > 0) {
const placeholders = [...externalUrls].map(() => "?").join(", "); const placeholders = [...externalUrls].map(() => "?").join(", ");
const rows = db.prepare( 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 }[]; ).all(...externalUrls) as { id: string }[];
for (const row of rows) targetIds.add(row.id); for (const row of rows) targetIds.add(row.id);
} }
@@ -122,5 +114,5 @@ export function getRelatedDumps(
ORDER BY created_at DESC LIMIT 20;`, ORDER BY created_at DESC LIMIT 20;`,
).all(dumpId, dumpId, requestingUserId ?? ""); ).all(dumpId, dumpId, requestingUserId ?? "");
return rows.filter(isDumpRow).map(dumpRowToApi); return parseRows(rows, isDumpRow, dumpRowToApi);
} }

View File

@@ -23,6 +23,7 @@ import {
import { type Page, pageOf } from "../lib/pagination.ts"; import { type Page, pageOf } from "../lib/pagination.ts";
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
import { VALIDATION } from "../../shared/validation.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, * 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( const rows = db.prepare(
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`, `SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
).all(); ).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 ?;`, ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
).all(category.id, ...privacyParams, limit + 1, offset); ).all(category.id, ...privacyParams, limit + 1, offset);
const items = rows.filter(isDumpRow).map(dumpRowToApi); const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items); attachCategoryIds(items);
return pageOf(items, limit); return pageOf(items, limit);
} }

View File

@@ -10,7 +10,7 @@
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
import { isUserRow, type User, userRowToApi } from "../model/user.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 { type Page, pageOf } from "../lib/pagination.ts";
import { notifyCommentOwnerLike } from "./notification-service.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts";
@@ -54,22 +54,19 @@ export function castCommentLike(
const isPrivate = commentVisibility(commentId, userId); const isPrivate = commentVisibility(commentId, userId);
let likeCount: number; let likeCount: number;
try { try {
db.exec("BEGIN;"); likeCount = transaction(() => {
db.prepare( db.prepare(
`INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`, `INSERT INTO comment_likes (comment_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(commentId, userId, new Date().toISOString()); ).run(commentId, userId, new Date().toISOString());
db.prepare( db.prepare(
`UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`, `UPDATE comments SET like_count = like_count + 1 WHERE id = ?;`,
).run(commentId); ).run(commentId);
const row = db.prepare( const row = db.prepare(
`SELECT like_count FROM comments WHERE id = ?;`, `SELECT like_count FROM comments WHERE id = ?;`,
).get(commentId) as { like_count: number } | undefined; ).get(commentId) as { like_count: number };
db.exec("COMMIT;"); return row.like_count;
likeCount = row?.like_count ?? 0; });
} catch (err) { } catch (err) {
try {
db.exec("ROLLBACK;");
} catch { /* ignore if no active transaction */ }
if (err instanceof Error && err.message.includes("UNIQUE constraint")) { if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
throw new APIException(409, "Already liked"); throw new APIException(409, "Already liked");
} }
@@ -98,13 +95,11 @@ export function removeCommentLike(
WHERE c.id = ?;`, WHERE c.id = ?;`,
).get(commentId) as { is_private: number } | undefined; ).get(commentId) as { is_private: number } | undefined;
const isPrivate = Boolean(dumpRow?.is_private); const isPrivate = Boolean(dumpRow?.is_private);
try { return transaction(() => {
db.exec("BEGIN;");
const result = db.prepare( const result = db.prepare(
`DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`, `DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?;`,
).run(commentId, userId); ).run(commentId, userId);
if (result.changes === 0) { if (result.changes === 0) {
db.exec("ROLLBACK;");
throw new APIException(404, "Like not found"); throw new APIException(404, "Like not found");
} }
db.prepare( db.prepare(
@@ -112,14 +107,9 @@ export function removeCommentLike(
).run(commentId); ).run(commentId);
const row = db.prepare( const row = db.prepare(
`SELECT like_count FROM comments WHERE id = ?;`, `SELECT like_count FROM comments WHERE id = ?;`,
).get(commentId) as { like_count: number } | undefined; ).get(commentId) as { like_count: number };
db.exec("COMMIT;"); return { likeCount: row.like_count, isPrivate };
return { likeCount: row?.like_count ?? 0, isPrivate }; });
} catch (err) {
if (err instanceof APIException) throw err;
db.exec("ROLLBACK;");
throw err;
}
} }
/** /**

View File

@@ -8,7 +8,6 @@
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
import type { Comment } from "../../shared/comment.ts"; import type { Comment } from "../../shared/comment.ts";
import { type SQLOutputValue } from "node:sqlite";
import { commentRowToApi, isCommentRow } from "../model/comment.ts"; import { commentRowToApi, isCommentRow } from "../model/comment.ts";
import { db } from "../db/connection.ts"; import { db } from "../db/connection.ts";
import { import {
@@ -27,9 +26,7 @@ function fetchComment(commentId: string): Comment {
const row = db.prepare( const row = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
).get(commentId); ).get(commentId);
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) { if (!row) throw new APIException(404, "Comment not found");
throw new APIException(404, "Comment not found");
}
if (!isCommentRow(row)) { if (!isCommentRow(row)) {
throw new APIException(500, "Malformed comment data"); throw new APIException(500, "Malformed comment data");
} }

View File

@@ -54,6 +54,8 @@ import {
attachCategoryIds, attachCategoryIds,
setDumpCategories, setDumpCategories,
} from "./category-service.ts"; } from "./category-service.ts";
import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts";
function isAllowedMime(mime: string): boolean { function isAllowedMime(mime: string): boolean {
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
@@ -327,11 +329,11 @@ export function searchDumps(
requestingUserId?: string, requestingUserId?: string,
): { items: Dump[]; total: number } { ): { items: Dump[]; total: number } {
if (!query.trim()) return { items: [], total: 0 }; if (!query.trim()) return { items: [], total: 0 };
const pattern = `%${query}%`; const pattern = likePattern(query);
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const searchClause = 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; const searchParams = [pattern, pattern, pattern, pattern] as const;
if (requestingUserId) { if (requestingUserId) {
@@ -341,7 +343,7 @@ export function searchDumps(
const totalRow = db.prepare( const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`, `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined; ).get(requestingUserId, ...searchParams) as { count: number } | undefined;
const items = rows.filter(isDumpRow).map(dumpRowToApi); const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items); attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 }; return { items, total: totalRow?.count ?? 0 };
} else { } else {
@@ -351,7 +353,7 @@ export function searchDumps(
const totalRow = db.prepare( const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`, `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined; ).get(...searchParams) as { count: number } | undefined;
const items = rows.filter(isDumpRow).map(dumpRowToApi); const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items); attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 }; return { items, total: totalRow?.count ?? 0 };
} }

View File

@@ -78,18 +78,6 @@ function getTransporter(): Transporter {
return 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<boolean> {
if (!isEmailEnabled()) return false;
await getTransporter().verify();
return true;
}
/** /**
* Sends `message` and resolves once the SMTP server has accepted it. The * Sends `message` and resolves once the SMTP server has accepted it. The
* transport is created on the first send. * transport is created on the first send.

View File

@@ -30,12 +30,16 @@ import { attachCategoryIds } from "./category-service.ts";
* Makes `followerId` follow `followedUserId`, and notifies the followed user. * Makes `followerId` follow `followedUserId`, and notifies the followed user.
* Following again changes nothing. * 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 { export function followUser(followerId: string, followedUserId: string): void {
if (followerId === followedUserId) { if (followerId === followedUserId) {
throw new APIException(400, "Cannot follow yourself"); 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. // The unique index answers "already following" without a race.
let isNew = true; let isNew = true;
try { try {
@@ -71,7 +75,7 @@ export function unfollowUser(followerId: string, followedUserId: string): void {
* Following again changes nothing. A follow outlives the playlist going * Following again changes nothing. A follow outlives the playlist going
* private, but the feeds and lists skip it while it is. * 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. * private.
*/ */
export function followPlaylist(followerId: string, playlistId: string): void { export function followPlaylist(followerId: string, playlistId: string): void {
@@ -82,8 +86,9 @@ export function followPlaylist(followerId: string, playlistId: string): void {
if (!row) { if (!row) {
throw new APIException(404, "Playlist not found"); throw new APIException(404, "Playlist not found");
} }
// Private is answered as missing, so as not to confirm that it exists.
if (!row.is_public) { if (!row.is_public) {
throw new APIException(403, "Cannot follow a private playlist"); throw new APIException(404, "Playlist not found");
} }
let isNew = true; let isNew = true;

View File

@@ -22,7 +22,7 @@ import {
} from "../model/notification.ts"; } from "../model/notification.ts";
import { type Page, pageOf } from "../lib/pagination.ts"; import { type Page, pageOf } from "../lib/pagination.ts";
import { sendToUser } from "./ws-service.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`, * 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 * Notifies `followedUserId` that `followerId` follows them, once however often
* they unfollow and follow again. `followUser` calls it. * they unfollow and follow again. `followUser` calls it.
@@ -301,11 +291,7 @@ export function notifyMentions(
).get(mentionerUserId) as { username: string } | undefined; ).get(mentionerUserId) as { username: string } | undefined;
if (!mentionerRow) return; if (!mentionerRow) return;
const usernames = [ const usernames = mentionedUsernames(body);
...new Set(
[...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase()),
),
];
for (const username of usernames) { for (const username of usernames) {
const mentionedRow = db.prepare( const mentionedRow = db.prepare(

View File

@@ -18,7 +18,7 @@ import type {
import type { Dump } from "../../shared/dump.ts"; import type { Dump } from "../../shared/dump.ts";
import { db, transaction } from "../db/connection.ts"; import { db, transaction } from "../db/connection.ts";
import { import {
DUMP_COLUMNS_ALIASED, DUMP_SELECT_COLUMNS_ALIASED,
dumpRowToApi, dumpRowToApi,
isDumpRow, isDumpRow,
} from "../model/dump.ts"; } from "../model/dump.ts";
@@ -40,6 +40,8 @@ import {
isMemberVisibleTo, isMemberVisibleTo,
type PlaylistMember, type PlaylistMember,
} from "../lib/playlist-members.ts"; } 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 * The select list and `FROM` clause of a playlist query, adding the owner's
@@ -78,14 +80,15 @@ export function createPlaylist(
): Playlist { ): Playlist {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const createdAt = new Date(); const createdAt = new Date();
const slug = makeSlug(req.title, id); const title = req.title.trim();
const slug = makeSlug(title, id);
db.prepare( db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at) `INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run( ).run(
id, id,
userId, userId,
req.title, title,
slug, slug,
req.description ?? null, req.description ?? null,
req.isPublic ? 1 : 0, req.isPublic ? 1 : 0,
@@ -94,7 +97,7 @@ export function createPlaylist(
const playlist: Playlist = { const playlist: Playlist = {
id, id,
userId, userId,
title: req.title, title,
slug, slug,
description: req.description, description: req.description,
isPublic: req.isPublic, isPublic: req.isPublic,
@@ -103,7 +106,7 @@ export function createPlaylist(
if (req.description) { if (req.description) {
// A private playlist's mentions are notified if it goes public. // A private playlist's mentions are notified if it goes public.
if (req.isPublic) { if (req.isPublic) {
notifyMentions(userId, req.description, "playlist", id, req.title); notifyMentions(userId, req.description, "playlist", id, title);
} }
linkAttachments(req.description, "playlist", id); linkAttachments(req.description, "playlist", id);
} }
@@ -130,8 +133,7 @@ export function getPlaylist(
} }
const rows = db.prepare( const rows = db.prepare(
`SELECT ${DUMP_COLUMNS_ALIASED}, `SELECT ${DUMP_SELECT_COLUMNS_ALIASED}
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
FROM dumps d FROM dumps d
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
WHERE pd.playlist_id = ? WHERE pd.playlist_id = ?
@@ -139,7 +141,7 @@ export function getPlaylist(
ORDER BY pd.position ASC;`, ORDER BY pd.position ASC;`,
).all(playlist.id, requestingUserId ?? ""); ).all(playlist.id, requestingUserId ?? "");
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi); const dumps: Dump[] = parseRows(rows, isDumpRow, dumpRowToApi);
const visibleDumps = dumps.filter((d) => const visibleDumps = dumps.filter((d) =>
isMemberVisibleTo(d, playlist, requestingUserId) isMemberVisibleTo(d, playlist, requestingUserId)
); );
@@ -158,8 +160,8 @@ export function searchPlaylists(
requestingUserId?: string, requestingUserId?: string,
): Playlist[] { ): Playlist[] {
if (!query.trim()) return []; if (!query.trim()) return [];
const pattern = `%${query}%`; const pattern = likePattern(query);
const searchClause = `(p.title LIKE ? OR p.description LIKE ?)`; const searchClause = `(p.title ${LIKE} OR p.description ${LIKE})`;
const rows = requestingUserId const rows = requestingUserId
? db.prepare( ? 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 ?;`, `SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(pattern, pattern, 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 ?;`; : `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); 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); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) { 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 const newDescription = "description" in req
? (req.description ?? null) ? (req.description ?? null)
: (playlist.description ?? null); : (playlist.description ?? null);
@@ -276,7 +278,7 @@ export function deletePlaylist(
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) { 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); db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
@@ -298,7 +300,7 @@ export function getEditablePlaylist(
): Playlist { ): Playlist {
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) { 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; return playlist;
} }
@@ -352,7 +354,7 @@ export function addDumpToPlaylist(
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) { 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 // 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); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) { 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( db.prepare(
@@ -444,7 +446,7 @@ export function reorderPlaylist(
const playlist = getPlaylistById(playlistId); const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) { 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 // 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;`, ORDER BY p.created_at DESC LIMIT 20;`,
).all(dumpId, requestingUserId ?? ""); ).all(dumpId, requestingUserId ?? "");
return rows.filter(isPlaylistRow).map(playlistRowToApi); return parseRows(rows, isPlaylistRow, playlistRowToApi);
} }
/** /**

View File

@@ -24,10 +24,12 @@ import {
userRowToApi, userRowToApi,
} from "../model/user.ts"; } from "../model/user.ts";
import { db } from "../db/connection.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 { hashPassword } from "../lib/password.ts";
import { linkAttachments } from "./attachment-service.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. */ /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */
const USER_SELECT = const USER_SELECT =
@@ -194,8 +196,8 @@ export function searchUsers(
): UserSuggestion[] { ): UserSuggestion[] {
if (!query) return []; if (!query) return [];
const rows = db.prepare( const rows = db.prepare(
`SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`, `SELECT id, username, avatar_mime FROM users WHERE username ${LIKE} ORDER BY username LIMIT ?;`,
).all(`${query}%`, limit) as { ).all(likePattern(query, "prefix"), limit) as {
id: string; id: string;
username: string; username: string;
avatar_mime: string | null; avatar_mime: string | null;
@@ -213,28 +215,11 @@ export function searchUsers(
*/ */
export function searchUsersGlobal(query: string, limit: number): PublicUser[] { export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
if (!query.trim()) return []; if (!query.trim()) return [];
const pattern = `%${query}%`; const pattern = likePattern(query);
const rows = db.prepare( 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); ).all(pattern, pattern, limit);
return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser); return parseRows(rows, isUserRow, 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);
} }
/** /**
@@ -357,20 +342,3 @@ export function getInviteTree(userId: string): Invitee[] {
createdAt: new Date(r.created_at), 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");
}
}

View File

@@ -10,7 +10,7 @@
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
import { isUserRow, type User, userRowToApi } from "../model/user.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 { type Page, pageOf } from "../lib/pagination.ts";
import { notifyDumpOwnerUpvote } from "./notification-service.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts";
@@ -49,22 +49,19 @@ export function castVote(dumpId: string, userId: string): VoteResult {
const { isPrivate } = visibleDump(dumpId, userId); const { isPrivate } = visibleDump(dumpId, userId);
let voteCount: number; let voteCount: number;
try { try {
db.exec("BEGIN;"); voteCount = transaction(() => {
db.prepare( db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(dumpId, userId, new Date().toISOString()); ).run(dumpId, userId, new Date().toISOString());
db.prepare( db.prepare(
`UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`, `UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`,
).run(dumpId); ).run(dumpId);
const row = db.prepare( const row = db.prepare(
`SELECT vote_count FROM dumps WHERE id = ?;`, `SELECT vote_count FROM dumps WHERE id = ?;`,
).get(dumpId) as { vote_count: number } | undefined; ).get(dumpId) as { vote_count: number };
db.exec("COMMIT;"); return row.vote_count;
voteCount = row?.vote_count ?? 0; });
} catch (err) { } catch (err) {
try {
db.exec("ROLLBACK;");
} catch { /* ignore if no active transaction */ }
if (err instanceof Error && err.message.includes("UNIQUE constraint")) { if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
throw new APIException(409, "Already voted"); throw new APIException(409, "Already voted");
} }
@@ -89,28 +86,21 @@ export function removeVote(dumpId: string, userId: string): VoteResult {
dumpId, dumpId,
) as { is_private: number } | undefined; ) as { is_private: number } | undefined;
const isPrivate = Boolean(row?.is_private); const isPrivate = Boolean(row?.is_private);
try { return transaction(() => {
db.exec("BEGIN;");
const result = db.prepare( const result = db.prepare(
`DELETE FROM votes WHERE dump_id = ? AND user_id = ?;`, `DELETE FROM votes WHERE dump_id = ? AND user_id = ?;`,
).run(dumpId, userId); ).run(dumpId, userId);
if (result.changes === 0) { if (result.changes === 0) {
db.exec("ROLLBACK;");
throw new APIException(404, "Vote not found"); throw new APIException(404, "Vote not found");
} }
db.prepare( db.prepare(
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`, `UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
).run(dumpId); ).run(dumpId);
const count = db.prepare( const row = db.prepare(
`SELECT vote_count FROM dumps WHERE id = ?;`, `SELECT vote_count FROM dumps WHERE id = ?;`,
).get(dumpId) as { vote_count: number } | undefined; ).get(dumpId) as { vote_count: number };
db.exec("COMMIT;"); return { voteCount: row.vote_count, isPrivate };
return { voteCount: count?.vote_count ?? 0, isPrivate }; });
} catch (err) {
if (err instanceof APIException) throw err;
db.exec("ROLLBACK;");
throw err;
}
} }
/** /**

View File

@@ -135,19 +135,6 @@ export function sendToUser(userId: string, data: ServerToClientMessage): void {
broadcast(data, (client) => client.userId === userId); 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 * 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: * calls it on a password change. Each tab reconnects with the token it holds:

View File

@@ -1,10 +1,6 @@
import "../setup.ts"; import "../setup.ts";
import { assert, assertFalse } from "@std/assert"; import { assert, assertFalse } from "@std/assert";
import { import { isReservedSlug, isValidSlugFormat } from "../../lib/reserved-slugs.ts";
isReservedSlug,
isSlugAvailable,
isValidSlugFormat,
} from "../../lib/reserved-slugs.ts";
Deno.test("category slugs: format and reserved words", () => { Deno.test("category slugs: format and reserved words", () => {
assert(isValidSlugFormat("music")); assert(isValidSlugFormat("music"));
@@ -14,8 +10,7 @@ Deno.test("category slugs: format and reserved words", () => {
} }
assert(isReservedSlug("dumps")); assert(isReservedSlug("dumps"));
assert(isReservedSlug("api")); assert(isReservedSlug("api"));
assert(isReservedSlug("ws"));
assert(isReservedSlug("emojibase"));
assertFalse(isReservedSlug("music")); assertFalse(isReservedSlug("music"));
assert(isSlugAvailable("music"));
assertFalse(isSlugAvailable("users"));
assertFalse(isSlugAvailable("Nope"));
}); });

View File

@@ -89,3 +89,24 @@ Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () =>
"Unsupported protocol", "Unsupported protocol",
); );
}); });
Deno.test("readTextCapped: decodes the charset the header or a <meta> 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", '<meta charset="windows-1252">')),
'<meta charset="windows-1252">café',
);
assertEquals(
await readTextCapped(page("text/html; charset=nonsense")),
"caf<61>",
);
});

View File

@@ -5,7 +5,6 @@ import {
extractFirstContentImage, extractFirstContentImage,
extractJsonLd, extractJsonLd,
extractLargeImage, extractLargeImage,
extractLinkHref,
extractMaskIconColor, extractMaskIconColor,
extractMetaName, extractMetaName,
extractOgTag, extractOgTag,
@@ -156,8 +155,4 @@ Deno.test("extractBestIcon: SVG beats everything, else the largest", () => {
"https://example.com/icon", "https://example.com/icon",
); );
assertEquals(extractBestIcon("<p></p>", BASE), undefined); assertEquals(extractBestIcon("<p></p>", BASE), undefined);
assertEquals(
extractLinkHref(`<link href="/feed" rel="alternate">`, "alternate", BASE),
"https://example.com/feed",
);
}); });

View File

@@ -358,14 +358,14 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
isPrivate: true, isPrivate: true,
}); });
using _ = stubNetwork(() => PAGE.clone()); using _ = stubNetwork(() => PAGE.clone());
// A new dump whose comment links the target by its URL and the secret dump by // A new dump whose comment links the target by another shape of its URL,
// its page — and itself, which is ignored. // and the secret dump by its page — and itself, which is ignored.
const src = await post<Dump>("/api/dumps", { const src = await post<Dump>("/api/dumps", {
auth: other.auth, auth: other.auth,
json: { json: {
url: "https://example.com/src", url: "https://example.com/src",
comment: 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; const srcId = src.data.id;

View File

@@ -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, (await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status,
400, 400,
); );
assertEquals(
(await post(`/api/follows/users/${crypto.randomUUID()}`, {
auth: me.auth,
})).status,
404,
);
assertEquals( assertEquals(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data, (await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data,
{ followedUserIds: [them.id], followedPlaylistIds: [] }, { followedUserIds: [them.id], followedPlaylistIds: [] },
@@ -76,7 +82,7 @@ test("following playlists: public ones only, and not your own notification", asy
); );
assertEquals( assertEquals(
(await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status, (await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status,
403, 404,
); );
assertEquals( assertEquals(
(await post(`/api/follows/playlists/${crypto.randomUUID()}`, { (await post(`/api/follows/playlists/${crypto.randomUUID()}`, {

View File

@@ -1,12 +1,4 @@
import { import { count, createUser, get, insertDump, post, test } from "../helpers.ts";
count,
createUser,
get,
insertDump,
patch,
post,
test,
} from "../helpers.ts";
import type { Notification } from "../../../shared/notification.ts"; import type { Notification } from "../../../shared/notification.ts";
import type { PaginatedData } from "../../../shared/api.ts"; import type { PaginatedData } from "../../../shared/api.ts";
import { assertEquals } from "@std/assert"; import { assertEquals } from "@std/assert";
@@ -36,30 +28,6 @@ test("notifications: listed newest first, marked read one or all, never someone
true, true,
); );
const theirs = (await get<PaginatedData<Notification>>("/api/notifications", {
auth: other.auth,
})).data
.items[0].id;
assertEquals(
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
.status,
204,
);
assertEquals(
(await get<PaginatedData<Notification>>("/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<PaginatedData<Notification>>("/api/notifications", {
auth: me.auth,
})).data.items;
assertEquals(after.map((n) => n.read), [true, false]);
assertEquals( assertEquals(
(await post("/api/notifications/read-all", { auth: me.auth })).status, (await post("/api/notifications/read-all", { auth: me.auth })).status,
204, 204,

View File

@@ -25,3 +25,22 @@ Deno.test("GET /api/proxy-image: rasters only, fetched once for everyone", async
); );
assertEquals(svg.status, 400); 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);
});

View File

@@ -163,6 +163,14 @@ test("GET /api/users/search: prefix match for mentions, capped at 8", async () =
avatarMime: null, avatarMime: null,
}); });
assertEquals((await get<UserSuggestion[]>("/api/users/search?q=")).data, []); assertEquals((await get<UserSuggestion[]>("/api/users/search?q=")).data, []);
// An underscore is a letter of the name, not a wildcard.
await createUser({ username: "zedx9" });
assertEquals(
(await get<UserSuggestion[]>("/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 () => { test("GET /api/users/:username/invitees: the invite tree below a user", async () => {

View File

@@ -46,3 +46,23 @@ test("mentions: notify the named, once per context, never the author or an email
1, 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,
);
});

View File

@@ -11,12 +11,14 @@ Deno.test("dump requests", () => {
isCreateUrlDumpRequest({ isCreateUrlDumpRequest({
url: "https://x", url: "https://x",
title: "t", title: "t",
comment: null, comment: "c",
isPrivate: true, isPrivate: true,
categoryIds: ["a"], categoryIds: ["a"],
}), }),
); );
assertFalse(isCreateUrlDumpRequest({})); assertFalse(isCreateUrlDumpRequest({}));
assertFalse(isCreateUrlDumpRequest({ url: "https://x", comment: null }));
assertFalse(isCreateUrlDumpRequest({ url: "https://x", title: null }));
assertFalse( assertFalse(
isCreateUrlDumpRequest({ url: "https://x", title: "x".repeat(201) }), isCreateUrlDumpRequest({ url: "https://x", title: "x".repeat(201) }),
); );
@@ -27,7 +29,8 @@ Deno.test("dump requests", () => {
assertFalse(isCreateUrlDumpRequest({ url: "https://x", categoryIds: [1] })); assertFalse(isCreateUrlDumpRequest({ url: "https://x", categoryIds: [1] }));
assert(isUpdateDumpRequest({})); assert(isUpdateDumpRequest({}));
assert(isUpdateDumpRequest({ comment: null, url: null })); assert(isUpdateDumpRequest({ comment: null }));
assertFalse(isUpdateDumpRequest({ url: null }));
assertFalse(isUpdateDumpRequest({ title: " " })); assertFalse(isUpdateDumpRequest({ title: " " }));
assertFalse(isUpdateDumpRequest({ title: "x".repeat(201) })); assertFalse(isUpdateDumpRequest({ title: "x".repeat(201) }));
assertFalse(isUpdateDumpRequest({ categoryIds: "a" })); assertFalse(isUpdateDumpRequest({ categoryIds: "a" }));

View File

@@ -29,16 +29,12 @@ export function isCreateUrlDumpRequest(
!("url" in obj) || typeof obj.url !== "string" !("url" in obj) || typeof obj.url !== "string"
) return false; ) return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ( if ("comment" in o && typeof o.comment !== "string") return false;
"comment" in o && typeof o.comment !== "string" && o.comment !== null
) return false;
if ( if (
typeof o.comment === "string" && typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false; ) return false;
if ("title" in o && typeof o.title !== "string" && o.title !== null) { if ("title" in o && typeof o.title !== "string") return false;
return false;
}
if ( if (
typeof o.title === "string" && typeof o.title === "string" &&
(o.title as string).length > VALIDATION.DUMP_TITLE_MAX (o.title as string).length > VALIDATION.DUMP_TITLE_MAX
@@ -56,7 +52,7 @@ export function isCreateUrlDumpRequest(
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
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 ("title" in o) {
if (typeof o.title !== "string") return false; if (typeof o.title !== "string") return false;
const trimmed = (o.title as string).trim(); const trimmed = (o.title as string).trim();

View File

@@ -11,10 +11,16 @@ import type {
} from "../../shared/playlist.ts"; } from "../../shared/playlist.ts";
import { VALIDATION } from "../../shared/validation.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 * Checks whether `obj` is a {@link CreatePlaylistRequest} with a title that
* title and description within the `VALIDATION` limits. A null description * isn't blank, and a title and description within the `VALIDATION` limits. A
* passes. A title of spaces passes too. * null description passes.
*/ */
export function isCreatePlaylistRequest( export function isCreatePlaylistRequest(
obj: unknown, obj: unknown,
@@ -25,10 +31,7 @@ export function isCreatePlaylistRequest(
!("isPublic" in obj) || typeof obj.isPublic !== "boolean" !("isPublic" in obj) || typeof obj.isPublic !== "boolean"
) return false; ) return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ( if (!isValidTitle(o.title as string)) return false;
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
if ( if (
"description" in o && typeof o.description !== "string" && "description" in o && typeof o.description !== "string" &&
o.description !== null o.description !== null
@@ -42,7 +45,7 @@ export function isCreatePlaylistRequest(
/** /**
* Checks whether `obj` is an {@link UpdatePlaylistRequest} whose title, if * 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. * limits.
*/ */
export function isUpdatePlaylistRequest( export function isUpdatePlaylistRequest(
@@ -52,10 +55,7 @@ export function isUpdatePlaylistRequest(
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ("title" in o) { if ("title" in o) {
if (typeof o.title !== "string") return false; if (typeof o.title !== "string") return false;
if ( if (!isValidTitle(o.title as string)) return false;
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
} }
if ( if (
"description" in o && typeof o.description !== "string" && "description" in o && typeof o.description !== "string" &&

View File

@@ -10,7 +10,7 @@ import {
type RegisterUserRequest, type RegisterUserRequest,
type UpdateUserRequest, type UpdateUserRequest,
} from "../../shared/user.ts"; } 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 * 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" !("email" in obj) || typeof obj.email !== "string"
) return "Invalid request"; ) return "Invalid request";
const { username, password, email } = obj as RegisterUserRequest; const { username, password, email } = obj as RegisterUserRequest;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!EMAIL_RE.test(email)) {
return "Invalid email address"; return "Invalid email address";
} }
if ( if (!USERNAME_RE.test(username)) {
!new RegExp(
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
)
.test(username)
) {
return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`; return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`;
} }
if (password.length < VALIDATION.PASSWORD_MIN) { 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 * Checks whether `obj` is a valid {@link UpdateUserRequest}, for
* `PATCH /api/users/me`. Every field is optional, but each one present must be * `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 * well-formed and within the `VALIDATION` limits.
* are not those of sign-up, which the route never lets it change anyway.
*/ */
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
if (!obj || typeof obj !== "object") return false; if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>; const o = obj as Record<string, unknown>;
if ("username" in o) { if ("username" in o) {
if (typeof o.username !== "string") return false; 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 ("password" in o) {
if (typeof o.password !== "string") return false; if (typeof o.password !== "string") return false;
@@ -104,7 +98,7 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
) return false; ) return false;
if ("email" in o) { if ("email" in o) {
if (typeof o.email !== "string") return false; 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 ( if (
typeof o.description === "string" && typeof o.description === "string" &&

View File

@@ -1,6 +1,6 @@
/** /**
* Defines what counts as an @mention, so that the server notifies the same * 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 * @module
*/ */
@@ -9,9 +9,25 @@
* Matches an @username, with the name as its first group. The `@` must not * 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 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 * follow `[` or `(`, which leaves out a Markdown link's text or URL starting
* with `@`, and also a plain `(@name)`. * with `@`, and also a plain `(@name)`. The editor's suggestions match its
* `notifyMentions` notifies the users it matches, and the chat's * source at the caret; the rest goes through {@link mentionedUsernames}.
* `mentionsUser` highlights and chimes by it. `Markdown` links mentions with a * `Markdown` links mentions with a looser pattern of its own, on text already
* looser pattern of its own, on text already parsed. * parsed, where code and URLs are out of the way.
*/ */
export const MENTION_RE: RegExp = /(?<![[(\w])@(\w+)/g; export const MENTION_RE: RegExp = /(?<![[(\w])@(\w+)/g;
/**
* Returns the names `body` mentions, in lower case, each once. Code, fenced
* or inline, and URLs mention no one, as `Markdown` shows them. The server
* notifies by it, and the chat highlights and chimes by it.
*/
export function mentionedUsernames(body: string): string[] {
const prose = body
// A fence left open runs to the end, as Markdown renders it.
.replace(/```[\s\S]*?(?:```|$)/g, " ")
.replace(/`[^`\n]*`/g, " ")
.replace(/https?:\/\/\S+/g, " ");
return [
...new Set([...prose.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase())),
];
}

View File

@@ -28,6 +28,21 @@ export const VALIDATION = {
CHAT_MESSAGE_MAX: 2000, CHAT_MESSAGE_MAX: 2000,
} as const; } 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 * The largest file a file dump may hold, in bytes. dump-service refuses a
* bigger one, and the create form before uploading it. * bigger one, and the create form before uploading it.

View File

@@ -385,14 +385,6 @@ export interface ErrorMessage {
nonce?: string; 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 * 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 * chat message is posted. `WSProvider` adds it to the live chat, counts it
@@ -473,7 +465,6 @@ export type ServerToClientMessage =
| CommentDeletedMessage | CommentDeletedMessage
| NotificationCreatedMessage | NotificationCreatedMessage
| ErrorMessage | ErrorMessage
| ForceLogoutMessage
| ChatMessageMessage | ChatMessageMessage
| ChatMessageUpdatedMessage | ChatMessageUpdatedMessage
| ChatMessageDeletedMessage | ChatMessageDeletedMessage

View File

@@ -128,10 +128,10 @@ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
* anonymously, and reconnects with a backoff doubling up to 30 s. A new token * 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. * (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 * 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 { export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const { token, user, logout } = useAuth(); const { token, user } = useAuth();
const userId = user?.id ?? null; const userId = user?.id ?? null;
const [wsStatus, setWSStatus] = useState< const [wsStatus, setWSStatus] = useState<
"connecting" | "connected" | "disconnected" "connecting" | "connected" | "disconnected"
@@ -223,7 +223,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const myLikesRef = useRef(myLikes); const myLikesRef = useRef(myLikes);
const userIdRef = useRef(userId); const userIdRef = useRef(userId);
const usernameRef = useRef(user?.username ?? null); const usernameRef = useRef(user?.username ?? null);
const onForceLogoutRef = useRef(logout);
useLayoutEffect(() => { useLayoutEffect(() => {
voteCountsRef.current = voteCounts; voteCountsRef.current = voteCounts;
myVotesRef.current = myVotes; myVotesRef.current = myVotes;
@@ -231,7 +230,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
myLikesRef.current = myLikes; myLikesRef.current = myLikes;
userIdRef.current = userId; userIdRef.current = userId;
usernameRef.current = user?.username ?? null; usernameRef.current = user?.username ?? null;
onForceLogoutRef.current = logout;
}); });
const socketRef = useRef<WebSocket | null>(null); const socketRef = useRef<WebSocket | null>(null);
@@ -683,10 +681,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
break; break;
} }
case "force_logout":
onForceLogoutRef.current();
break;
case "error": { case "error": {
if (msg.scope === "chat") { if (msg.scope === "chat") {
const message = msg.message ?? t`Something went wrong`; const message = msg.message ?? t`Something went wrong`;

View File

@@ -16,6 +16,7 @@ import {
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { insideCode } from "../../utils/editor/text.ts"; import { insideCode } from "../../utils/editor/text.ts";
import type { UserSuggestion } from "../../../shared/user.ts"; import type { UserSuggestion } from "../../../shared/user.ts";
import { MENTION_RE } from "../../../shared/mention.ts";
interface MentionState { interface MentionState {
open: boolean; open: boolean;
@@ -40,7 +41,7 @@ const CLOSED: MentionState = {
* Matches the `@query` ending at the caret by the rule of shared/mention.ts, * 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. * which the server notifies by, so that what is offered would notify.
*/ */
const TRIGGER_RE = /(?<![[(\w])@(\w+)$/; const TRIGGER_RE = new RegExp(`${MENTION_RE.source}$`);
/** /**
* Returns the `@query` that ends at `pos` in `value`, or null when there is * Returns the `@query` that ends at `pos` in `value`, or null when there is

View File

@@ -11,7 +11,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { VALIDATION } from "../../shared/validation.ts"; import { USERNAME_RE, VALIDATION } from "../../shared/validation.ts";
import { deserializeAuthResponse } from "../utils/shared/deserialize.ts"; import { deserializeAuthResponse } from "../utils/shared/deserialize.ts";
import type { Wire } from "../../shared/api.ts"; import type { Wire } from "../../shared/api.ts";
import type { AuthResponse, RegisterUserRequest } from "../../shared/user.ts"; import type { AuthResponse, RegisterUserRequest } from "../../shared/user.ts";
@@ -133,9 +133,7 @@ export function UserRegister(): React.JSX.Element {
rules={{ rules={{
required: true, required: true,
pattern: { pattern: {
value: new RegExp( value: USERNAME_RE,
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
),
message: message:
t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`, t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`,
}, },

View File

@@ -138,13 +138,6 @@ describe("connection", () => {
expect(ws().connectionEpoch).toBe(1); 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", () => { test("signing out resets live state and reconnects as a guest", () => {
const { socket, hook, ws } = setup(); const { socket, hook, ws } = setup();
socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 }); socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 });

View File

@@ -5,7 +5,7 @@
* @module * @module
*/ */
import { MENTION_RE } from "../../../shared/mention.ts"; import { mentionedUsernames } from "../../../shared/mention.ts";
/** /**
* Checks whether `body` @mentions `username`, ignoring case, with the pattern * 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. * chime, and `ChatModal` to highlight the message.
*/ */
export function mentionsUser(body: string, username: string): boolean { export function mentionsUser(body: string, username: string): boolean {
const target = username.toLowerCase(); return mentionedUsernames(body).includes(username.toLowerCase());
for (const m of body.matchAll(MENTION_RE)) {
if (m[1].toLowerCase() === target) return true;
}
return false;
} }
/** /**