v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 21:07:34 +00:00
parent 1702370447
commit d62e4a9883
119 changed files with 2967 additions and 611 deletions

View File

@@ -1,13 +1,27 @@
/**
* Images pasted into Markdown bodies (chat, comments, descriptions): their
* records, and binding them to the resource whose body links them, so the
* startup purge keeps them.
*
* @module
*/
import { db, transaction } from "../db/connection.ts";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
/** Records the upload of attachment `id`, not yet bound to any resource. */
export function createAttachment(id: string, mime: string): void {
db.prepare(
"INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)",
).run(id, mime, new Date().toISOString());
}
/**
* Returns attachment `attachmentId`'s MIME type.
*
* @throws {APIException} 404 when there is no such attachment.
*/
export function getAttachment(attachmentId: string): { mime: string } {
const row = db.prepare(
"SELECT mime FROM attachments WHERE id = ?",
@@ -24,12 +38,12 @@ export function getAttachment(attachmentId: string): { mime: string } {
return { mime: row.mime };
}
// UUID pattern used to extract attachment IDs from markdown/text bodies.
/** An attachment URL in a Markdown body, capturing its id. */
const ATTACHMENT_RE =
/\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g;
/**
* Parse `text` for attachment URLs and bind any unowned attachments to
* Binds the unowned attachments whose URLs appear in `text` to
* `resourceId`. Only updates rows where resource_id IS NULL so a user cannot
* claim attachments that already belong to another resource.
*/

View File

@@ -1,3 +1,10 @@
/**
* Backlinks between dumps: which dumps a dump's description and comments link
* to, and the dumps related to one through them.
*
* @module
*/
import type { Dump } from "../../shared/dump.ts";
import { db, transaction } from "../db/connection.ts";
import {
@@ -7,17 +14,22 @@ import {
} from "../model/dump.ts";
import { UUID_RE } from "../lib/slugify.ts";
// Matches http(s) URLs in free text — stops at whitespace or markdown-link
// delimiters (so `[text](url)` and `<url>` don't swallow the closing char).
/**
* An http(s) URL in free text — stops at whitespace or markdown-link
* delimiters (so `[text](url)` and `<url>` don't swallow the closing char).
*/
const URL_RE = /https?:\/\/[^\s)\]<>"']+/g;
// A link to another dump's page on this same site (e.g. pasted from the
// address bar) — host-agnostic, since the configured public URL can differ
// between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`,
// ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links
// still resolve to the same target dump).
/**
* A link to another dump's page on this same site (e.g. pasted from
* the address bar) — host-agnostic, since the configured public URL can differ
* between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`,
* ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links
* still resolve to the same target dump).
*/
const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/;
/** Returns `url` as the URL parser normalises it, or null when it isn't one. */
function canonicalize(url: string): string | null {
try {
return new URL(url).toString();
@@ -35,7 +47,7 @@ function resolveDumpPathId(idOrSlug: string): string | null {
}
/**
* Re-derive the backlinks contributed by one text source (a dump's own
* Re-derives the backlinks contributed by one text source (a dump's own
* `comment` field, or a single comment's `body`) and persist them. Always
* deletes the source's previous contribution first, so editing/clearing the
* text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`,
@@ -93,7 +105,10 @@ export function relinkBacklinks(
});
}
/** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */
/**
* Returns the dumps linked to or from `dumpId`, newest first and at most 20,
* without the private ones the requesting user can't see.
*/
export function getRelatedDumps(
dumpId: string,
requestingUserId?: string,

View File

@@ -1,3 +1,9 @@
/**
* Categories: managing them, and which dumps belong to which.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
@@ -17,6 +23,7 @@ import { type Page, pageOf } from "../lib/pagination.ts";
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
import { VALIDATION } from "../../shared/validation.ts";
/** Throws a 400 unless `slug` is well-formed, short enough, and not reserved. */
function validateSlug(slug: string): void {
if (!isValidSlugFormat(slug)) {
throw new APIException(
@@ -41,6 +48,7 @@ function validateSlug(slug: string): void {
}
}
/** Returns `name` trimmed, or throws a 400 when it is blank or too long. */
function requireName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
@@ -56,7 +64,10 @@ function requireName(name: string): string {
return trimmed;
}
// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field.
/**
* Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending
* field, and anything else as is.
*/
function uniqueConflict(err: unknown, slug: string, name: string): never {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("categories.name")) {
@@ -76,6 +87,11 @@ function uniqueConflict(err: unknown, slug: string, name: string): never {
throw err as Error;
}
/**
* Returns category `id`.
*
* @throws {APIException} 404 when there is no such category.
*/
export function getCategoryById(id: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id);
if (!row || !isCategoryRow(row)) {
@@ -84,6 +100,11 @@ export function getCategoryById(id: string): Category {
return categoryRowToApi(row);
}
/**
* Returns the category at `slug`.
*
* @throws {APIException} 404 when there is no such category.
*/
export function getCategoryBySlug(slug: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
if (!row || !isCategoryRow(row)) {
@@ -92,7 +113,7 @@ export function getCategoryBySlug(slug: string): Category {
return categoryRowToApi(row);
}
/** Resolve a slug to its category id, or undefined if no such category. */
/** Returns the id of the category at `slug`, or undefined when there is none. */
export function getCategoryIdBySlug(slug: string): string | undefined {
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
return row && typeof (row as { id?: unknown }).id === "string"
@@ -100,6 +121,7 @@ export function getCategoryIdBySlug(slug: string): string | undefined {
: undefined;
}
/** Returns every category, by position then slug. */
export function listCategories(): Category[] {
const rows = db.prepare(
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
@@ -107,6 +129,12 @@ export function listCategories(): Category[] {
return rows.filter(isCategoryRow).map(categoryRowToApi);
}
/**
* Creates a category.
*
* @throws {APIException} 400 when the slug or name is invalid, 409 when
* either is taken.
*/
export function createCategory(req: CreateCategoryRequest): Category {
validateSlug(req.slug);
const name = requireName(req.name);
@@ -137,6 +165,12 @@ export function createCategory(req: CreateCategoryRequest): Category {
};
}
/**
* Applies `req` to category `id`.
*
* @throws {APIException} 404 when there is no such category, 400 when the new
* slug or name is invalid, 409 when either is taken.
*/
export function updateCategory(
id: string,
req: UpdateCategoryRequest,
@@ -176,6 +210,11 @@ export function updateCategory(
};
}
/**
* Deletes category `id`; its dumps stay, outside it.
*
* @throws {APIException} 404 when there is no such category.
*/
export function deleteCategory(id: string): void {
const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id);
if (result.changes === 0) {
@@ -184,9 +223,11 @@ export function deleteCategory(id: string): void {
}
/**
* Throws a 400 unless every id names an existing category. Callers that write
* Checks that every id names an existing category. Callers that write
* anything else first (a dump row, an uploaded file) run this before those
* writes, so a bad id can't leave them half-done.
*
* @throws {APIException} 400 when an id names no category.
*/
export function assertCategoriesExist(categoryIds: string[]): void {
const unique = [...new Set(categoryIds)];
@@ -207,8 +248,10 @@ export function assertCategoriesExist(categoryIds: string[]): void {
}
/**
* Replace the set of categories a dump belongs to. Unknown category ids are
* rejected. An empty list clears all category assignments.
* Replaces the set of categories a dump belongs to. An empty list clears all
* category assignments.
*
* @throws {APIException} 400 when an id names no category.
*/
export function setDumpCategories(
dumpId: string,
@@ -228,7 +271,7 @@ export function setDumpCategories(
});
}
/** Fill `categoryIds` on each dump with a single batched lookup. */
/** Fills `categoryIds` on each dump with a single batched lookup. */
export function attachCategoryIds(dumps: Dump[]): void {
if (dumps.length === 0) return;
const ids = dumps.map((d) => d.id);
@@ -248,6 +291,12 @@ export function attachCategoryIds(dumps: Dump[]): void {
}
}
/**
* Returns a page of the dumps in the category at `slug`, newest first, with
* the requesting user's private ones.
*
* @throws {APIException} 404 when there is no such category.
*/
export function listDumpsByCategory(
slug: string,
page: number,

View File

@@ -1,3 +1,9 @@
/**
* The site-wide chat: messages, their history, and each user's read marker.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { ChatMessage } from "../../shared/chat.ts";
@@ -6,27 +12,36 @@ import { db } from "../db/connection.ts";
import { linkAttachments } from "./attachment-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
/** How many messages a page holds when the caller names no limit. */
const DEFAULT_LIMIT = 50;
/** The most messages a page may hold. */
const MAX_LIMIT = 100;
// Length cap for the replied-to preview snippet, so a reply to a long message
// doesn't ship the whole 2000-char body over the wire for every reply. This is
// raw markdown: the client strips it to text before showing a line of it, so
// it's cut generously enough that an image URL or link doesn't eat it all.
/**
* The length cap of the replied-to preview snippet, so a reply to a long
* message doesn't ship the whole 2000-char body over the wire for every reply.
* This is raw markdown: the client strips it to text before showing a line of
* it, so it's cut generously enough that an image URL or link doesn't eat it
* all.
*/
const REPLY_SNIPPET_LEN = 300;
/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
// Common joins: author (required) plus the optional replied-to message and its
// author (LEFT — null when there's no reply or the target was deleted).
/**
* The common joins: author (required) plus the optional replied-to message and
* its author (LEFT — null when there's no reply or the target was deleted).
*/
const FROM_JOINS = `FROM chat_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`;
/** Returns message `id`, or throws a 404. */
function fetchMessage(id: string): ChatMessage {
const row = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
@@ -37,6 +52,13 @@ function fetchMessage(id: string): ChatMessage {
return chatMessageRowToApi(row);
}
/**
* Posts a message from `userId`, replying to `replyToId` when that message
* exists, and binds its attachments.
*
* @throws {APIException} 400 when the body is blank or longer than
* `VALIDATION.CHAT_MESSAGE_MAX`.
*/
export function createChatMessage(
userId: string,
body: string,
@@ -77,6 +99,7 @@ export function createChatMessage(
return fetchMessage(id);
}
/** Returns the author of message `id`, or throws a 404. */
function getMessageOwner(id: string): string {
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
id,
@@ -87,7 +110,14 @@ function getMessageOwner(id: string): string {
return row.user_id;
}
// Owners may edit their own messages; moderators may edit any.
/**
* Replaces a message's body, as its author or a moderator (`canModerate`), and
* binds any new attachment.
*
* @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator, 400 when the body is blank or too
* long.
*/
export function updateChatMessage(
id: string,
body: string,
@@ -124,7 +154,12 @@ export function updateChatMessage(
return fetchMessage(id);
}
// Owners may delete their own messages; moderators may delete any.
/**
* Deletes a message, as its author or a moderator (`canModerate`).
*
* @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator.
*/
export function deleteChatMessage(
id: string,
requestingUserId: string,
@@ -141,16 +176,22 @@ export function deleteChatMessage(
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
}
/** A position in the chat stream. Messages are ordered by (createdAt, id), so
/**
* A position in the chat stream. Messages are ordered by (createdAt, id), so
* two sharing a millisecond still have a strict order and a page boundary
* between them can't skip either. */
* between them can't skip either.
*/
export interface ChatCursor {
/** The `created_at` of the message the cursor sits at. */
createdAt: string;
/** Omitted: the cursor sits before (for `after`) or after (for `before`)
* every message sharing that timestamp. */
/**
* The id of that message. Omitted: the cursor sits before (for `after`) or
* after (for `before`) every message sharing that timestamp.
*/
id?: string;
}
/** Converts query rows to messages. */
function toMessages(rows: unknown[]): ChatMessage[] {
if (!rows.every(isChatMessageRow)) {
throw new APIException(
@@ -162,6 +203,7 @@ function toMessages(rows: unknown[]): ChatMessage[] {
return rows.map(chatMessageRowToApi);
}
/** Clamps a page size to [1, {@link MAX_LIMIT}]. */
function capLimit(limit: number): number {
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
}
@@ -213,13 +255,20 @@ export function getChatMessagesAfter(
return toMessages(rows);
}
/** How far a user has read the chat. */
export interface ChatReadState {
/** created_at of the newest message the user has seen (ISO). */
/** The `created_at` of the newest message the user has seen (ISO). */
readAt: string;
/** Messages from other people since then. */
unread: number;
}
/**
* Returns how far `userId` has read the chat; a user who never opened it has
* read up to their registration.
*
* @throws {APIException} 404 when there is no such user.
*/
export function getChatReadState(userId: string): ChatReadState {
const row = db.prepare(
`SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at,
@@ -238,6 +287,9 @@ export function getChatReadState(userId: string): ChatReadState {
* Moves the user's read marker forward to `at` (a message's created_at) —
* never backward, so a lagging tab can't undo a newer tab's read, and never
* past the newest message, so a bogus timestamp can't pre-read the future.
*
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
* such user.
*/
export function markChatRead(userId: string, at: string): ChatReadState {
const parsed = new Date(at);

View File

@@ -1,3 +1,9 @@
/**
* Likes on comments.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
@@ -5,13 +11,18 @@ import { db } from "../db/connection.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { notifyCommentOwnerLike } from "./notification-service.ts";
/** A comment's likes after a like was cast or removed. */
export interface LikeResult {
/** The comment's like count now. */
likeCount: number;
/** Likes on a private dump's comments go to the dump's owner only. */
/** Whether the comment's dump is private: its likes go to the dump's owner only. */
isPrivate: boolean;
}
// Visibility of the comment's dump, or a 404 when the user can't see it.
/**
* Returns whether the comment's dump is private, or throws a 404 when the user
* can't see it.
*/
function commentVisibility(commentId: string, userId: string): boolean {
const row = db.prepare(
`SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id
@@ -23,6 +34,12 @@ function commentVisibility(commentId: string, userId: string): boolean {
return Boolean(row.is_private);
}
/**
* Records `userId`'s like on a comment and notifies its author.
*
* @throws {APIException} 404 when the comment doesn't exist or sits on someone
* else's private dump, 409 when the user already likes it.
*/
export function castCommentLike(
commentId: string,
userId: string,
@@ -62,8 +79,12 @@ export function castCommentLike(
return { likeCount, isPrivate };
}
// No visibility check: taking back a like is harmless, and it's the user's own
// row.
/**
* Takes back `userId`'s like on a comment. No visibility check: taking back a
* like is harmless, and it's the user's own row.
*
* @throws {APIException} 404 when the user doesn't like the comment.
*/
export function removeCommentLike(
commentId: string,
userId: string,
@@ -97,6 +118,7 @@ export function removeCommentLike(
}
}
/** Returns the ids of every comment `userId` likes. */
export function getUserCommentLikes(userId: string): string[] {
const rows = db.prepare(
`SELECT comment_id FROM comment_likes WHERE user_id = ?;`,
@@ -104,6 +126,7 @@ export function getUserCommentLikes(userId: string): string[] {
return rows.map((r) => r.comment_id);
}
/** Returns a page of the users who like a comment, latest first. */
export function getCommentLikers(
commentId: string,
page: number,

View File

@@ -1,3 +1,9 @@
/**
* Comments on dumps, threaded by `parent_id`.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Comment } from "../../shared/comment.ts";
@@ -11,10 +17,12 @@ import {
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
/** The columns of a full `CommentRow`, from `comments c` and `users u`. */
const SELECT_COLS =
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
u.username as author_username, u.avatar_mime as author_avatar_mime`;
/** Returns comment `commentId`, or throws a 404. */
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 = ?;`,
@@ -32,6 +40,11 @@ function fetchComment(commentId: string): Comment {
return commentRowToApi(row);
}
/**
* Returns the id of the dump comment `commentId` is posted under.
*
* @throws {APIException} 404 when there is no such comment.
*/
export function getCommentDumpId(commentId: string): string {
const row = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`).get(
commentId,
@@ -42,6 +55,10 @@ export function getCommentDumpId(commentId: string): string {
return row.dump_id;
}
/**
* Returns every comment on dump `dumpId`, deleted ones included, oldest
* first.
*/
export function getComments(dumpId: string): Comment[] {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
@@ -57,6 +74,11 @@ export function getComments(dumpId: string): Comment[] {
return rows.map(commentRowToApi);
}
/**
* Posts a comment on dump `dumpId`, replying to `parentId` if given; notifies
* the dump's poster and anyone @mentioned, and binds its attachments and
* backlinks.
*/
export function createComment(
dumpId: string,
userId: string,
@@ -86,6 +108,13 @@ export function createComment(
return comment;
}
/**
* Replaces a comment's body, as its author or a moderator (`canModerate`);
* notifies anyone newly @mentioned, and rebinds its attachments and backlinks.
*
* @throws {APIException} 404 when there is no such comment, 400 when it is
* deleted, 401 when the user is neither its author nor a moderator.
*/
export function updateComment(
commentId: string,
body: string,
@@ -139,6 +168,14 @@ export function updateComment(
};
}
/**
* Deletes a comment, as its author or a moderator (`canModerate`): its body is
* emptied and its backlinks dropped, but the row stays so its replies keep
* their place.
*
* @throws {APIException} 404 when there is no such comment, 401 when the user
* is neither its author nor a moderator.
*/
export function deleteComment(
commentId: string,
requestingUserId: string,

View File

@@ -1,3 +1,13 @@
/**
* Dumps: posting links and files, editing and deleting them, and reading them
* as each viewer may see them. A private dump exists only for its poster.
*
* The functions that change a dump don't check who asks: the routes do, before
* calling them.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
@@ -49,13 +59,17 @@ import {
setDumpCategories,
} from "./category-service.ts";
/** Checks whether a file dump may have type `mime`. */
function isAllowedMime(mime: string): boolean {
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
DUMP_ALLOWED_MIME_TYPES.has(mime);
}
// The JSON routes validate these in isCreateUrlDumpRequest/isUpdateDumpRequest;
// the multipart ones (file create and replace) arrive here unchecked.
/**
* Throws a 400 when a file dump's title or comment is too long. The JSON routes
* validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; the multipart
* ones (file create and replace) arrive here unchecked.
*/
function assertFileDumpText(title?: string, comment?: string): void {
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
throw new APIException(
@@ -73,6 +87,7 @@ function assertFileDumpText(title?: string, comment?: string): void {
}
}
/** Returns the title a link gets without a better one: its host. */
function titleFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
@@ -81,6 +96,13 @@ function titleFromUrl(url: string): string {
}
}
/**
* Posts a link as `userId`, with the preview fetched from it; a public one is
* announced and notified to the poster's followers.
*
* @throws {APIException} 400 when the URL isn't a public http(s) URL or a
* category doesn't exist.
*/
export async function createUrlDump(
request: CreateUrlDumpRequest,
userId: string,
@@ -148,6 +170,13 @@ export async function createUrlDump(
return dump;
}
/**
* Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public
* one is announced and notified to the poster's followers.
*
* @throws {APIException} 400 when the title or comment is too long, the file's
* type isn't allowed, it exceeds 50 MB, or a category doesn't exist.
*/
export async function createFileDump(
file: File,
comment: string | undefined,
@@ -239,7 +268,7 @@ export async function createFileDump(
}
/**
* A file dump's version as the app puts it in file and thumbnail URLs
* Returns a file dump's version as the app puts it in file and thumbnail URLs
* (`?v=`): it changes whenever the file can have. Mirrored by
* `dumpFileVersion` in src/utils/shared/urls.ts.
*/
@@ -251,8 +280,11 @@ export function dumpFileVersion(
}`;
}
/** Files generated from a dump's upload (see api/routes/thumbnails.ts):
* dropped when it's replaced or gone, so they're made again from the new one. */
/**
* Removes the files generated from a dump's upload (see
* api/routes/thumbnails.ts) when it's replaced or gone, so they're made again
* from the new one.
*/
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
await Promise.all(
[`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map(
@@ -261,7 +293,10 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
);
}
// Internal fetch — no privacy check. Use only when ownership is already enforced.
/**
* Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy
* check: use only when ownership is already enforced.
*/
function fetchDump(idOrSlug: string): Dump {
const row = UUID_RE.test(idOrSlug)
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug)
@@ -274,7 +309,11 @@ function fetchDump(idOrSlug: string): Dump {
return dumpRowToApi(row);
}
// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own.
/**
* Returns a dump, by id or slug, as `requestingUserId` may see it.
*
* @throws {APIException} 404 when there is no such dump, or it is private and not theirs.
*/
export function getDump(dumpId: string, requestingUserId?: string): Dump {
const dump = fetchDump(dumpId);
if (dump.isPrivate && dump.userId !== requestingUserId) {
@@ -284,6 +323,11 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
return dump;
}
/**
* Returns a page of the dumps whose title, comment, or previewed title or
* description contains `query`, newest first, and how many match in all: the
* public ones, and the requesting user's own.
*/
export function searchDumps(
query: string,
page: number,
@@ -321,6 +365,10 @@ export function searchDumps(
}
}
/**
* Returns a page of the dumps, newest first: the public ones, and the
* requesting user's own.
*/
export function listDumps(
page: number,
limit: number,
@@ -350,7 +398,8 @@ export function listDumps(
}
/**
* Dumps that already point at the same thing as `url`, newest first.
* Returns the dumps that already point at the same thing as `url`, newest
* first.
*
* Matched on the canonical key rather than the raw URL, so a link reposted with
* different tracking parameters, scheme, `www.`, or (for YouTube) in a
@@ -397,6 +446,14 @@ export function findDumpsByUrl(
}));
}
/**
* Applies `request` to a dump: its title, comment, visibility and categories,
* and a URL dump's link (fetching the new preview). Announces the change as the
* visibility requires, and notifies anyone newly @mentioned.
*
* @throws {APIException} 404 when there is no such dump, 400 when a new URL isn't a public http(s) URL
* or a category doesn't exist.
*/
export async function updateDump(
dumpId: string,
request: UpdateDumpRequest,
@@ -538,14 +595,23 @@ export async function updateDump(
return updatedDump;
}
/**
* Replaces a file dump's upload, and with it its comment and optionally its
* title, categories and visibility. `isPrivate` left out, visibility stays as
* it is; it is taken here rather than by a second request so a new file and a
* change of visibility land together. The old file is restored if the update
* fails.
*
* @throws {APIException} 404 when there is no such dump; 400 when it isn't a file dump, the title or
* comment is too long, the file's type isn't allowed, it exceeds 50 MB, or a
* category doesn't exist.
*/
export async function replaceFileDump(
dumpId: string,
file: File,
comment: string | undefined,
title?: string,
categoryIds?: string[],
// Left out, visibility stays as it is. Taken here rather than by a second
// request so a new file and a change of visibility land together.
isPrivate?: boolean,
): Promise<Dump> {
assertFileDumpText(title, comment);
@@ -651,6 +717,10 @@ export async function replaceFileDump(
return updatedDump;
}
/**
* Returns a page of `userId`'s dumps, newest first, the private ones only with
* `includePrivate`.
*/
export function getDumpsByUser(
userId: string,
page: number,
@@ -674,6 +744,10 @@ export function getDumpsByUser(
return pageOf(items, limit);
}
/**
* Returns a page of the dumps `userId` upvoted, latest vote first — their own
* private ones only when they are the one asking.
*/
export function getVotedDumpsByUser(
userId: string,
page: number,
@@ -715,6 +789,12 @@ export function getVotedDumpsByUser(
return pageOf(items, limit);
}
/**
* Fetches a URL dump's preview again and replaces its title and preview.
*
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a URL dump, 502 when the
* page can't be reached.
*/
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
@@ -754,6 +834,11 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
return updatedDump;
}
/**
* Returns a dump's id and custom thumbnail type, or undefined without one.
*
* @throws {APIException} 404 when there is no such dump.
*/
export function getDumpThumbnailInfo(
idOrSlug: string,
): { id: string; mime: string } | undefined {
@@ -763,6 +848,11 @@ export function getDumpThumbnailInfo(
: undefined;
}
/**
* Records a dump's new custom thumbnail, once its file is in place.
*
* @throws {APIException} 404 when there is no such dump.
*/
export function setDumpThumbnail(dumpId: string, mime: string): Dump {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = ? WHERE id = ?;`).run(
@@ -774,6 +864,11 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
return updatedDump;
}
/**
* Removes a dump's custom thumbnail, and its file.
*
* @throws {APIException} 404 when there is no such dump.
*/
export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
@@ -784,6 +879,11 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
return updatedDump;
}
/**
* Deletes a dump, its files and thumbnails, and announces it.
*
* @throws {APIException} 404 when there is no such dump.
*/
export async function deleteDump(dumpId: string): Promise<void> {
const dump = fetchDump(dumpId);

View File

@@ -1,32 +1,54 @@
/**
* Sending mail through the SMTP server in {@link SMTPS_URL}.
*
* @module
*/
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
import { SMTPS_URL } from "../config.ts";
/** One address, a comma-separated list of them, or an array. */
export type EmailRecipient = string | string[];
/** A message to send. It needs a recipient, a subject, and a text or HTML body. */
export interface EmailMessage {
/** The sender's address. */
from: string;
/** The recipients. */
to: EmailRecipient;
/** The subject line. */
subject: string;
/** The plain-text body. */
text?: string;
/** The HTML body. */
html?: string;
/** The carbon-copy recipients. */
cc?: EmailRecipient;
/** The blind-carbon-copy recipients. */
bcc?: EmailRecipient;
/** The address replies go to. */
replyTo?: string;
}
/** What the SMTP server said about a sent message. */
export interface EmailSendResult {
/** The message's `Message-ID`. */
messageId: string;
/** The recipients the server accepted. */
accepted: string[];
/** The recipients the server rejected. */
rejected: string[];
}
let transporter: Transporter | null = null;
/** Checks whether an SMTP server is configured. */
export function isEmailEnabled(): boolean {
return SMTPS_URL.length > 0;
}
/** Flattens the recipient forms nodemailer takes and returns into addresses. */
function normalizeRecipients(
recipient: string | readonly (string | { address?: string })[] | undefined,
): string[] {
@@ -53,12 +75,24 @@ function getTransporter(): Transporter {
return transporter;
}
/**
* Checks the connection to the SMTP server. Resolves false when email is
* disabled.
*
* @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`.
*
* @throws {Error} When email is disabled, the message lacks a recipient,
* subject or body, or the server refuses it.
*/
export async function sendEmail(
message: EmailMessage,
): Promise<EmailSendResult> {

View File

@@ -1,3 +1,9 @@
/**
* Following users and playlists, and the feeds made of what one follows.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type { Dump } from "../../shared/dump.ts";
@@ -21,6 +27,12 @@ import { attachCategoryIds } from "./category-service.ts";
// ── Follow / unfollow a user ──────────────────────────────────────────────────
/**
* Makes `followerId` follow `followedUserId`, and notifies them the first
* time. Following again changes nothing.
*
* @throws {APIException} 400 when a user tries to follow themselves.
*/
export function followUser(followerId: string, followedUserId: string): void {
if (followerId === followedUserId) {
throw new APIException(
@@ -51,6 +63,7 @@ export function followUser(followerId: string, followedUserId: string): void {
if (isNew) notifyUserNewFollower(followerId, followedUserId);
}
/** Makes `followerId` stop following `followedUserId`, if they did. */
export function unfollowUser(followerId: string, followedUserId: string): void {
db.prepare(
`DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`,
@@ -59,6 +72,13 @@ export function unfollowUser(followerId: string, followedUserId: string): void {
// ── Follow / unfollow a playlist ─────────────────────────────────────────────
/**
* Makes `followerId` follow a public playlist, and notifies its owner the
* first time. Following again changes nothing.
*
* @throws {APIException} 404 when there is no such playlist, 403 when it is
* private.
*/
export function followPlaylist(followerId: string, playlistId: string): void {
const row = db.prepare(
`SELECT id, is_public FROM playlists WHERE id = ?;`,
@@ -97,6 +117,7 @@ export function followPlaylist(followerId: string, playlistId: string): void {
if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId);
}
/** Makes `followerId` stop following a playlist, if they did. */
export function unfollowPlaylist(followerId: string, playlistId: string): void {
db.prepare(
`DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`,
@@ -105,6 +126,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void {
// ── Follow status ─────────────────────────────────────────────────────────────
/** Returns the ids of every user and playlist `followerId` follows. */
export function getFollowStatus(followerId: string): FollowStatus {
const rawUserRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
@@ -132,6 +154,10 @@ export function getFollowStatus(followerId: string): FollowStatus {
// ── Followed-users feed ───────────────────────────────────────────────────────
/**
* Returns a page of the public dumps posted by the users `followerId` follows,
* newest first, optionally only those in `categoryId`.
*/
export function getFollowedUsersDumpFeed(
followerId: string,
page: number,
@@ -172,6 +198,10 @@ export function getFollowedUsersDumpFeed(
// ── Followed-playlists dump feed ──────────────────────────────────────────────
/**
* Returns a page of the public dumps in the public playlists `followerId`
* follows, most recently added first, optionally only those in `categoryId`.
*/
export function getFollowedPlaylistsDumpFeed(
followerId: string,
page: number,
@@ -214,6 +244,10 @@ export function getFollowedPlaylistsDumpFeed(
// ── Followed playlists (as playlist objects) ──────────────────────────────────
/**
* Returns a page of the public playlists `userId` follows, most recently
* followed first.
*/
export function getFollowedPlaylistsByUser(
userId: string,
page: number,
@@ -245,6 +279,7 @@ export function getFollowedPlaylistsByUser(
// ── Followed users (as user objects) ─────────────────────────────────────────
/** Returns a page of the users `userId` follows, most recently followed first. */
export function getFollowedUsersByUser(
userId: string,
page: number,

View File

@@ -1,3 +1,9 @@
/**
* Invites: making them, and checking and claiming one on registration.
*
* @module
*/
import { randomBytes } from "node:crypto";
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
@@ -5,11 +11,15 @@ import { db } from "../db/connection.ts";
import { isInviteRow } from "../model/invite.ts";
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */
/**
* Returns a short, opaque, URL-safe invite token (128 bits of entropy → 22
* chars).
*/
function generateInviteToken(): string {
return randomBytes(16).toString("base64url");
}
/** Makes an invite from `inviterId` and returns its token. */
export function createInvite(inviterId: string): string {
const token = generateInviteToken();
db.prepare(
@@ -20,12 +30,14 @@ export function createInvite(inviterId: string): string {
/**
* Looks the token up, checks it has not been used and has not expired (same
* 7-day window as the startup cleanup). Returns the inviterId on success;
* throws APIException otherwise.
* 7-day window as the startup cleanup), and returns the inviter's id.
*
* Tokens are opaque random strings stored as the row's primary key. Invites
* created before this scheme (full JWTs) are still stored verbatim in the
* token column, so they continue to validate by direct lookup.
*
* @throws {APIException} 404 when the invite doesn't exist or has expired,
* 409 when it has been used.
*/
export function validateInvite(token: string): string {
const row = db.prepare(
@@ -62,9 +74,13 @@ export function validateInvite(token: string): string {
}
/**
* Validates the token and marks it used in one step, returning the inviterId.
* Validates the token and marks it used in one step, returning the inviter's
* id.
* Claim before the (async) account creation, not after: checking first and
* marking later lets parallel registrations all pass the check on one invite.
*
* @throws {APIException} 404 when the invite doesn't exist or has expired,
* 409 when it has been used.
*/
export function claimInvite(token: string): string {
const inviterId = validateInvite(token);

View File

@@ -1,3 +1,11 @@
/**
* Notifications: storing them, pushing each new one to its user's sockets,
* and the triggers that create them. A trigger passes a source key, so the same
* event never notifies a user twice.
*
* @module
*/
import type {
Notification,
NotificationData,
@@ -17,7 +25,10 @@ import { MENTION_RE } from "../../shared/mention.ts";
// ── Core CRUD ─────────────────────────────────────────────────────────────────
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
/**
* Stores a notification for `userId` and pushes it live. With a `sourceKey`,
* a second one for the same `(userId, sourceKey)` is a no-op.
*/
function createNotification(
userId: string,
type: NotificationType,
@@ -61,6 +72,7 @@ function createNotification(
}
}
/** Returns a page of `userId`'s notifications, newest first. */
export function getNotificationsForUser(
userId: string,
page: number,
@@ -81,6 +93,7 @@ export function getNotificationsForUser(
return pageOf(rawRows.map(notificationRowToApi), limit);
}
/** Returns how many of `userId`'s notifications are unread. */
export function getUnreadCount(userId: string): number {
const row = db.prepare(
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`,
@@ -88,12 +101,14 @@ export function getUnreadCount(userId: string): number {
return row?.count ?? 0;
}
/** Marks every notification of `userId` read. */
export function markAllRead(userId: string): void {
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
userId,
);
}
/** Marks one of `userId`'s notifications read; someone else's is left alone. */
export function markOneRead(notificationId: string, userId: string): void {
db.prepare(
`UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`,
@@ -102,6 +117,7 @@ export function markOneRead(notificationId: string, userId: string): void {
// ── Trigger helpers ───────────────────────────────────────────────────────────
/** Notifies `followedUserId` that `followerId` follows them. */
export function notifyUserNewFollower(
followerId: string,
followedUserId: string,
@@ -120,6 +136,10 @@ export function notifyUserNewFollower(
);
}
/**
* Notifies a playlist's owner that `followerId` follows it, unless they are
* the owner.
*/
export function notifyPlaylistOwnerNewFollower(
followerId: string,
playlistId: string,
@@ -148,6 +168,7 @@ export function notifyPlaylistOwnerNewFollower(
);
}
/** Notifies everyone following `dumperId` that they posted a dump. */
export function notifyUserFollowersNewDump(
dumperId: string,
dumpId: string,
@@ -215,6 +236,7 @@ export function notifyUserFollowersNewDump(
});
}
/** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */
export function notifyDumpOwnerUpvote(
voterId: string,
dumpId: string,
@@ -243,6 +265,13 @@ export function notifyDumpOwnerUpvote(
);
}
/**
* Notifies every user @mentioned in `body`, except its author, that they were
* mentioned in the given comment, dump, playlist or chat message. Each is
* notified once per mention context, however often the body is edited.
* `skipUserId` spares a mentioned user (e.g. one already reading the chat
* live, for whom the persistent notification would be redundant).
*/
export function notifyMentions(
mentionerUserId: string,
body: string,
@@ -250,8 +279,6 @@ export function notifyMentions(
contextId: string,
contextTitle: string,
dumpId?: string,
// Optional: skip notifying a mentioned user (e.g. they're already reading the
// chat live, so the persistent notification would be redundant).
skipUserId?: (userId: string) => boolean,
): void {
const mentionerRow = db.prepare(
@@ -288,6 +315,10 @@ export function notifyMentions(
}
}
/**
* Notifies a dump's poster that `commenterId` commented on it, unless they are
* the poster.
*/
export function notifyDumpOwnerNewComment(
commenterId: string,
commentId: string,
@@ -318,6 +349,10 @@ export function notifyDumpOwnerNewComment(
);
}
/**
* Notifies a comment's author that `likerId` likes it, unless they are the
* author.
*/
export function notifyCommentOwnerLike(
likerId: string,
commentId: string,
@@ -350,6 +385,7 @@ export function notifyCommentOwnerLike(
);
}
/** Notifies everyone following a playlist that a dump was added to it. */
export function notifyPlaylistFollowersNewDump(
playlistId: string,
playlistTitle: string,

View File

@@ -1,3 +1,10 @@
/**
* Password resets by email: sending a reset link, and setting the new
* password with it.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { db } from "../db/connection.ts";
@@ -13,16 +20,17 @@ import { VALIDATION } from "../../shared/validation.ts";
import { marked } from "marked";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
/** How long a reset link works. */
const RESET_TOKEN_TTL_HOURS = 1;
/** Per address: enough to retry a lost email, not enough to flood an inbox. */
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
/**
* Looks up the user by email, creates a signed reset token, persists it, and
* sends the reset link. Always resolves without throwing so callers can return
* 200 unconditionally (no user enumeration).
*/
// Per address: enough to retry a lost email, not enough to flood an inbox.
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
export async function requestPasswordReset(email: string): Promise<void> {
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
@@ -60,6 +68,10 @@ export async function requestPasswordReset(email: string): Promise<void> {
/**
* Verifies the token (JWT signature + expiry + DB record + not used), updates
* the password, and marks the token consumed.
*
* @throws {APIException} 400 when the password's length is out of bounds or
* the token is invalid or expired, 404 when the token isn't on record or its
* user is gone, 409 when it has been used.
*/
export async function resetPassword(
token: string,

View File

@@ -1,3 +1,11 @@
/**
* Playlists: managing them and their dumps, and reading them as each viewer
* may see them. A playlist is public or private to its owner; its members are
* filtered per viewer (see {@link isMemberVisibleTo}).
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
@@ -33,10 +41,20 @@ import {
type PlaylistMember,
} from "../lib/playlist-members.ts";
/**
* The columns of a `PlaylistRow` with its owner's name and dump count,
* and the `FROM playlists p` they come from.
*/
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p LEFT JOIN users u ON u.id = p.user_id`;
/**
* Returns the playlist with id or slug `idOrSlug`, private or not: callers
* that show it check its visibility.
*
* @throws {APIException} 404 when there is no such playlist.
*/
export function getPlaylistById(idOrSlug: string): Playlist {
const row = UUID_RE.test(idOrSlug)
? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug)
@@ -47,6 +65,10 @@ export function getPlaylistById(idOrSlug: string): Playlist {
return playlistRowToApi(row);
}
/**
* Creates a playlist owned by `userId`, notifies anyone @mentioned in its
* description, and announces it.
*/
export function createPlaylist(
req: CreatePlaylistRequest,
userId: string,
@@ -83,6 +105,12 @@ export function createPlaylist(
return playlist;
}
/**
* Returns a playlist with the dumps `requestingUserId` may see in it, in
* order.
*
* @throws {APIException} 404 when there is no such playlist, or it is private and not theirs.
*/
export function getPlaylist(
playlistId: string,
requestingUserId: string | null,
@@ -111,6 +139,10 @@ export function getPlaylist(
return { ...playlist, dumps: visibleDumps };
}
/**
* Returns up to `limit` playlists whose title or description contains
* `query`, newest first: the public ones, and the requesting user's own.
*/
export function searchPlaylists(
query: string,
limit: number,
@@ -131,6 +163,10 @@ export function searchPlaylists(
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
/**
* Returns a page of `userId`'s playlists, newest first — the private ones only
* to `userId` themselves.
*/
export function listPlaylistsByUser(
userId: string,
requestingUserId: string | null,
@@ -148,6 +184,13 @@ export function listPlaylistsByUser(
return pageOf(rows.filter(isPlaylistRow).map(playlistRowToApi), limit);
}
/**
* Applies `req` to a playlist (its slug follows the title), as its owner or a
* moderator (`canModerate`); notifies anyone @mentioned in the description,
* and announces the change.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
*/
export function updatePlaylist(
playlistId: string,
req: UpdatePlaylistRequest,
@@ -203,6 +246,12 @@ export function updatePlaylist(
return updated;
}
/**
* Deletes a playlist, as its owner or a moderator (`canModerate`), and
* announces it.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
*/
export function deletePlaylist(
playlistId: string,
requestingUserId: string,
@@ -218,7 +267,11 @@ export function deletePlaylist(
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
}
/** The playlist, if the requester may edit it; 404 or 403 otherwise. */
/**
* Returns the playlist, if the requester may edit it.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
*/
export function getEditablePlaylist(
playlistId: string,
requestingUserId: string,
@@ -231,7 +284,12 @@ export function getEditablePlaylist(
return playlist;
}
// Call once the image file is in place: this is what announces it.
/**
* Records a playlist's new cover image and announces it. Call once the image
* file is in place: this is what announces it.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
*/
export function setPlaylistImage(
playlistId: string,
imageMime: string,
@@ -253,6 +311,13 @@ export function setPlaylistImage(
return updated;
}
/**
* Puts a dump at the top of a playlist, announces the change, and notifies the
* followers of a public playlist when the dump is public.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator; 404 when the dump doesn't exist or is
* someone else's private dump, 409 when it is already in the playlist.
*/
export function addDumpToPlaylist(
playlistId: string,
dumpId: string,
@@ -313,6 +378,11 @@ export function addDumpToPlaylist(
}
}
/**
* Takes a dump out of a playlist, if it is in it, and announces the change.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
*/
export function removeDumpFromPlaylist(
playlistId: string,
dumpId: string,
@@ -332,6 +402,13 @@ export function removeDumpFromPlaylist(
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
}
/**
* Puts the playlist's members the requester can see in the order of
* `dumpIds`, and announces the change.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator; 400 when `dumpIds` isn't exactly the
* members they can see.
*/
export function reorderPlaylist(
playlistId: string,
dumpIds: string[],
@@ -382,6 +459,10 @@ export function reorderPlaylist(
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
}
/**
* Returns each of `userId`'s playlists, newest first, and whether it holds
* dump `dumpId`.
*/
export function getPlaylistMembershipsForDump(
dumpId: string,
userId: string,
@@ -410,7 +491,7 @@ export function getPlaylistMembershipsForDump(
}
/**
* The playlists a dump appears in — the "in collections" counterpart to
* Returns the playlists a dump appears in — the "in collections" counterpart to
* backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for
* everyone, plus the requesting user's own private ones. Capped at 20 — a
* small, bounded list of supporting context.
@@ -429,15 +510,31 @@ export function getPlaylistsForDump(
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
/**
* Returns a playlist's id, cover image type and visibility, or undefined
* without a cover. A private playlist's cover exists only for its owner.
*
* @throws {APIException} 404 when there is no such playlist, or it is private
* and `requestingUserId` isn't its owner.
*/
export function getPlaylistImageInfo(
idOrSlug: string,
): { id: string; imageMime: string } | undefined {
requestingUserId: string | null,
): { id: string; imageMime: string; isPublic: boolean } | undefined {
const playlist = getPlaylistById(idOrSlug);
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
}
return playlist.imageMime
? { id: playlist.id, imageMime: playlist.imageMime }
? {
id: playlist.id,
imageMime: playlist.imageMime,
isPublic: playlist.isPublic,
}
: undefined;
}
/** Returns a playlist's members, in order, with what their visibility takes. */
function getCurrentMembers(playlistId: string): PlaylistMember[] {
const rows = db.prepare(
`SELECT pd.dump_id, d.is_private, d.user_id

View File

@@ -1,3 +1,9 @@
/**
* Link previews: picks the provider for a URL and caches recent results.
*
* @module
*/
import type { RichContent } from "../../shared/dump.ts";
import type { RichContentProvider } from "../remote/providers/provider.ts";
import { youtubeProvider } from "../remote/providers/youtube.ts";
@@ -7,7 +13,7 @@ import { selfProvider } from "../remote/providers/self.ts";
import { genericProvider } from "../remote/providers/generic.ts";
/**
* Register providers in priority order. The first match wins.
* The providers, in priority order. The first match wins.
* `selfProvider` resolves gerbeur URLs directly from the DB (no HTTP round-trip).
* `genericProvider` must stay last — it always matches.
*/
@@ -19,36 +25,45 @@ const providers: RichContentProvider[] = [
genericProvider,
];
/** The outcome of fetching a link's preview. */
export interface FetchRichContentResult {
/** False when the page couldn't be reached at all, as opposed to reached and
/**
* False when the page couldn't be reached at all, as opposed to reached and
* found to carry no metadata. Callers that already hold good metadata must
* not overwrite it with a failure stub. */
* not overwrite it with a failure stub.
*/
ok: boolean;
/** The preview, or on failure a stub with just the URL and host. */
content?: RichContent;
}
/**
* Fetch metadata for `url`, reporting whether the fetch itself succeeded.
*
* On failure it still yields a minimal stub so a *new* dump has something
* displayable, but `ok: false` lets callers with existing metadata keep it.
*/
// Metadata fetched in the last few minutes, by URL. The create form previews a
// link and then posts it; without this the page was fetched twice, and the
// second fetch was the slow part of posting. Only successes are kept, and a
// metadata refresh (`fresh`) always goes to the page.
/** How long a fetched preview is reused. */
const RECENT_TTL_MS = 5 * 60 * 1000;
/** The most previews kept; the oldest write goes first. */
const RECENT_MAX = 200;
/**
* Previews fetched in the last few minutes, by URL. The create form previews a
* link and then posts it; without this the page was fetched twice, and the
* second fetch was the slow part of posting. Only successes are kept, and a
* metadata refresh (`fresh`) always goes to the page.
*/
const recent = new Map<
string,
{ at: number; result: Promise<FetchRichContentResult> }
>();
/** Forget every cached result (tests). */
/** Forgets every cached preview (for the tests). */
export function clearRichContentCache(): void {
recent.clear();
}
/**
* Fetches the preview of `url`, reporting whether the fetch itself succeeded.
* A preview fetched in the last five minutes is reused unless `fresh` is set.
*
* On failure it still yields a minimal stub so a *new* dump has something
* displayable, but `ok: false` lets callers with existing metadata keep it.
*/
export function tryFetchRichContent(
url: string,
{ fresh = false }: { fresh?: boolean } = {},
@@ -71,6 +86,7 @@ export function tryFetchRichContent(
return result;
}
/** Fetches the preview of `url` through the first provider that matches. */
async function fetchRichContentUncached(
url: string,
): Promise<FetchRichContentResult> {
@@ -94,7 +110,7 @@ async function fetchRichContentUncached(
}
}
/** Metadata for `url`, or a minimal stub when it can't be fetched. */
/** Returns the preview of `url`, or a minimal stub when it can't be fetched. */
export async function fetchRichContent(
url: string,
): Promise<RichContent | undefined> {

View File

@@ -1,3 +1,9 @@
/**
* Users: accounts, profiles, lookups and search, and the invite tree.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import type {
@@ -21,12 +27,19 @@ import { closeUserSockets, disconnectUser } from "./ws-service.ts";
import { hashPassword } from "../lib/jwt.ts";
import { linkAttachments } from "./attachment-service.ts";
/** The query selecting full `UserRow`s from `users u`, with the inviter's name. */
const USER_SELECT =
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email,
i.username as invited_by_username
FROM users u
LEFT JOIN users i ON i.id = u.invited_by`;
/**
* Creates a user with the `user` role, invited by `inviterId`.
*
* @throws {APIException} 400 when the username is taken, 409 when the email
* is.
*/
export async function createUser(
request: RegisterUserRequest,
inviterId: string | null,
@@ -86,6 +99,11 @@ export async function createUser(
};
}
/**
* Returns user `userId`.
*
* @throws {APIException} 404 when there is no such user.
*/
export function getUserById(userId: string): User {
const userRow = db.prepare(
`${USER_SELECT} WHERE u.id = ?`,
@@ -99,9 +117,9 @@ export function getUserById(userId: string): User {
}
/**
* What a session token is checked against: the user's live role, and the
* moment older tokens stopped counting (their last password change). Undefined
* when the user is gone.
* Returns what a session token is checked against: the user's live role, and
* the moment older tokens stopped counting (their last password change).
* Undefined when the user is gone.
*/
export function getSessionState(
userId: string,
@@ -117,6 +135,11 @@ export function getSessionState(
};
}
/**
* Returns the user named `username`.
*
* @throws {APIException} 404 when there is no such user.
*/
export function getUserByUsername(username: string): User {
const userRow = db.prepare(
`${USER_SELECT} WHERE u.username = ?`,
@@ -130,8 +153,9 @@ export function getUserByUsername(username: string): User {
}
/**
* Password resets are looked up by email, so an address can belong to one
* account only — otherwise which account a reset link resets is up to row order.
* Throws a 409 when another account than `exceptUserId` has `email`. Password
* resets are looked up by email, so an address can belong to one account only
* — otherwise which account a reset link resets is up to row order.
*/
function assertEmailAvailable(email: string, exceptUserId?: string): void {
const row = db.prepare(
@@ -146,6 +170,7 @@ function assertEmailAvailable(email: string, exceptUserId?: string): void {
}
}
/** Returns the user whose email is `email`, or null. */
export function getUserByEmail(email: string): User | null {
const userRow = db.prepare(
`${USER_SELECT} WHERE u.email = ?`,
@@ -155,6 +180,10 @@ export function getUserByEmail(email: string): User | null {
return userRowToApi(userRow);
}
/**
* Returns up to `limit` users whose name starts with `query`, for @mention
* suggestions.
*/
export function searchUsers(
query: string,
limit: number,
@@ -174,7 +203,10 @@ export function searchUsers(
}));
}
// For the public search page: profiles only, never hashes or emails.
/**
* Returns up to `limit` users whose name or profile contains `query`, for the
* public search page: profiles only, never hashes or emails.
*/
export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
if (!query.trim()) return [];
const pattern = `%${query}%`;
@@ -184,6 +216,7 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser);
}
/** Returns every user. */
export function listUsers(): User[] {
const userRows = db.prepare(
`${USER_SELECT}`,
@@ -196,6 +229,13 @@ export function listUsers(): User[] {
return userRows.map(userRowToApi);
}
/**
* Applies `request` to user `userId`. A new password also ends every session
* opened before it and closes the user's sockets.
*
* @throws {APIException} 404 when there is no such user, 409 when the new
* email belongs to another account.
*/
export async function updateUser(
userId: string,
request: UpdateUserRequest,
@@ -251,6 +291,11 @@ export async function updateUser(
return updatedUser;
}
/**
* Records that user `userId` now has an avatar of type `mime`.
*
* @throws {APIException} 404 when there is no such user.
*/
export function updateUserAvatar(userId: string, mime: string): void {
const result = db.prepare(
`UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`,
@@ -261,6 +306,10 @@ export function updateUserAvatar(userId: string, mime: string): void {
}
}
/**
* Returns everyone `userId` invited, and everyone they invited in turn, down
* to ten levels, in registration order.
*/
export function getInviteTree(userId: string): Invitee[] {
const rows = db.prepare(`
WITH RECURSIVE tree AS (
@@ -291,6 +340,11 @@ export function getInviteTree(userId: string): Invitee[] {
}));
}
/**
* Deletes user `userId` and disconnects their sockets.
*
* @throws {APIException} 404 when there is no such user.
*/
export function deleteUser(userId: string): void {
disconnectUser(userId);

View File

@@ -1,3 +1,9 @@
/**
* Upvotes on dumps.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
@@ -5,14 +11,18 @@ import { db } from "../db/connection.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
/** A dump's votes after a vote was cast or removed. */
export interface VoteResult {
/** The dump's vote count now. */
voteCount: number;
/** Private dumps' vote counts go to their owner only. */
/** Whether the dump is private: its vote count goes to its owner only. */
isPrivate: boolean;
}
// Visibility of the dump, or a 404 when the user can't see it — voting must not
// confirm that a private dump exists.
/**
* Returns whether the dump is private, or throws a 404 when the user can't see
* it — voting must not confirm that a private dump exists.
*/
function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } {
const row = db.prepare(
`SELECT is_private, user_id FROM dumps WHERE id = ?;`,
@@ -23,6 +33,12 @@ function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } {
return { isPrivate: Boolean(row.is_private) };
}
/**
* Records `userId`'s upvote on a dump and notifies its poster.
*
* @throws {APIException} 404 when the dump doesn't exist or is someone else's
* private dump, 409 when the user already voted for it.
*/
export function castVote(dumpId: string, userId: string): VoteResult {
const { isPrivate } = visibleDump(dumpId, userId);
let voteCount: number;
@@ -59,8 +75,13 @@ export function castVote(dumpId: string, userId: string): VoteResult {
return { voteCount, isPrivate };
}
// No visibility check: taking back a vote on a dump that has since gone
// private is harmless, and it's the voter's own row.
/**
* Takes back `userId`'s upvote on a dump. No visibility check: taking back a
* vote on a dump that has since gone private is harmless, and it's the voter's
* own row.
*
* @throws {APIException} 404 when the user hasn't voted for the dump.
*/
export function removeVote(dumpId: string, userId: string): VoteResult {
const row = db.prepare(`SELECT is_private FROM dumps WHERE id = ?;`).get(
dumpId,
@@ -90,6 +111,7 @@ export function removeVote(dumpId: string, userId: string): VoteResult {
}
}
/** Returns the ids of every dump `userId` upvoted. */
export function getUserVotes(userId: string): string[] {
const rows = db.prepare(
`SELECT dump_id FROM votes WHERE user_id = ?;`,
@@ -97,6 +119,7 @@ export function getUserVotes(userId: string): string[] {
return rows.map((r) => r.dump_id);
}
/** Returns a page of the users who upvoted a dump, latest first. */
export function getDumpVoters(
dumpId: string,
page: number,

View File

@@ -1,3 +1,11 @@
/**
* The connected WebSocket clients, and everything pushed to them: presence,
* live updates to dumps, comments, playlists and chat, typing indicators, and
* the keepalive that drops dead sockets.
*
* @module
*/
import type { ChatMessage } from "../../shared/chat.ts";
import type { Comment } from "../../shared/comment.ts";
import type { Dump } from "../../shared/dump.ts";
@@ -9,31 +17,45 @@ import {
type PlaylistMember,
} from "../lib/playlist-members.ts";
/** A connected socket, and what the server knows of its user. */
export interface WsClient {
/** The socket. */
socket: WebSocket;
/** The user's id; undefined for an anonymous socket. */
userId?: string;
/** The user's name. */
username?: string;
/** The user's role. */
role?: Role;
/** The user's avatar type, undefined without one. */
avatarMime?: string;
/** When the avatar last changed, to bust the client's cache. */
avatarVersion?: number;
/** Whether the client answered the last ping; undefined before the first. */
pongReceived?: boolean;
/** Whether this client currently has the chatbox open (is reading chat). */
chatOpen?: boolean;
/** Timestamp (ms) of the client's last "typing" signal; undefined when not
* composing. Entries older than TYPING_TTL are treated as stale. */
/**
* Timestamp (ms) of the client's last "typing" signal; undefined when not
* composing. Entries older than {@link TYPING_TTL} are treated as stale.
*/
typingAt?: number;
}
/** Every connected client. */
const clients = new Set<WsClient>();
/** Adds a connected client. */
export function register(client: WsClient): void {
clients.add(client);
}
/** Removes a disconnected client. */
export function unregister(client: WsClient): void {
clients.delete(client);
}
/** Records a user's new avatar on their clients and rebroadcasts presence. */
export function updateClientAvatar(userId: string, avatarMime: string): void {
const version = Date.now();
for (const client of clients) {
@@ -45,6 +67,7 @@ export function updateClientAvatar(userId: string, avatarMime: string): void {
broadcastPresence();
}
/** Returns each signed-in user with at least one socket, once. */
export function getOnlineUsers(): OnlineUser[] {
const seen = new Map<string, OnlineUser>();
for (const client of clients) {
@@ -60,16 +83,20 @@ export function getOnlineUsers(): OnlineUser[] {
return Array.from(seen.values());
}
/** Sends an already serialized message, if the socket is open. */
function sendRaw(socket: WebSocket, payload: string): void {
if (socket.readyState === WebSocket.OPEN) socket.send(payload);
}
/** Sends one message to one socket. */
function send(socket: WebSocket, data: ServerToClientMessage): void {
sendRaw(socket, JSON.stringify(data));
}
/** Sends one message to every client `include` accepts (all by default),
* serialized once rather than once per socket. */
/**
* Sends one message to every client `include` accepts (all by default),
* serialized once rather than once per socket.
*/
function broadcast(
data: ServerToClientMessage,
include?: (client: WsClient) => boolean,
@@ -82,10 +109,12 @@ function broadcast(
}
}
/** Sends a message to every socket of `userId`. */
export function sendToUser(userId: string, data: ServerToClientMessage): void {
broadcast(data, (client) => client.userId === userId);
}
/** Logs a deleted user out of every socket and closes them. */
export function disconnectUser(userId: string): void {
for (const client of clients) {
if (client.userId === userId) {
@@ -107,23 +136,30 @@ export function closeUserSockets(userId: string, reason: string): void {
}
}
/** Sends everyone the list of online users. */
export function broadcastPresence(): void {
broadcast({ type: "presence_update", users: getOnlineUsers() });
}
/** Tells everyone a dump was posted. */
export function broadcastNewDump(dump: Dump): void {
broadcast({ type: "dump_created", dump });
}
/** Tells everyone a dump changed. */
export function broadcastDumpUpdated(dump: Dump): void {
broadcast({ type: "dump_updated", dump });
}
/** Tells everyone a dump was deleted. */
export function broadcastDumpDeleted(dumpId: string): void {
broadcast({ type: "dump_deleted", dumpId });
}
// `onlyUserId`: a private dump's count goes to that user's sockets alone.
/**
* Tells everyone a dump's vote count changed. With `onlyUserId`, a private
* dump's count goes to that user's sockets alone.
*/
export function broadcastVoteUpdate(
dumpId: string,
voteCount: number,
@@ -137,7 +173,10 @@ export function broadcastVoteUpdate(
);
}
// `onlyUserId`: a like on a private dump's comment goes to that user alone.
/**
* Tells everyone a comment's like count changed. With `onlyUserId`, a like on
* a private dump's comment goes to that user alone.
*/
export function broadcastCommentLikeUpdate(
commentId: string,
likeCount: number,
@@ -151,6 +190,7 @@ export function broadcastCommentLikeUpdate(
);
}
/** Sends a message to everyone for a public playlist, to its owner otherwise. */
function sendToPlaylistAudience(
playlist: Pick<Playlist, "isPublic" | "userId">,
data: ServerToClientMessage,
@@ -161,13 +201,17 @@ function sendToPlaylistAudience(
);
}
/** Tells a new playlist's audience it was created. */
export function broadcastPlaylistCreated(playlist: Playlist): void {
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
}
// `wasPublic`: the visibility before this change. A playlist that just went
// private still goes to everyone, so non-owners can drop it from their views;
// one that was and stays private goes to its owner alone.
/**
* Tells a playlist's audience it changed. `wasPublic` is the visibility before
* this change: a playlist that just went private still goes to everyone, so
* non-owners can drop it from their views; one that was and stays private goes
* to its owner alone.
*/
export function broadcastPlaylistUpdated(
playlist: Playlist,
wasPublic: boolean,
@@ -178,6 +222,7 @@ export function broadcastPlaylistUpdated(
);
}
/** Tells a playlist's audience it was deleted. */
export function broadcastPlaylistDeleted(
playlistId: string,
userId: string,
@@ -190,8 +235,11 @@ export function broadcastPlaylistDeleted(
});
}
// Each viewer gets the members they can see, as GET would list them: the ids
// of private dumps don't go out to the whole audience of a public playlist.
/**
* Tells a playlist's audience its dumps changed. Each viewer gets the members
* they can see, as GET would list them: the ids of private dumps don't go out
* to the whole audience of a public playlist.
*/
export function broadcastPlaylistDumpsUpdated(
playlist: Playlist,
members: PlaylistMember[],
@@ -209,14 +257,17 @@ export function broadcastPlaylistDumpsUpdated(
}
}
/** Tells everyone a user's profile changed. */
export function broadcastUserUpdated(user: PublicUser): void {
broadcast({ type: "user_updated", user });
}
/** Tells everyone a comment was posted. */
export function broadcastCommentCreated(comment: Comment): void {
broadcast({ type: "comment_created", comment });
}
/** Tells everyone a comment was deleted. */
export function broadcastCommentDeleted(
commentId: string,
dumpId: string,
@@ -224,17 +275,23 @@ export function broadcastCommentDeleted(
broadcast({ type: "comment_deleted", commentId, dumpId });
}
/** Tells everyone a comment was edited. */
export function broadcastCommentUpdated(comment: Comment): void {
broadcast({ type: "comment_updated", comment });
}
// Global chat room for members: history is behind auth, so the live stream is
// too — an anonymous socket gets nothing chat-related.
/**
* Sends a chat message to signed-in sockets only: history is behind auth, so
* the live stream is too — an anonymous socket gets nothing chat-related.
*/
function sendToMembers(data: ServerToClientMessage): void {
broadcast(data, (client) => !!client.userId);
}
// `nonce` is the sender's id for its pending copy; harmless to everyone else.
/**
* Sends a new chat message to the members. `nonce` is the sender's id for its
* pending copy; harmless to everyone else.
*/
export function broadcastChatMessage(
message: ChatMessage,
nonce?: string,
@@ -242,21 +299,28 @@ export function broadcastChatMessage(
sendToMembers({ type: "chat_message", message, nonce });
}
/** Tells the members a chat message was edited. */
export function broadcastChatMessageUpdated(message: ChatMessage): void {
sendToMembers({ type: "chat_message_updated", message });
}
/** Tells the members a chat message was deleted. */
export function broadcastChatMessageDeleted(id: string): void {
sendToMembers({ type: "chat_message_deleted", id });
}
// Typing indicator: a client's "typing" signal is considered live for this long
// after its last refresh. Must exceed the client's typing-ping cadence so a
// steady typist never flickers out between pings.
/**
* How long a client's "typing" signal is considered live after its last
* refresh. Must exceed the client's typing-ping cadence so a steady typist
* never flickers out between pings.
*/
const TYPING_TTL = 6_000;
// Deduped set of users currently composing a chat message (typing signal within
// the TTL), shaped like OnlineUser so the client renders the same avatars.
/**
* Returns each user currently composing a chat message (typing signal within
* {@link TYPING_TTL}) once, shaped like the online users so the client renders
* the same avatars.
*/
export function getTypingUsers(): OnlineUser[] {
const now = Date.now();
const seen = new Map<string, OnlineUser>();
@@ -276,13 +340,17 @@ export function getTypingUsers(): OnlineUser[] {
return Array.from(seen.values());
}
/** Sends the members the list of users typing. */
export function broadcastTyping(): void {
sendToMembers({ type: "chat_typing_update", users: getTypingUsers() });
}
// Record (or clear) a client's typing state and tell everyone. The client also
// resends "true" periodically while composing; the sweep below expires it if
// they go quiet without a clean "false" (e.g. they closed the tab).
/**
* Records (or clears) a client's typing state and tells everyone when it
* changed. The client also resends "true" periodically while composing; a
* sweep expires it if they go quiet without a clean "false" (e.g. they closed
* the tab).
*/
export function setClientTyping(client: WsClient, typing: boolean): void {
const wasTyping = !!client.typingAt;
client.typingAt = typing ? Date.now() : undefined;
@@ -305,8 +373,11 @@ setInterval(() => {
if (changed) broadcastTyping();
}, 2_000);
// True if the user has the chatbox open on any of their connected clients — i.e.
// they're already reading chat, so a mention notification would be redundant.
/**
* Checks whether the user has the chatbox open on any of their connected
* clients — i.e. they're already reading chat, so a mention notification would
* be redundant.
*/
export function isUserChatOpen(userId: string): boolean {
for (const client of clients) {
if (client.userId === userId && client.chatOpen) return true;
@@ -316,12 +387,17 @@ export function isUserChatOpen(userId: string): boolean {
export { type RateLimit, takeToken } from "../lib/rate-limit.ts";
/** Records that a client answered the keepalive ping. */
export function handleClientPong(client: WsClient): void {
client.pongReceived = true;
}
// Keepalive: ping all clients every 30s, disconnect non-responsive ones
/**
* How often every client is pinged. One that hasn't answered the previous ping
* is disconnected.
*/
const PING_INTERVAL = 30_000;
/** The serialized ping. */
const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage);
setInterval(() => {