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:
24
api/lib/like.ts
Normal file
24
api/lib/like.ts
Normal 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 '\\'";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -63,23 +63,6 @@ export async function authMiddleware<R extends string>(
|
||||
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
|
||||
* run after {@link authMiddleware}, as in
|
||||
|
||||
@@ -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
|
||||
|
||||
25
api/model/rows.ts
Normal file
25
api/model/rows.ts
Normal 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);
|
||||
}
|
||||
@@ -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<string> {
|
||||
if (!res.body) return "";
|
||||
maxBytes: number,
|
||||
): Promise<Uint8Array> {
|
||||
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 `<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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -393,36 +393,3 @@ export function extractFirstContentImage(
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,7 +17,7 @@ const glyphCache = new Map<string, string>();
|
||||
/**
|
||||
* 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}`,
|
||||
|
||||
@@ -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<InviteResponse> = {
|
||||
success: true,
|
||||
data: { token: createInvite(ctx.state.user.userId) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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");
|
||||
}
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(getUserById(ctx.state.user.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, SQLOutputValue>)) {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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 <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>",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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("<p></p>", BASE), undefined);
|
||||
assertEquals(
|
||||
extractLinkHref(`<link href="/feed" rel="alternate">`, "alternate", BASE),
|
||||
"https://example.com/feed",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<Dump>("/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;
|
||||
|
||||
@@ -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<FollowStatus>("/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()}`, {
|
||||
|
||||
@@ -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<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(
|
||||
(await post("/api/notifications/read-all", { auth: me.auth })).status,
|
||||
204,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -163,6 +163,14 @@ test("GET /api/users/search: prefix match for mentions, capped at 8", async () =
|
||||
avatarMime: null,
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -29,16 +29,12 @@ export function isCreateUrlDumpRequest(
|
||||
!("url" in obj) || typeof obj.url !== "string"
|
||||
) return false;
|
||||
const o = obj as Record<string, unknown>;
|
||||
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<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 (typeof o.title !== "string") return false;
|
||||
const trimmed = (o.title as string).trim();
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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" &&
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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" &&
|
||||
|
||||
@@ -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 = /(?<![[(\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())),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
* (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<WebSocket | null>(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`;
|
||||
|
||||
@@ -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 = /(?<![[(\w])@(\w+)$/;
|
||||
const TRIGGER_RE = new RegExp(`${MENTION_RE.source}$`);
|
||||
|
||||
/**
|
||||
* Returns the `@query` that ends at `pos` in `value`, or null when there is
|
||||
|
||||
@@ -11,7 +11,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
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 type { Wire } from "../../shared/api.ts";
|
||||
import type { AuthResponse, RegisterUserRequest } from "../../shared/user.ts";
|
||||
@@ -133,9 +133,7 @@ export function UserRegister(): React.JSX.Element {
|
||||
rules={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: new RegExp(
|
||||
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
|
||||
),
|
||||
value: USERNAME_RE,
|
||||
message:
|
||||
t`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`,
|
||||
},
|
||||
|
||||
@@ -138,13 +138,6 @@ describe("connection", () => {
|
||||
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 });
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user