v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Records the images pasted into Markdown bodies, and binds each one to the
|
||||
* resource whose body links it. `routes/attachments.ts` stores and serves the
|
||||
* files; the services that save a body call {@link linkAttachments}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,7 +10,11 @@ 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. */
|
||||
/**
|
||||
* Records attachment `id` once `POST /api/attachments` has written its file.
|
||||
* Until a saved body links it, the startup purge deletes it after
|
||||
* `ORPHANED_ATTACHMENTS_RETENTION_HOURS`.
|
||||
*/
|
||||
export function createAttachment(id: string, mime: string): void {
|
||||
db.prepare(
|
||||
"INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)",
|
||||
@@ -18,7 +22,8 @@ export function createAttachment(id: string, mime: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns attachment `attachmentId`'s MIME type.
|
||||
* Returns attachment `attachmentId`'s MIME type, for
|
||||
* `GET /api/attachments/:attachmentId` to serve its file with.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such attachment.
|
||||
*/
|
||||
@@ -38,14 +43,15 @@ export function getAttachment(attachmentId: string): { mime: string } {
|
||||
return { mime: row.mime };
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Binds the attachments whose URLs appear in `text` to `resourceId`, so the
|
||||
* startup purge keeps them. It is called whenever a dump, comment, chat
|
||||
* message, playlist or profile description is saved. An attachment already
|
||||
* bound stays with its resource, so pasting another's URL claims nothing, and
|
||||
* removing the link from a body never unbinds it.
|
||||
*/
|
||||
export function linkAttachments(text: string, resourceId: string): void {
|
||||
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Backlinks between dumps: which dumps a dump's description and comments link
|
||||
* to, and the dumps related to one through them.
|
||||
* Records which dumps a dump's note and comments link to, and finds the dumps
|
||||
* related to one through those links. The dump and comment services keep the
|
||||
* links up to date, and the dump page lists the related dumps.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -15,21 +16,19 @@ import {
|
||||
import { UUID_RE } from "../lib/slugify.ts";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Matches an http(s) URL in Markdown. It stops at the delimiters of
|
||||
* `[text](url)` and `<url>`, which would otherwise end up in the URL.
|
||||
*/
|
||||
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).
|
||||
* Matches the path of a dump's page, `/dumps/{slug-or-id}`, and ignores what
|
||||
* follows it, so an `/edit` or `#comment-x` link counts too. The host isn't
|
||||
* checked, since the site's public URL differs between setups.
|
||||
*/
|
||||
const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/;
|
||||
|
||||
/** Returns `url` as the URL parser normalises it, or null when it isn't one. */
|
||||
/** 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();
|
||||
@@ -38,7 +37,7 @@ function canonicalize(url: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves a same-site `/dumps/{slug-or-id}` path segment to a dump id. */
|
||||
/** 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)
|
||||
? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug)
|
||||
@@ -47,11 +46,11 @@ function resolveDumpPathId(idOrSlug: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
* which are purely additive.
|
||||
* 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
|
||||
* an empty `text` when a comment is deleted.
|
||||
*/
|
||||
export function relinkBacklinks(
|
||||
sourceType: "dump_comment" | "comment",
|
||||
@@ -88,7 +87,7 @@ export function relinkBacklinks(
|
||||
for (const row of rows) targetIds.add(row.id);
|
||||
}
|
||||
|
||||
targetIds.delete(fromDumpId); // no self-links
|
||||
targetIds.delete(fromDumpId);
|
||||
|
||||
transaction(() => {
|
||||
db.prepare(
|
||||
@@ -107,7 +106,8 @@ export function relinkBacklinks(
|
||||
|
||||
/**
|
||||
* Returns the dumps linked to or from `dumpId`, newest first and at most 20,
|
||||
* without the private ones the requesting user can't see.
|
||||
* without the private ones the requesting user can't see. It doesn't check
|
||||
* `dumpId` itself: `GET /api/dumps/:dumpId/related` does that first.
|
||||
*/
|
||||
export function getRelatedDumps(
|
||||
dumpId: string,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Categories: managing them, and which dumps belong to which.
|
||||
* Stores the categories and which dumps belong to them. The category routes
|
||||
* manage and list them, the dump service files each dump it writes under its
|
||||
* categories, and every service that lists dumps fills in their `categoryIds`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -23,7 +25,10 @@ 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. */
|
||||
/**
|
||||
* Throws a 400 unless `slug` is well-formed, short enough and not reserved,
|
||||
* with a message for each case.
|
||||
*/
|
||||
function validateSlug(slug: string): void {
|
||||
if (!isValidSlugFormat(slug)) {
|
||||
throw new APIException(
|
||||
@@ -48,7 +53,6 @@ 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) {
|
||||
@@ -65,8 +69,9 @@ function requireName(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending
|
||||
* field, and anything else as is.
|
||||
* Rethrows a UNIQUE violation from SQLite as a 409 naming the slug or name
|
||||
* that is taken, and any other error as is. SQLite's message names the
|
||||
* column, so this matches on it.
|
||||
*/
|
||||
function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -88,7 +93,7 @@ function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns category `id`.
|
||||
* Returns category `id`, for {@link updateCategory}.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
@@ -101,7 +106,7 @@ export function getCategoryById(id: string): Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the category at `slug`.
|
||||
* Returns the category at `slug`, for `GET /api/categories/:slug`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
@@ -113,7 +118,10 @@ export function getCategoryBySlug(slug: string): Category {
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/** Returns the id of the category at `slug`, or undefined when there is none. */
|
||||
/**
|
||||
* Returns the id of the category at `slug`, or undefined when there is none.
|
||||
* The follows routes use it for their `?category=` feed filter.
|
||||
*/
|
||||
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"
|
||||
@@ -121,7 +129,9 @@ export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns every category, by position then slug. */
|
||||
/**
|
||||
* Returns every category, by position then slug, for `GET /api/categories`.
|
||||
*/
|
||||
export function listCategories(): Category[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
|
||||
@@ -130,7 +140,8 @@ export function listCategories(): Category[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a category.
|
||||
* Creates a category, for `POST /api/categories`. The name is stored trimmed,
|
||||
* and the position defaults to 0.
|
||||
*
|
||||
* @throws {APIException} 400 when the slug or name is invalid, 409 when
|
||||
* either is taken.
|
||||
@@ -166,7 +177,8 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `req` to category `id`.
|
||||
* Applies `req` to category `id`, for `PUT /api/categories/:id`, and returns
|
||||
* the category as it now is. Fields `req` leaves out keep their value.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category, 400 when the new
|
||||
* slug or name is invalid, 409 when either is taken.
|
||||
@@ -211,7 +223,8 @@ export function updateCategory(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes category `id`; its dumps stay, outside it.
|
||||
* Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and
|
||||
* lose it from their `categoryIds`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
@@ -223,9 +236,9 @@ export function deleteCategory(id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks that every id in `categoryIds` names a category. The dump service
|
||||
* runs it before it writes a dump row or stores an uploaded file, so a bad id
|
||||
* fails the request before anything is written.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
@@ -248,8 +261,9 @@ export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the set of categories a dump belongs to. An empty list clears all
|
||||
* category assignments.
|
||||
* Replaces the categories of dump `dumpId` with `categoryIds`, duplicates
|
||||
* dropped. An empty list takes the dump out of every category. Called inside
|
||||
* the dump service's transaction, it joins that one.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
@@ -271,7 +285,10 @@ export function setDumpCategories(
|
||||
});
|
||||
}
|
||||
|
||||
/** Fills `categoryIds` on each dump with a single batched lookup. */
|
||||
/**
|
||||
* Sets `categoryIds` on each of `dumps` in place, in one query. The dump row
|
||||
* doesn't carry them, so every service that returns dumps calls this first.
|
||||
*/
|
||||
export function attachCategoryIds(dumps: Dump[]): void {
|
||||
if (dumps.length === 0) return;
|
||||
const ids = dumps.map((d) => d.id);
|
||||
@@ -292,8 +309,9 @@ 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.
|
||||
* Returns a page of the dumps in the category at `slug`, newest first, for
|
||||
* `GET /api/categories/:slug/dumps`. Private dumps are left out, except
|
||||
* `requestingUserId`'s own.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* The site-wide chat: messages, their history, and each user's read marker.
|
||||
* Stores the site-wide chat's messages and each user's read marker.
|
||||
*
|
||||
* The WebSocket route (`routes/ws.ts`) writes messages and moves read markers,
|
||||
* then broadcasts the result. `routes/chat.ts` reads the history, a page at a
|
||||
* time.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,36 +16,32 @@ 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* How much of the replied-to message a reply carries. The client shows one line
|
||||
* of it, once stripped of its Markdown, so the cut leaves room for a long link
|
||||
* or image URL before the text.
|
||||
*/
|
||||
const REPLY_SNIPPET_LEN = 300;
|
||||
|
||||
/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */
|
||||
/** The columns of a `ChatMessageRow`, to select 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`;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Joins a message to its author, and to the message it replies to and that
|
||||
* message's author. Those two are left joins: a reply outlives its target.
|
||||
*/
|
||||
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. */
|
||||
/** Returns message `id` as the broadcast sends it, or throws a 404. */
|
||||
function fetchMessage(id: string): ChatMessage {
|
||||
const row = db.prepare(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
|
||||
@@ -53,8 +53,9 @@ function fetchMessage(id: string): ChatMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a message from `userId`, replying to `replyToId` when that message
|
||||
* exists, and binds its attachments.
|
||||
* Stores a message from `userId` on a `chat_send`, and claims the pasted images
|
||||
* it links to. A `replyToId` naming no message is dropped, and the message is
|
||||
* posted as no reply. The caller broadcasts the result.
|
||||
*
|
||||
* @throws {APIException} 400 when the body is blank or longer than
|
||||
* `VALIDATION.CHAT_MESSAGE_MAX`.
|
||||
@@ -79,8 +80,6 @@ export function createChatMessage(
|
||||
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
|
||||
);
|
||||
}
|
||||
// Only store the reply target if it actually exists — drop dangling ids so a
|
||||
// message never references a phantom parent.
|
||||
let replyTo: string | null = null;
|
||||
if (replyToId) {
|
||||
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
|
||||
@@ -93,13 +92,12 @@ export function createChatMessage(
|
||||
db.prepare(
|
||||
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
|
||||
).run(id, userId, trimmed, createdAt, replyTo);
|
||||
// Claim any pasted/dropped image attachments so the startup cleanup doesn't
|
||||
// purge them as orphans (mirrors dumps/comments/playlists).
|
||||
// Unclaimed attachments are deleted as orphans at startup.
|
||||
linkAttachments(trimmed, id);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
/** Returns the author of message `id`, or throws a 404. */
|
||||
/** Returns the id of message `id`'s author, or throws a 404. */
|
||||
function getMessageOwner(id: string): string {
|
||||
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
|
||||
id,
|
||||
@@ -111,8 +109,9 @@ function getMessageOwner(id: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a message's body, as its author or a moderator (`canModerate`), and
|
||||
* binds any new attachment.
|
||||
* Replaces a message's body on a `chat_edit`, and claims any image the new body
|
||||
* links to. `canModerate` lets a moderator edit anyone's message. The caller
|
||||
* broadcasts the result.
|
||||
*
|
||||
* @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
|
||||
@@ -149,13 +148,13 @@ export function updateChatMessage(
|
||||
}
|
||||
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
|
||||
.run(trimmed, new Date().toISOString(), id);
|
||||
// An edit may introduce a freshly pasted image; claim its attachment too.
|
||||
linkAttachments(trimmed, id);
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a message, as its author or a moderator (`canModerate`).
|
||||
* Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete
|
||||
* anyone's message. Replies to it keep their `replyToId`, without its preview.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such message, 401 when the user
|
||||
* is neither its author nor a moderator.
|
||||
@@ -165,7 +164,7 @@ export function deleteChatMessage(
|
||||
requestingUserId: string,
|
||||
canModerate: boolean,
|
||||
): void {
|
||||
const ownerId = getMessageOwner(id); // 404s if the message doesn't exist
|
||||
const ownerId = getMessageOwner(id);
|
||||
if (ownerId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
@@ -177,21 +176,23 @@ export function deleteChatMessage(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Passed by `routes/chat.ts` to page through the history, from the message the
|
||||
* client already has at one end. Messages are ordered by `createdAt`, then by
|
||||
* id, so a page boundary between two messages sent in the same millisecond
|
||||
* skips neither.
|
||||
*/
|
||||
export interface ChatCursor {
|
||||
/** The `created_at` of the message the cursor sits at. */
|
||||
/** An ISO timestamp, compared as a string with `created_at`. */
|
||||
createdAt: string;
|
||||
/**
|
||||
* The id of that message. Omitted: the cursor sits before (for `after`) or
|
||||
* after (for `before`) every message sharing that timestamp.
|
||||
* Without it, the cursor takes in every message at `createdAt`: they are
|
||||
* all older for {@link getRecentChatMessages} and all newer for
|
||||
* {@link getChatMessagesAfter}.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Converts query rows to messages. */
|
||||
/** Converts query rows to messages, or throws a 500 if one doesn't parse. */
|
||||
function toMessages(rows: unknown[]): ChatMessage[] {
|
||||
if (!rows.every(isChatMessageRow)) {
|
||||
throw new APIException(
|
||||
@@ -203,15 +204,15 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` most recent messages in ascending (display) order.
|
||||
* When `before` is given, returns the page of older messages immediately
|
||||
* preceding it — used for "load older" history.
|
||||
* Returns the latest `limit` messages, or with `before` the `limit` messages
|
||||
* just older than it, oldest first either way. `limit` is clamped to 1–100.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a message.
|
||||
*/
|
||||
export function getRecentChatMessages(
|
||||
limit = DEFAULT_LIMIT,
|
||||
@@ -226,7 +227,7 @@ export function getRecentChatMessages(
|
||||
).all(
|
||||
before.createdAt,
|
||||
before.createdAt,
|
||||
// No id: nothing at that timestamp sorts below the empty string.
|
||||
// Nothing sorts below "", so no message at that timestamp is taken.
|
||||
before.id ?? "",
|
||||
capped,
|
||||
)
|
||||
@@ -234,14 +235,15 @@ export function getRecentChatMessages(
|
||||
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
||||
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
|
||||
).all(capped);
|
||||
// Newest-first from SQL; reverse to ascending for display.
|
||||
return toMessages(rows).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` messages following `after`, in ascending order — used
|
||||
* to resync the chat after a reconnect. Without an id the cursor is inclusive
|
||||
* of its timestamp.
|
||||
* Returns the first `limit` messages after `after`, oldest first. The chatbox
|
||||
* uses it to fetch what it missed while the socket was down. `limit` is
|
||||
* clamped to 1–100.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a message.
|
||||
*/
|
||||
export function getChatMessagesAfter(
|
||||
after: ChatCursor,
|
||||
@@ -255,17 +257,21 @@ export function getChatMessagesAfter(
|
||||
return toMessages(rows);
|
||||
}
|
||||
|
||||
/** How far a user has read the chat. */
|
||||
/**
|
||||
* Returned to the WebSocket route by {@link getChatReadState} and
|
||||
* {@link markChatRead}. The route sends it in `welcome` and in
|
||||
* `chat_read_update`, and the client shows `unread` on the chat's badge.
|
||||
*/
|
||||
export interface ChatReadState {
|
||||
/** The `created_at` of the newest message the user has seen (ISO). */
|
||||
/** The `created_at` of the newest message the user has read. */
|
||||
readAt: string;
|
||||
/** Messages from other people since then. */
|
||||
/** Counts only the messages of other users after `readAt`. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how far `userId` has read the chat; a user who never opened it has
|
||||
* read up to their registration.
|
||||
* Returns how far `userId` has read the chat. A user who never opened it has
|
||||
* read up to their registration, so the history from before it isn't unread.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
@@ -284,9 +290,10 @@ 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.
|
||||
* Moves `userId`'s read marker to `at`, a message's `created_at`, on a
|
||||
* `chat_read`. It never moves back, so a lagging tab can't undo a newer tab's
|
||||
* read. It never passes the newest message, so a timestamp from the future
|
||||
* can't mark messages read before they are sent.
|
||||
*
|
||||
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
|
||||
* such user.
|
||||
@@ -303,8 +310,7 @@ export function markChatRead(userId: string, at: string): ChatReadState {
|
||||
const newest = db.prepare(
|
||||
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
|
||||
).get() as { newest: string | null };
|
||||
// No messages yet: there's nothing to have read, and no newest message to
|
||||
// cap a bogus far-future marker at — which could never be moved back.
|
||||
// With no message to cap it at, a marker from the future would stay stuck.
|
||||
if (!newest.newest) return getChatReadState(userId);
|
||||
const iso = parsed.toISOString();
|
||||
const target = iso > newest.newest ? newest.newest : iso;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* Likes on comments.
|
||||
* Stores likes on comments and keeps each comment's `like_count` in step with
|
||||
* them. Likes have no HTTP endpoint to cast or remove. The WebSocket route
|
||||
* (`routes/ws.ts`) calls this module on `comment_like_cast` and
|
||||
* `comment_like_remove` messages, then broadcasts the new count. The comment
|
||||
* routes use it only to list a comment's likers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,16 +15,19 @@ 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. */
|
||||
/**
|
||||
* Returned to the WebSocket route by {@link castCommentLike} and
|
||||
* {@link removeCommentLike}. The route acks the liker with `likeCount`, then
|
||||
* broadcasts it to every client, or to the liker's alone when the comment's
|
||||
* dump `isPrivate`.
|
||||
*/
|
||||
export interface LikeResult {
|
||||
/** The comment's like count now. */
|
||||
likeCount: number;
|
||||
/** Whether the comment's dump is private: its likes go to the dump's owner only. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the comment's dump is private, or throws a 404 when the user
|
||||
* Returns whether the comment's dump is private, or throws a 404 when `userId`
|
||||
* can't see it.
|
||||
*/
|
||||
function commentVisibility(commentId: string, userId: string): boolean {
|
||||
@@ -35,7 +42,8 @@ function commentVisibility(commentId: string, userId: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Records `userId`'s like on a comment and notifies its author.
|
||||
* Records `userId`'s like on a comment and notifies the comment's author. The
|
||||
* notification is best-effort: if it fails, the like still counts.
|
||||
*
|
||||
* @throws {APIException} 404 when the comment doesn't exist or sits on someone
|
||||
* else's private dump, 409 when the user already likes it.
|
||||
@@ -72,7 +80,7 @@ export function castCommentLike(
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Notification is best-effort — must not prevent like_ack from being sent
|
||||
// The like is committed: a failed notification must not report it as failed.
|
||||
try {
|
||||
notifyCommentOwnerLike(userId, commentId);
|
||||
} catch { /* ignore */ }
|
||||
@@ -80,8 +88,9 @@ export function castCommentLike(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Takes back `userId`'s like on a comment. It doesn't check that the user can
|
||||
* still see the comment, since a like on a dump that has gone private is still
|
||||
* theirs to take back.
|
||||
*
|
||||
* @throws {APIException} 404 when the user doesn't like the comment.
|
||||
*/
|
||||
@@ -118,7 +127,11 @@ export function removeCommentLike(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the ids of every comment `userId` likes. */
|
||||
/**
|
||||
* Returns the ids of the comments `userId` likes. The WebSocket route sends
|
||||
* them in its `welcome` message, and the client shows those like buttons
|
||||
* pressed.
|
||||
*/
|
||||
export function getUserCommentLikes(userId: string): string[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT comment_id FROM comment_likes WHERE user_id = ?;`,
|
||||
@@ -126,7 +139,13 @@ 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. */
|
||||
/**
|
||||
* Returns a page of the users who like a comment, latest like first, for
|
||||
* `GET /api/comments/:commentId/likers`. It doesn't check that the caller may
|
||||
* see the comment: the route does that first, with `getDump`.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a user.
|
||||
*/
|
||||
export function getCommentLikers(
|
||||
commentId: string,
|
||||
page: number,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Comments on dumps, threaded by `parent_id`.
|
||||
* Stores the comments on dumps, threaded by `parent_id`. The comment routes
|
||||
* call it and broadcast what it returns. It leaves checking that the caller
|
||||
* may see the dump to them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,12 +19,11 @@ 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`. */
|
||||
/** The select list of a `CommentRow`, from `comments c` joined to `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 = ?;`,
|
||||
@@ -41,7 +42,8 @@ function fetchComment(commentId: string): Comment {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the dump comment `commentId` is posted under.
|
||||
* Returns the id of the dump comment `commentId` is posted under. The likers
|
||||
* route uses it to check that the caller may see that dump.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such comment.
|
||||
*/
|
||||
@@ -56,8 +58,11 @@ export function getCommentDumpId(commentId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every comment on dump `dumpId`, deleted ones included, oldest
|
||||
* first.
|
||||
* Returns every comment on dump `dumpId`, oldest first, for
|
||||
* `GET /api/dumps/:dumpId/comments`. Deleted comments are included, emptied,
|
||||
* so the client can keep their replies in place.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a comment.
|
||||
*/
|
||||
export function getComments(dumpId: string): Comment[] {
|
||||
const rows = db.prepare(
|
||||
@@ -75,9 +80,10 @@ export function getComments(dumpId: string): Comment[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Posts a comment on dump `dumpId`, replying to `parentId` if given, for
|
||||
* `POST /api/dumps/:dumpId/comments`. It notifies the dump's poster and the
|
||||
* users it mentions, and links the comment's attachments and backlinks.
|
||||
* `parentId` isn't checked to be a comment on the same dump.
|
||||
*/
|
||||
export function createComment(
|
||||
dumpId: string,
|
||||
@@ -109,8 +115,10 @@ export function createComment(
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a comment's body, as its author or a moderator (`canModerate`);
|
||||
* notifies anyone newly @mentioned, and rebinds its attachments and backlinks.
|
||||
* Replaces a comment's body, for `PATCH /api/comments/:commentId`.
|
||||
* `canModerate` lets someone other than the author do it. It notifies the users
|
||||
* it mentions for the first time and relinks the attachments and backlinks. The
|
||||
* route broadcasts the comment unless `isPrivate`.
|
||||
*
|
||||
* @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.
|
||||
@@ -169,9 +177,10 @@ 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.
|
||||
* Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets
|
||||
* someone other than the author do it. The body is emptied and the backlinks
|
||||
* dropped, but the row stays so its replies keep their place. The route
|
||||
* broadcasts the deletion unless `isPrivate`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such comment, 401 when the user
|
||||
* is neither its author nor a moderator.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* Posts, edits and deletes dumps, and reads them as each viewer may see them.
|
||||
* `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail
|
||||
* routes to read dumps. It broadcasts the changes to public dumps over the
|
||||
* WebSocket.
|
||||
*
|
||||
* The functions that change a dump don't check who asks: the routes do, before
|
||||
* calling them.
|
||||
* The functions that change a dump don't check who asks: the routes do first.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -59,16 +60,14 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Throws a 400 when a file dump's title or comment is too long. The multipart
|
||||
* routes that post and replace a file don't check them, unlike the JSON ones.
|
||||
*/
|
||||
function assertFileDumpText(title?: string, comment?: string): void {
|
||||
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
|
||||
@@ -87,7 +86,7 @@ function assertFileDumpText(title?: string, comment?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the title a link gets without a better one: its host. */
|
||||
/** Returns a link's host name, the title it gets when its page has none. */
|
||||
function titleFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
@@ -97,8 +96,10 @@ 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.
|
||||
* Posts a link as `userId`, for `POST /api/dumps`, with its preview fetched
|
||||
* from the page. A public dump is broadcast as `dump_created` and notified to
|
||||
* the poster's followers. Anyone @mentioned in the note is notified even when
|
||||
* the dump is private.
|
||||
*
|
||||
* @throws {APIException} 400 when the URL isn't a public http(s) URL or a
|
||||
* category doesn't exist.
|
||||
@@ -171,8 +172,9 @@ export async function createUrlDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public
|
||||
* one is announced and notified to the poster's followers.
|
||||
* Posts an uploaded file as `userId`, for `POST /api/dumps`, and stores it in
|
||||
* `DUMPS_DIR` under the dump's id. It is announced and notified as
|
||||
* {@link createUrlDump} does.
|
||||
*
|
||||
* @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.
|
||||
@@ -234,7 +236,7 @@ export async function createFileDump(
|
||||
setDumpCategories(dumpId, dedupedCategoryIds);
|
||||
});
|
||||
} catch (err) {
|
||||
// Roll back the file if DB insert fails
|
||||
// Leave no file behind when the write or the insert fails.
|
||||
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
@@ -268,9 +270,10 @@ export async function createFileDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns a file dump's version, which the app's file and thumbnail URLs carry
|
||||
* as `?v=`. It changes whenever the file may have, so the file and thumbnail
|
||||
* routes cache a URL naming the current one for good. The frontend's
|
||||
* `dumpFileVersion` must compute the same.
|
||||
*/
|
||||
export function dumpFileVersion(
|
||||
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
|
||||
@@ -281,9 +284,9 @@ export function dumpFileVersion(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
|
||||
* upload, once the upload is replaced or deleted. The route makes them again
|
||||
* from a new one.
|
||||
*/
|
||||
async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
await Promise.all(
|
||||
@@ -294,8 +297,8 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy
|
||||
* check: use only when ownership is already enforced.
|
||||
* Returns the dump with id or slug `idOrSlug`, without its categories, or
|
||||
* throws a 404. It doesn't check privacy: the routes do, with {@link getDump}.
|
||||
*/
|
||||
function fetchDump(idOrSlug: string): Dump {
|
||||
const row = UUID_RE.test(idOrSlug)
|
||||
@@ -310,9 +313,11 @@ function fetchDump(idOrSlug: string): Dump {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump, by id or slug, as `requestingUserId` may see it.
|
||||
* Returns a dump, by id or slug, as `requestingUserId` may see it. The routes
|
||||
* resolve every `:dumpId` with it before acting on the dump.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump, or it is private and not theirs.
|
||||
* @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);
|
||||
@@ -324,9 +329,9 @@ export function getDump(dumpId: string, requestingUserId?: string): 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.
|
||||
* Returns a page of the dumps `requestingUserId` may see whose title, note, or
|
||||
* previewed title or description contains `query`, newest first, with how many
|
||||
* match in all. `GET /api/search` calls it.
|
||||
*/
|
||||
export function searchDumps(
|
||||
query: string,
|
||||
@@ -366,8 +371,10 @@ export function searchDumps(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps, newest first: the public ones, and the
|
||||
* requesting user's own.
|
||||
* Returns a page of the dumps `requestingUserId` may see, newest first, for the
|
||||
* index feed (`GET /api/dumps`).
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function listDumps(
|
||||
page: number,
|
||||
@@ -375,7 +382,6 @@ export function listDumps(
|
||||
requestingUserId?: string,
|
||||
): Page<Dump> {
|
||||
const offset = (page - 1) * limit;
|
||||
// Show public dumps + the requesting user's own private dumps
|
||||
const rows = requestingUserId
|
||||
? db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
@@ -398,16 +404,11 @@ export function listDumps(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* different share shape still counts as the same dump. Private dumps are
|
||||
* visible only to their owner, exactly as everywhere else.
|
||||
*
|
||||
* Returns a small display-only projection: this feeds a hint on the create
|
||||
* form, not a feed.
|
||||
* Returns the dumps `requestingUserId` may see that were posted with the same
|
||||
* link as `url`, newest first, for the create form's duplicate notice
|
||||
* (`GET /api/dumps/by-url`). Links are compared in their canonical form, so
|
||||
* tracking parameters, the scheme, `www.` or a YouTube share format don't hide
|
||||
* a repost.
|
||||
*/
|
||||
export function findDumpsByUrl(
|
||||
url: string,
|
||||
@@ -447,12 +448,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.
|
||||
* Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its
|
||||
* preview fetched and gives the dump its page's title, unless `request` sets
|
||||
* one. A file dump ignores `url`. Clients get the dump as `dump_updated`, or a
|
||||
* `dump_deleted` when it turns private and a `dump_created` when it turns
|
||||
* public. Anyone @mentioned in the note is notified.
|
||||
*
|
||||
* @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.
|
||||
* @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,
|
||||
@@ -465,7 +468,6 @@ export async function updateDump(
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// File dumps: title, comment and isPrivate are editable
|
||||
if (dump.kind === "file") {
|
||||
const title = request.title?.trim() || dump.title;
|
||||
const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
|
||||
@@ -516,7 +518,6 @@ export async function updateDump(
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
// URL dumps
|
||||
const newUrl = request.url ?? dump.url!;
|
||||
|
||||
if (!isValidHttpUrl(newUrl)) {
|
||||
@@ -596,15 +597,15 @@ export async function updateDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Replaces a file dump's upload and its comment, for
|
||||
* `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left
|
||||
* out stays as it is. Visibility is taken here so that a new file and a change
|
||||
* of visibility land together. The old file is put back if the database update
|
||||
* fails, and clients are told as {@link updateDump} tells them.
|
||||
*
|
||||
* @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.
|
||||
* @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,
|
||||
@@ -681,7 +682,6 @@ export async function replaceFileDump(
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// Put the previous file back on DB failure
|
||||
if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {});
|
||||
else await Deno.remove(filePath).catch(() => {});
|
||||
throw err;
|
||||
@@ -718,8 +718,11 @@ export async function replaceFileDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s dumps, newest first, the private ones only with
|
||||
* `includePrivate`.
|
||||
* Returns a page of `userId`'s dumps, newest first, for
|
||||
* `GET /api/users/:username/dumps`. The route sets `includePrivate` when users
|
||||
* ask for their own.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function getDumpsByUser(
|
||||
userId: string,
|
||||
@@ -745,8 +748,11 @@ export function getDumpsByUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps `userId` upvoted, latest vote first — their own
|
||||
* private ones only when they are the one asking.
|
||||
* Returns a page of the dumps `userId` upvoted, latest vote first, for
|
||||
* `GET /api/users/:username/votes`. Their own private dumps are included only
|
||||
* when they are the one asking.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function getVotedDumpsByUser(
|
||||
userId: string,
|
||||
@@ -759,7 +765,6 @@ export function getVotedDumpsByUser(
|
||||
let rows: unknown[];
|
||||
|
||||
if (requestingUserId === userId) {
|
||||
// Own profile: include private dumps the user themselves voted on and owns.
|
||||
rows = db.prepare(
|
||||
`SELECT ${dumpCols}
|
||||
FROM dumps d
|
||||
@@ -790,10 +795,11 @@ export function getVotedDumpsByUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a URL dump's preview again and replaces its title and preview.
|
||||
* Fetches a URL dump's preview again, bypassing the cache, and replaces its
|
||||
* title and preview, for `POST /api/dumps/:dumpId/refresh-metadata`.
|
||||
*
|
||||
* @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.
|
||||
* @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);
|
||||
@@ -806,10 +812,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
);
|
||||
}
|
||||
|
||||
// A failed fetch yields a stub with no title and no thumbnail. Writing that
|
||||
// over a dump that already has good metadata would silently destroy it and
|
||||
// rename the dump to its bare hostname, so bail out instead — the caller
|
||||
// surfaces the error and nothing is lost.
|
||||
// A failed fetch yields a stub with no title or thumbnail. Written over the
|
||||
// dump's metadata, it would rename the dump to its host name.
|
||||
const { ok, content: richContent } = await tryFetchRichContent(dump.url, {
|
||||
fresh: true,
|
||||
});
|
||||
@@ -835,7 +839,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one.
|
||||
* Returns a dump's id and custom thumbnail type, or undefined without one, for
|
||||
* the thumbnail route. It doesn't check privacy: the route calls
|
||||
* {@link getDump} first.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
@@ -849,7 +855,9 @@ export function getDumpThumbnailInfo(
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a dump's new custom thumbnail, once its file is in place.
|
||||
* Records that a dump has a custom thumbnail of type `mime`, for
|
||||
* `PUT /api/dumps/:dumpId/thumbnail`. The route writes the file after this
|
||||
* call returns.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
@@ -865,7 +873,8 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a dump's custom thumbnail, and its file.
|
||||
* Removes a dump's custom thumbnail and its file, for
|
||||
* `DELETE /api/dumps/:dumpId/thumbnail`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
@@ -880,7 +889,8 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a dump, its files and thumbnails, and announces it.
|
||||
* Deletes a dump with its files and thumbnails, for
|
||||
* `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such dump.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Sending mail through the SMTP server in {@link SMTPS_URL}.
|
||||
* Sends mail through the SMTP server of {@link SMTPS_URL}, which may be unset.
|
||||
* Its two callers, the welcome email at sign-up and the password reset link,
|
||||
* check {@link isEmailEnabled} first and don't wait for delivery.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,42 +10,43 @@ 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. */
|
||||
/**
|
||||
* One address, a comma-separated list of them, or an array, as nodemailer
|
||||
* takes them.
|
||||
*/
|
||||
export type EmailRecipient = string | string[];
|
||||
|
||||
/** A message to send. It needs a recipient, a subject, and a text or HTML body. */
|
||||
/**
|
||||
* Passed to {@link sendEmail}. It needs a recipient, a subject, and a text or
|
||||
* HTML body; the callers send the same Markdown as both, rendered for `html`.
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* Returned by {@link sendEmail} once the SMTP server has taken the message.
|
||||
* No caller reads it yet.
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* Checks whether an SMTP server is configured. Without one, sign-up sends no
|
||||
* welcome email and a password reset request does nothing.
|
||||
*/
|
||||
export function isEmailEnabled(): boolean {
|
||||
return SMTPS_URL.length > 0;
|
||||
}
|
||||
@@ -76,8 +79,8 @@ function getTransporter(): Transporter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the connection to the SMTP server. Resolves false when email is
|
||||
* disabled.
|
||||
* 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.
|
||||
*/
|
||||
@@ -88,7 +91,8 @@ export async function verifyEmailTransport(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends `message`.
|
||||
* Sends `message` and resolves once the SMTP server has accepted it. The
|
||||
* transport is created on the first send.
|
||||
*
|
||||
* @throws {Error} When email is disabled, the message lacks a recipient,
|
||||
* subject or body, or the server refuses it.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Following users and playlists, and the feeds made of what one follows.
|
||||
* Stores who follows which users and playlists, and builds the feeds and
|
||||
* lists made from it. `routes/follows.ts` serves the caller's own, and
|
||||
* `routes/users.ts` what any user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -25,11 +27,9 @@ import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { attachCategoryIds } from "./category-service.ts";
|
||||
|
||||
// ── Follow / unfollow a user ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Makes `followerId` follow `followedUserId`, and notifies them the first
|
||||
* time. Following again changes nothing.
|
||||
* Makes `followerId` follow `followedUserId`, and notifies the followed user.
|
||||
* Following again changes nothing.
|
||||
*
|
||||
* @throws {APIException} 400 when a user tries to follow themselves.
|
||||
*/
|
||||
@@ -41,6 +41,7 @@ export function followUser(followerId: string, followedUserId: string): void {
|
||||
"Cannot follow yourself",
|
||||
);
|
||||
}
|
||||
// The unique index answers "already following" without a race.
|
||||
let isNew = true;
|
||||
try {
|
||||
db.prepare(
|
||||
@@ -70,11 +71,10 @@ export function unfollowUser(followerId: string, followedUserId: string): void {
|
||||
).run(followerId, followedUserId);
|
||||
}
|
||||
|
||||
// ── Follow / unfollow a playlist ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Makes `followerId` follow a public playlist, and notifies its owner the
|
||||
* first time. Following again changes nothing.
|
||||
* Makes `followerId` follow a public playlist, and notifies its owner.
|
||||
* 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
|
||||
* private.
|
||||
@@ -124,9 +124,12 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void {
|
||||
).run(followerId, playlistId);
|
||||
}
|
||||
|
||||
// ── Follow status ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the ids of every user and playlist `followerId` follows. */
|
||||
/**
|
||||
* Returns the ids of every user and playlist `followerId` follows, private
|
||||
* playlists included, for `GET /api/follows/status`.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse.
|
||||
*/
|
||||
export function getFollowStatus(followerId: string): FollowStatus {
|
||||
const rawUserRows = db.prepare(
|
||||
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
||||
@@ -152,11 +155,12 @@ 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`.
|
||||
* newest first, optionally only those in `categoryId`. It feeds the "users"
|
||||
* half of the index's "followed" tab.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function getFollowedUsersDumpFeed(
|
||||
followerId: string,
|
||||
@@ -166,7 +170,6 @@ export function getFollowedUsersDumpFeed(
|
||||
): Page<Dump> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Optional category filter: join the membership table and constrain to it.
|
||||
const catJoin = categoryId
|
||||
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
|
||||
: "";
|
||||
@@ -196,11 +199,13 @@ export function getFollowedUsersDumpFeed(
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
// ── 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`.
|
||||
* follows, optionally only those in `categoryId`. It feeds the "playlists"
|
||||
* half of the index's "followed" tab. A dump in several of them shows once,
|
||||
* dated by its latest addition.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a dump.
|
||||
*/
|
||||
export function getFollowedPlaylistsDumpFeed(
|
||||
followerId: string,
|
||||
@@ -242,11 +247,11 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
// ── Followed playlists (as playlist objects) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a page of the public playlists `userId` follows, most recently
|
||||
* followed first.
|
||||
* followed first, for their profile and playlists pages.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a playlist.
|
||||
*/
|
||||
export function getFollowedPlaylistsByUser(
|
||||
userId: string,
|
||||
@@ -277,9 +282,13 @@ export function getFollowedPlaylistsByUser(
|
||||
return pageOf(rawRows.map(playlistRowToApi), limit);
|
||||
}
|
||||
|
||||
// ── Followed users (as user objects) ─────────────────────────────────────────
|
||||
|
||||
/** Returns a page of the users `userId` follows, most recently followed first. */
|
||||
/**
|
||||
* Returns a page of the users `userId` follows, most recently followed first,
|
||||
* for their profile page. The users carry their password hash and email: pass
|
||||
* them through `toPublicUser` before sending them.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a user.
|
||||
*/
|
||||
export function getFollowedUsersByUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Invites: making them, and checking and claiming one on registration.
|
||||
* Makes invites, and checks and claims them at sign-up. An invite works once,
|
||||
* for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and
|
||||
* checks them, and `POST /api/users/register` claims one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,15 +13,15 @@ import { db } from "../db/connection.ts";
|
||||
import { isInviteRow } from "../model/invite.ts";
|
||||
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
|
||||
|
||||
/**
|
||||
* Returns a short, opaque, URL-safe invite token (128 bits of entropy → 22
|
||||
* chars).
|
||||
*/
|
||||
/** Returns 128 random bits as 22 URL-safe characters. */
|
||||
function generateInviteToken(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
/** Makes an invite from `inviterId` and returns its token. */
|
||||
/**
|
||||
* Makes an invite from `inviterId` and returns its token, which the client
|
||||
* puts in the invite link.
|
||||
*/
|
||||
export function createInvite(inviterId: string): string {
|
||||
const token = generateInviteToken();
|
||||
db.prepare(
|
||||
@@ -29,12 +31,9 @@ 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), 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.
|
||||
* Returns the id of the inviter when invite `token` is unused and still in its
|
||||
* window, which is the one after which `db/startup.ts` purges it. It doesn't
|
||||
* claim the invite: sign-up goes through {@link claimInvite}.
|
||||
*
|
||||
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
||||
* 409 when it has been used.
|
||||
@@ -74,10 +73,9 @@ export function validateInvite(token: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Marks invite `token` used and returns the inviter's id. Call it before the
|
||||
* async account creation, and {@link releaseInvite} if that fails: checking
|
||||
* first and marking after would let parallel sign-ups share one invite.
|
||||
*
|
||||
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
||||
* 409 when it has been used.
|
||||
@@ -97,7 +95,10 @@ export function claimInvite(token: string): string {
|
||||
return inviterId;
|
||||
}
|
||||
|
||||
/** Hands a claimed invite back when the account it was claimed for failed. */
|
||||
/**
|
||||
* Makes a claimed invite usable again, when the sign-up that claimed it
|
||||
* failed.
|
||||
*/
|
||||
export function releaseInvite(token: string): void {
|
||||
db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* Stores notifications, pushes each new one to its user's sockets as
|
||||
* `notification_created`, and reads them back for `routes/notifications.ts`.
|
||||
* The services of follows, votes, likes, comments, dumps, playlists and the
|
||||
* chat call its `notify…` triggers. Every trigger passes a source key, so the
|
||||
* same event never notifies a user twice.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -23,11 +25,9 @@ import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { sendToUser } from "./ws-service.ts";
|
||||
import { MENTION_RE } from "../../shared/mention.ts";
|
||||
|
||||
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stores a notification for `userId` and pushes it live. With a `sourceKey`,
|
||||
* a second one for the same `(userId, sourceKey)` is a no-op.
|
||||
* a second one for the same `(userId, sourceKey)` is neither stored nor sent.
|
||||
*/
|
||||
function createNotification(
|
||||
userId: string,
|
||||
@@ -43,7 +43,6 @@ function createNotification(
|
||||
let changes: number;
|
||||
|
||||
if (sourceKey) {
|
||||
// INSERT OR IGNORE: idempotent — same (user_id, source_key) pair is a no-op
|
||||
const result = db.prepare(
|
||||
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?);`,
|
||||
@@ -72,7 +71,12 @@ function createNotification(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a page of `userId`'s notifications, newest first. */
|
||||
/**
|
||||
* Returns a page of `userId`'s notifications, newest first, for
|
||||
* `GET /api/notifications`.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse.
|
||||
*/
|
||||
export function getNotificationsForUser(
|
||||
userId: string,
|
||||
page: number,
|
||||
@@ -93,7 +97,10 @@ export function getNotificationsForUser(
|
||||
return pageOf(rawRows.map(notificationRowToApi), limit);
|
||||
}
|
||||
|
||||
/** Returns how many of `userId`'s notifications are unread. */
|
||||
/**
|
||||
* Returns how many of `userId`'s notifications are unread. The WebSocket route
|
||||
* sends it in its `welcome` message, for the bell's count.
|
||||
*/
|
||||
export function getUnreadCount(userId: string): number {
|
||||
const row = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`,
|
||||
@@ -101,23 +108,30 @@ export function getUnreadCount(userId: string): number {
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
/** Marks every notification of `userId` read. */
|
||||
/**
|
||||
* Marks every notification of `userId` read, when they open the
|
||||
* `/notifications` page.
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ── Trigger helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Notifies `followedUserId` that `followerId` follows them. */
|
||||
/**
|
||||
* Notifies `followedUserId` that `followerId` follows them, once however often
|
||||
* they unfollow and follow again. `followUser` calls it.
|
||||
*/
|
||||
export function notifyUserNewFollower(
|
||||
followerId: string,
|
||||
followedUserId: string,
|
||||
@@ -138,7 +152,8 @@ export function notifyUserNewFollower(
|
||||
|
||||
/**
|
||||
* Notifies a playlist's owner that `followerId` follows it, unless they are
|
||||
* the owner.
|
||||
* the owner, and once however often they unfollow and follow again.
|
||||
* `followPlaylist` calls it.
|
||||
*/
|
||||
export function notifyPlaylistOwnerNewFollower(
|
||||
followerId: string,
|
||||
@@ -168,7 +183,10 @@ export function notifyPlaylistOwnerNewFollower(
|
||||
);
|
||||
}
|
||||
|
||||
/** Notifies everyone following `dumperId` that they posted a dump. */
|
||||
/**
|
||||
* Notifies everyone following `dumperId` that they posted a dump. The dump
|
||||
* service calls it for a public dump only, when it is created.
|
||||
*/
|
||||
export function notifyUserFollowersNewDump(
|
||||
dumperId: string,
|
||||
dumpId: string,
|
||||
@@ -196,10 +214,8 @@ export function notifyUserFollowersNewDump(
|
||||
const createdAt = now.toISOString();
|
||||
const sourceKey = `dump:${dumpId}`;
|
||||
|
||||
// Insert per follower so we can broadcast only the rows that were actually
|
||||
// created (INSERT OR IGNORE skips followers who already have this dump's
|
||||
// notification) and reuse each persisted id in the live event — otherwise a
|
||||
// later refetch would dedupe against a mismatched id and show a duplicate.
|
||||
// One insert per follower, so that only the rows actually stored are sent,
|
||||
// each with its stored id: the client dedupes a refetch against it.
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?);`,
|
||||
@@ -236,7 +252,11 @@ export function notifyUserFollowersNewDump(
|
||||
});
|
||||
}
|
||||
|
||||
/** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */
|
||||
/**
|
||||
* Notifies a dump's poster that `voterId` upvoted it, unless they are the
|
||||
* poster, and once however often the vote is withdrawn and cast again.
|
||||
* `castVote` calls it.
|
||||
*/
|
||||
export function notifyDumpOwnerUpvote(
|
||||
voterId: string,
|
||||
dumpId: string,
|
||||
@@ -250,7 +270,7 @@ export function notifyDumpOwnerUpvote(
|
||||
).get(dumpId) as { title: string; user_id: string } | undefined;
|
||||
|
||||
if (!voterRow || !dumpRow) return;
|
||||
if (voterId === dumpRow.user_id) return; // no self-notification
|
||||
if (voterId === dumpRow.user_id) return;
|
||||
|
||||
createNotification(
|
||||
dumpRow.user_id,
|
||||
@@ -267,10 +287,10 @@ 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).
|
||||
* mentioned in the comment, dump, playlist or chat message `contextId`. Call
|
||||
* it again after each edit: a user already notified for this context isn't
|
||||
* notified twice. `skipUserId` spares a mentioned user; the chat uses it for
|
||||
* those who have it open.
|
||||
*/
|
||||
export function notifyMentions(
|
||||
mentionerUserId: string,
|
||||
@@ -317,7 +337,7 @@ export function notifyMentions(
|
||||
|
||||
/**
|
||||
* Notifies a dump's poster that `commenterId` commented on it, unless they are
|
||||
* the poster.
|
||||
* the poster. `createComment` calls it for replies too, but not on an edit.
|
||||
*/
|
||||
export function notifyDumpOwnerNewComment(
|
||||
commenterId: string,
|
||||
@@ -333,7 +353,7 @@ export function notifyDumpOwnerNewComment(
|
||||
).get(dumpId) as { title: string; user_id: string } | undefined;
|
||||
|
||||
if (!commenterRow || !dumpRow) return;
|
||||
if (commenterId === dumpRow.user_id) return; // no self-notification
|
||||
if (commenterId === dumpRow.user_id) return;
|
||||
|
||||
createNotification(
|
||||
dumpRow.user_id,
|
||||
@@ -351,7 +371,7 @@ export function notifyDumpOwnerNewComment(
|
||||
|
||||
/**
|
||||
* Notifies a comment's author that `likerId` likes it, unless they are the
|
||||
* author.
|
||||
* author, and once however often the like is withdrawn and given again.
|
||||
*/
|
||||
export function notifyCommentOwnerLike(
|
||||
likerId: string,
|
||||
@@ -369,7 +389,7 @@ export function notifyCommentOwnerLike(
|
||||
| undefined;
|
||||
|
||||
if (!likerRow || !commentRow) return;
|
||||
if (likerId === commentRow.user_id) return; // no self-notification
|
||||
if (likerId === commentRow.user_id) return;
|
||||
|
||||
createNotification(
|
||||
commentRow.user_id,
|
||||
@@ -385,7 +405,11 @@ export function notifyCommentOwnerLike(
|
||||
);
|
||||
}
|
||||
|
||||
/** Notifies everyone following a playlist that a dump was added to it. */
|
||||
/**
|
||||
* Notifies everyone following a playlist that a dump was added to it. The
|
||||
* playlist service calls it only when both are public, since a follower can't
|
||||
* open a private one.
|
||||
*/
|
||||
export function notifyPlaylistFollowersNewDump(
|
||||
playlistId: string,
|
||||
playlistTitle: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Password resets by email: sending a reset link, and setting the new
|
||||
* password with it.
|
||||
* Emails password reset links and sets the new password when one is used. The
|
||||
* users routes call it for `POST /api/users/request-password-reset` and
|
||||
* `POST /api/users/reset-password`; the link opens the client's
|
||||
* `/reset-password` page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,16 +22,20 @@ 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. */
|
||||
/** Must match the `1h` that `createPasswordResetToken` signs into the token. */
|
||||
const RESET_TOKEN_TTL_HOURS = 1;
|
||||
|
||||
/** Per address: enough to retry a lost email, not enough to flood an inbox. */
|
||||
/**
|
||||
* Applies per address. It allows a retry after a lost email, but not enough
|
||||
* requests 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).
|
||||
* Emails a reset link to the account with address `email`, if there is one and
|
||||
* email is configured. It says nothing of what it did, so that the route
|
||||
* answers the same whether the address has an account or not. It sends at
|
||||
* most 3 links in a row per address, then one every 10 minutes.
|
||||
*/
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
|
||||
@@ -66,8 +72,8 @@ 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.
|
||||
* Sets `newPassword` for the user of a reset token and marks the token used.
|
||||
* Through `updateUser`, this ends the user's other sessions.
|
||||
*
|
||||
* @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
|
||||
@@ -109,8 +115,8 @@ export async function resetPassword(
|
||||
);
|
||||
}
|
||||
|
||||
// Consume the link before the (async) password hash: checking used_at and
|
||||
// marking it later would let parallel submissions all pass the check.
|
||||
// Claim the link before the async hash: checking `used_at` first and
|
||||
// marking it after would let parallel submissions all pass.
|
||||
const claimed = db.prepare(
|
||||
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
||||
).run(new Date().toISOString(), token);
|
||||
@@ -125,6 +131,7 @@ export async function resetPassword(
|
||||
try {
|
||||
await updateUser(payload.userId, { password: newPassword });
|
||||
} catch (err) {
|
||||
// The password didn't change, so the link must keep working.
|
||||
db.prepare(
|
||||
`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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}).
|
||||
* Stores playlists and the ordered dumps in them. The playlist routes call it,
|
||||
* and so do the search, users and dumps routes to list playlists. Every write
|
||||
* is broadcast over the WebSocket, and a viewer is only ever sent the dumps
|
||||
* {@link isMemberVisibleTo} lets them see.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -42,16 +43,18 @@ import {
|
||||
} 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.
|
||||
* The select list and `FROM` clause of a playlist query, adding the owner's
|
||||
* name and the dump count. Callers append the `WHERE`. The dump count includes
|
||||
* dumps the viewer can't see.
|
||||
*/
|
||||
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.
|
||||
* Returns the playlist with id or slug `idOrSlug`, private or not. A caller
|
||||
* that shows it to someone must check `isPublic` first, as the OG middleware
|
||||
* does.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist.
|
||||
*/
|
||||
@@ -66,8 +69,9 @@ export function getPlaylistById(idOrSlug: string): Playlist {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a playlist owned by `userId`, notifies anyone @mentioned in its
|
||||
* description, and announces it.
|
||||
* Creates a playlist owned by `userId`, for `POST /api/playlists`. It notifies
|
||||
* the users its description mentions, links the attachments it embeds, and
|
||||
* broadcasts `playlist_created`.
|
||||
*/
|
||||
export function createPlaylist(
|
||||
req: CreatePlaylistRequest,
|
||||
@@ -107,9 +111,11 @@ export function createPlaylist(
|
||||
|
||||
/**
|
||||
* Returns a playlist with the dumps `requestingUserId` may see in it, in
|
||||
* order.
|
||||
* order, for `GET /api/playlists/:playlistId`. A null `requestingUserId` is a
|
||||
* signed-out viewer.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, or it is private and not theirs.
|
||||
* @throws {APIException} 404 when there is no such playlist, or it is private
|
||||
* and not theirs.
|
||||
*/
|
||||
export function getPlaylist(
|
||||
playlistId: string,
|
||||
@@ -141,7 +147,8 @@ export function getPlaylist(
|
||||
|
||||
/**
|
||||
* Returns up to `limit` playlists whose title or description contains
|
||||
* `query`, newest first: the public ones, and the requesting user's own.
|
||||
* `query`, newest first, for `GET /api/search`. Private playlists are left
|
||||
* out, except `requestingUserId`'s own. A blank `query` finds nothing.
|
||||
*/
|
||||
export function searchPlaylists(
|
||||
query: string,
|
||||
@@ -164,8 +171,9 @@ export function searchPlaylists(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s playlists, newest first — the private ones only
|
||||
* to `userId` themselves.
|
||||
* Returns a page of `userId`'s playlists, newest first, for
|
||||
* `GET /api/users/:username/playlists`. The private ones are listed only when
|
||||
* `requestingUserId` is `userId`.
|
||||
*/
|
||||
export function listPlaylistsByUser(
|
||||
userId: string,
|
||||
@@ -185,11 +193,13 @@ export function listPlaylistsByUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns
|
||||
* it as it now is. The slug follows the title. `canModerate` lets someone
|
||||
* other than the owner edit it. It notifies the users the description
|
||||
* mentions, links its attachments and broadcasts `playlist_updated`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
|
||||
* @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,
|
||||
@@ -247,10 +257,11 @@ export function updatePlaylist(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a playlist, as its owner or a moderator (`canModerate`), and
|
||||
* announces it.
|
||||
* Deletes a playlist for `DELETE /api/playlists/:playlistId`, and broadcasts
|
||||
* `playlist_deleted`. `canModerate` lets someone other than the owner do it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
|
||||
* @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,
|
||||
@@ -268,9 +279,12 @@ export function deletePlaylist(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the playlist, if the requester may edit it.
|
||||
* Returns the playlist if `requestingUserId` may edit it. The cover upload
|
||||
* route checks this before it writes the file, then calls
|
||||
* {@link setPlaylistImage}.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
|
||||
* @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,
|
||||
@@ -285,10 +299,12 @@ export function getEditablePlaylist(
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a playlist's new cover image and announces it. Call once the image
|
||||
* file is in place: this is what announces it.
|
||||
* Records the type of a playlist's new cover and broadcasts `playlist_updated`.
|
||||
* The route must have written the file already, since clients fetch the cover
|
||||
* as soon as they get the broadcast.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
|
||||
* @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,
|
||||
@@ -301,8 +317,8 @@ export function setPlaylistImage(
|
||||
requestingUserId,
|
||||
canModerate,
|
||||
);
|
||||
// A new cover is an edit: bumping updated_at also moves the `?v=` the
|
||||
// client puts on the image URL, so a cached old cover isn't shown.
|
||||
// updated_at is the `?v=` the client puts on the cover URL, so bumping it
|
||||
// keeps browsers from showing the cached old cover.
|
||||
db.prepare(
|
||||
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
|
||||
).run(imageMime, new Date().toISOString(), playlist.id);
|
||||
@@ -312,11 +328,15 @@ export function setPlaylistImage(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Puts a dump at the top of a playlist, for
|
||||
* `POST /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts
|
||||
* `playlist_dumps_updated`. The followers of a public playlist are notified
|
||||
* 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.
|
||||
* @throws {APIException} 404 when there is no such playlist, or when the dump
|
||||
* doesn't exist or is someone else's private dump, 403 when the user is
|
||||
* neither the playlist's owner nor a moderator, 409 when the dump is already
|
||||
* in it.
|
||||
*/
|
||||
export function addDumpToPlaylist(
|
||||
playlistId: string,
|
||||
@@ -330,8 +350,8 @@ export function addDumpToPlaylist(
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
||||
}
|
||||
|
||||
// Only a dump the requester can see — someone else's private dump would
|
||||
// otherwise land in the playlist and its title in followers' notifications.
|
||||
// Else someone else's private dump would land in the playlist, and its title
|
||||
// in the followers' notifications.
|
||||
const dump = db.prepare(
|
||||
`SELECT title, is_private, user_id FROM dumps WHERE id = ?;`,
|
||||
).get(dumpId) as
|
||||
@@ -367,7 +387,7 @@ export function addDumpToPlaylist(
|
||||
|
||||
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
|
||||
|
||||
// Followers can't open a private dump, even the owner's own.
|
||||
// Followers can't open a private dump, even one of the owner's own.
|
||||
if (playlist.isPublic && !dump.is_private) {
|
||||
notifyPlaylistFollowersNewDump(
|
||||
playlist.id,
|
||||
@@ -379,9 +399,12 @@ export function addDumpToPlaylist(
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dump out of a playlist, if it is in it, and announces the change.
|
||||
* Takes a dump out of a playlist, for
|
||||
* `DELETE /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts
|
||||
* `playlist_dumps_updated`. A dump that isn't in it is no error.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator.
|
||||
* @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,
|
||||
@@ -403,8 +426,9 @@ export function removeDumpFromPlaylist(
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the playlist's members the requester can see in the order of
|
||||
* `dumpIds`, and announces the change.
|
||||
* Puts the dumps of a playlist that the requester can see in the order of
|
||||
* `dumpIds`, for `PUT /api/playlists/:playlistId/order`, and broadcasts
|
||||
* `playlist_dumps_updated`.
|
||||
*
|
||||
* @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
|
||||
@@ -422,10 +446,9 @@ export function reorderPlaylist(
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
||||
}
|
||||
|
||||
// The requester orders what they can see — the playlist as GET gives it to
|
||||
// them. A member hidden from them (someone else's dump that went private,
|
||||
// or the owner's private dumps for a moderator) can't be named, so it
|
||||
// keeps its slot and the visible ones are reordered around it.
|
||||
// The requester can only name the dumps GET showed them. A hidden one
|
||||
// (someone else's dump that went private, or the owner's private dump for a
|
||||
// moderator) keeps its slot, and the visible ones are reordered around it.
|
||||
const members = getCurrentMembers(playlist.id);
|
||||
const visibleIds = members
|
||||
.filter((m) => isMemberVisibleTo(m, playlist, requestingUserId))
|
||||
@@ -462,7 +485,10 @@ export function reorderPlaylist(
|
||||
|
||||
/**
|
||||
* Returns each of `userId`'s playlists, newest first, and whether it holds
|
||||
* dump `dumpId`.
|
||||
* dump `dumpId`, for `GET /api/playlists/by-dump/:dumpId/memberships`. The
|
||||
* client's add-to-playlist modal shows it. The playlists have no dump count.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a playlist.
|
||||
*/
|
||||
export function getPlaylistMembershipsForDump(
|
||||
dumpId: string,
|
||||
@@ -492,10 +518,10 @@ export function getPlaylistMembershipsForDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns up to 20 playlists that hold dump `dumpId`, newest first, for
|
||||
* `GET /api/dumps/:dumpId/playlists`. Private playlists are left out, except
|
||||
* `requestingUserId`'s own. The route checks first that the caller may see the
|
||||
* dump.
|
||||
*/
|
||||
export function getPlaylistsForDump(
|
||||
dumpId: string,
|
||||
@@ -512,8 +538,9 @@ export function getPlaylistsForDump(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns what `GET /api/playlists/:playlistId/image` needs to serve a
|
||||
* playlist's cover, or undefined when it has none. The route serves a private
|
||||
* playlist's cover as private, so no shared cache keeps it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, or it is private
|
||||
* and `requestingUserId` isn't its owner.
|
||||
@@ -535,7 +562,10 @@ export function getPlaylistImageInfo(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns a playlist's members, in order, with what their visibility takes. */
|
||||
/**
|
||||
* Returns a playlist's dumps in order, all of them, for the callers to filter
|
||||
* per viewer.
|
||||
*/
|
||||
function getCurrentMembers(playlistId: string): PlaylistMember[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT pd.dump_id, d.is_private, d.user_id
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Link previews: picks the provider for a URL and caches recent results.
|
||||
* Fetches a link's preview through the provider that knows its site, and
|
||||
* reuses it for a few minutes. `dump-service.ts` calls it when a link is
|
||||
* posted, changed or refreshed, and `GET /api/preview` when the create form
|
||||
* previews one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,9 +16,9 @@ import { selfProvider } from "../remote/providers/self.ts";
|
||||
import { genericProvider } from "../remote/providers/generic.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Lists the providers in priority order: the first that matches a URL reads
|
||||
* it. `selfProvider` reads this site's own pages from the database, and
|
||||
* `genericProvider` must stay last, since it matches every URL.
|
||||
*/
|
||||
const providers: RichContentProvider[] = [
|
||||
youtubeProvider,
|
||||
@@ -25,44 +28,51 @@ const providers: RichContentProvider[] = [
|
||||
genericProvider,
|
||||
];
|
||||
|
||||
/** The outcome of fetching a link's preview. */
|
||||
/**
|
||||
* Returned by {@link tryFetchRichContent} to `GET /api/preview`, which reports
|
||||
* `ok` as `reached`, and to `refreshDumpMetadata`, which gives up when it is
|
||||
* false.
|
||||
*/
|
||||
export interface FetchRichContentResult {
|
||||
/**
|
||||
* 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.
|
||||
* found to carry no metadata. A caller that already holds metadata must not
|
||||
* overwrite it with the failure stub.
|
||||
*/
|
||||
ok: boolean;
|
||||
/** The preview, or on failure a stub with just the URL and host. */
|
||||
/**
|
||||
* On failure, a stub holding the URL and its host name. Missing only when
|
||||
* the URL doesn't parse.
|
||||
*/
|
||||
content?: RichContent;
|
||||
}
|
||||
|
||||
/** How long a fetched preview is reused. */
|
||||
const RECENT_TTL_MS = 5 * 60 * 1000;
|
||||
/** The most previews kept; the oldest write goes first. */
|
||||
/** How many previews are kept. The oldest written 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.
|
||||
* Holds the previews fetched in the last few minutes, by URL. The create form
|
||||
* previews a link and then posts it, and without this the second fetch of the
|
||||
* page was the slow part of posting. Only successes are kept.
|
||||
*/
|
||||
const recent = new Map<
|
||||
string,
|
||||
{ at: number; result: Promise<FetchRichContentResult> }
|
||||
>();
|
||||
|
||||
/** Forgets every cached preview (for the tests). */
|
||||
/**
|
||||
* Forgets every cached preview. The tests' `stubNetwork` calls it, since
|
||||
* previews fetched from the previous stubbed network no longer apply.
|
||||
*/
|
||||
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.
|
||||
* Fetches the preview of `url`, and says whether the page could be reached. A
|
||||
* preview fetched in the last five minutes is reused unless `fresh` is set, as
|
||||
* a metadata refresh does. It never rejects: a failure resolves to a stub.
|
||||
*/
|
||||
export function tryFetchRichContent(
|
||||
url: string,
|
||||
@@ -86,7 +96,10 @@ export function tryFetchRichContent(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fetches the preview of `url` through the first provider that matches. */
|
||||
/**
|
||||
* Fetches the preview of `url` through the first provider that matches, or
|
||||
* makes the failure stub when the provider rejects.
|
||||
*/
|
||||
async function fetchRichContentUncached(
|
||||
url: string,
|
||||
): Promise<FetchRichContentResult> {
|
||||
@@ -110,7 +123,11 @@ async function fetchRichContentUncached(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the preview of `url`, or a minimal stub when it can't be fetched. */
|
||||
/**
|
||||
* Returns the preview of `url`, or a stub holding its host name when the page
|
||||
* can't be reached. `createUrlDump` and `updateDump` use it for a new link,
|
||||
* which has no earlier preview to keep.
|
||||
*/
|
||||
export async function fetchRichContent(
|
||||
url: string,
|
||||
): Promise<RichContent | undefined> {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Users: accounts, profiles, lookups and search, and the invite tree.
|
||||
* Reads and writes the `users` table. Its `User`s carry the password hash and
|
||||
* email, so a route strips them with `toPublicUser` or `toSelfUser` before
|
||||
* answering. `routes/users.ts` is its main caller; `lib/auth.ts` checks every
|
||||
* token against {@link getSessionState}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -27,7 +30,7 @@ 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. */
|
||||
/** Selects full `UserRow`s from `users u`, with the inviter's name joined. */
|
||||
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
|
||||
@@ -35,7 +38,9 @@ const USER_SELECT =
|
||||
LEFT JOIN users i ON i.id = u.invited_by`;
|
||||
|
||||
/**
|
||||
* Creates a user with the `user` role, invited by `inviterId`.
|
||||
* Creates a user with the `user` role, invited by `inviterId`, for
|
||||
* `POST /api/users/register`. The caller has validated `request` and claimed
|
||||
* the invite.
|
||||
*
|
||||
* @throws {APIException} 400 when the username is taken, 409 when the email
|
||||
* is.
|
||||
@@ -77,8 +82,8 @@ export async function createUser(
|
||||
request.email,
|
||||
);
|
||||
} catch (err) {
|
||||
// The check above ran before the hash yielded, so a parallel registration
|
||||
// can still take the name first; the UNIQUE constraint is the real guard.
|
||||
// A parallel sign-up can take the name while the hash is computed; the
|
||||
// UNIQUE constraint is the real guard. Emails have no such constraint.
|
||||
if (err instanceof Error && err.message.includes("UNIQUE")) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -100,7 +105,7 @@ export async function createUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns user `userId`.
|
||||
* Returns user `userId`, with their hash and email.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
@@ -117,9 +122,9 @@ export function getUserById(userId: string): User {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the user's live role and their last password change, before which
|
||||
* their tokens don't count, or undefined when the user is gone. `lib/auth.ts`
|
||||
* checks every session and media token against it.
|
||||
*/
|
||||
export function getSessionState(
|
||||
userId: string,
|
||||
@@ -136,7 +141,8 @@ export function getSessionState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user named `username`.
|
||||
* Returns the user named `username`, with their hash and email. The name
|
||||
* matches case-sensitively.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
@@ -153,9 +159,9 @@ export function getUserByUsername(username: string): User {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Throws a 409 when an account other than `exceptUserId` has `email`, in any
|
||||
* case. Password resets are looked up by email, so an address must belong to
|
||||
* one account only.
|
||||
*/
|
||||
function assertEmailAvailable(email: string, exceptUserId?: string): void {
|
||||
const row = db.prepare(
|
||||
@@ -170,7 +176,10 @@ function assertEmailAvailable(email: string, exceptUserId?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the user whose email is `email`, or null. */
|
||||
/**
|
||||
* Returns the user whose email is exactly `email`, or null, for
|
||||
* `requestPasswordReset`. Unlike the uniqueness check, it is case-sensitive.
|
||||
*/
|
||||
export function getUserByEmail(email: string): User | null {
|
||||
const userRow = db.prepare(
|
||||
`${USER_SELECT} WHERE u.email = ?`,
|
||||
@@ -181,8 +190,8 @@ export function getUserByEmail(email: string): User | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` users whose name starts with `query`, for @mention
|
||||
* suggestions.
|
||||
* Returns up to `limit` users whose name starts with `query`, for the editor's
|
||||
* @mention suggestions (`GET /api/users/search`). An empty query returns none.
|
||||
*/
|
||||
export function searchUsers(
|
||||
query: string,
|
||||
@@ -204,8 +213,8 @@ export function searchUsers(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to `limit` users whose name or profile contains `query`, for the
|
||||
* public search page: profiles only, never hashes or emails.
|
||||
* Returns up to `limit` users whose name or description contains `query`, for
|
||||
* the `/search` page (`GET /api/search`). They are already public profiles.
|
||||
*/
|
||||
export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
|
||||
if (!query.trim()) return [];
|
||||
@@ -216,7 +225,11 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
|
||||
return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser);
|
||||
}
|
||||
|
||||
/** Returns every user. */
|
||||
/**
|
||||
* 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}`,
|
||||
@@ -230,8 +243,11 @@ export function listUsers(): User[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `request` to user `userId`. A new password also ends every session
|
||||
* opened before it and closes the user's sockets.
|
||||
* Applies `request` to user `userId` and returns the updated user. It trusts
|
||||
* `request`: the routes pick the fields a caller may change. A new password
|
||||
* also ends every session opened before it and closes the user's sockets, so
|
||||
* the caller must issue fresh tokens if the user is to stay signed in. A
|
||||
* `description` of null keeps the current one.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user, 409 when the new
|
||||
* email belongs to another account.
|
||||
@@ -275,7 +291,6 @@ export async function updateUser(
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||
}
|
||||
|
||||
// A new password ends every session opened with the old one.
|
||||
if (password) {
|
||||
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
||||
now.toISOString(),
|
||||
@@ -292,7 +307,9 @@ export async function updateUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that user `userId` now has an avatar of type `mime`.
|
||||
* Records that user `userId` now has an avatar of type `mime`, once
|
||||
* `POST /api/avatars/me` has written the file. It bumps `updated_at`, which
|
||||
* the client uses to reload the image.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
@@ -307,8 +324,9 @@ 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.
|
||||
* Returns everyone `userId` invited, and everyone they invited in turn, eleven
|
||||
* levels deep at most, in sign-up order, for the profile page's invite tree
|
||||
* (`GET /api/users/:username/invitees`).
|
||||
*/
|
||||
export function getInviteTree(userId: string): Invitee[] {
|
||||
const rows = db.prepare(`
|
||||
@@ -341,7 +359,7 @@ export function getInviteTree(userId: string): Invitee[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes user `userId` and disconnects their sockets.
|
||||
* Deletes user `userId` and disconnects their sockets. Nothing calls it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such user.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* Upvotes on dumps.
|
||||
* Stores upvotes on dumps and keeps each dump's `vote_count` in step with them.
|
||||
*
|
||||
* Voting has no HTTP endpoint. The WebSocket route (`routes/ws.ts`) calls this
|
||||
* module on `vote_cast` and `vote_remove` messages, then broadcasts the new
|
||||
* count. `routes/dumps.ts` uses it only to list a dump's voters.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,11 +15,13 @@ 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. */
|
||||
/**
|
||||
* Returned to the WebSocket route by {@link castVote} and {@link removeVote}.
|
||||
* The route acks the voter with `voteCount`, then broadcasts it to every
|
||||
* client, or to the dump owner's alone when `isPrivate`.
|
||||
*/
|
||||
export interface VoteResult {
|
||||
/** The dump's vote count now. */
|
||||
voteCount: number;
|
||||
/** Whether the dump is private: its vote count goes to its owner only. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
@@ -34,7 +40,8 @@ function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } {
|
||||
}
|
||||
|
||||
/**
|
||||
* Records `userId`'s upvote on a dump and notifies its poster.
|
||||
* Records `userId`'s upvote on a dump and notifies the dump's poster. The
|
||||
* notification is best-effort: if it fails, the vote still counts.
|
||||
*
|
||||
* @throws {APIException} 404 when the dump doesn't exist or is someone else's
|
||||
* private dump, 409 when the user already voted for it.
|
||||
@@ -68,7 +75,7 @@ export function castVote(dumpId: string, userId: string): VoteResult {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Notification is best-effort — must not prevent vote_ack from being sent
|
||||
// The vote is committed: a failed notification must not report it as failed.
|
||||
try {
|
||||
notifyDumpOwnerUpvote(userId, dumpId);
|
||||
} catch { /* ignore */ }
|
||||
@@ -111,7 +118,10 @@ export function removeVote(dumpId: string, userId: string): VoteResult {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the ids of every dump `userId` upvoted. */
|
||||
/**
|
||||
* Returns the ids of the dumps `userId` upvoted. The WebSocket route sends them
|
||||
* in its `welcome` message, and the client shows those vote buttons pressed.
|
||||
*/
|
||||
export function getUserVotes(userId: string): string[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT dump_id FROM votes WHERE user_id = ?;`,
|
||||
@@ -119,7 +129,13 @@ 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. */
|
||||
/**
|
||||
* Returns a page of the users who upvoted a dump, latest vote first, for
|
||||
* `GET /api/dumps/:dumpId/voters`. It doesn't check that the caller may see the
|
||||
* dump: the route does that first, with `getDump`.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a user.
|
||||
*/
|
||||
export function getDumpVoters(
|
||||
dumpId: string,
|
||||
page: number,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
* Keeps the set of open sockets and sends them the server's messages.
|
||||
*
|
||||
* The WebSocket route (`routes/ws.ts`) registers each socket and handles what
|
||||
* it sends. The services and routes that change dumps, comments, playlists and
|
||||
* users call the `broadcast*` functions here after the change is stored. A
|
||||
* `broadcast*` function sends to everyone unless its doc names an audience, so
|
||||
* its caller must not pass it anything private. The module also pings every
|
||||
* socket and closes the ones that stop answering.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,45 +22,56 @@ import {
|
||||
type PlaylistMember,
|
||||
} from "../lib/playlist-members.ts";
|
||||
|
||||
/** A connected socket, and what the server knows of its user. */
|
||||
/**
|
||||
* Created by the WebSocket route for each socket it opens, and kept here until
|
||||
* the socket closes. The user fields are read at connect and left undefined for
|
||||
* an anonymous socket.
|
||||
*/
|
||||
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 {@link TYPING_TTL} are treated as stale.
|
||||
* Read again from the database on each chat edit or delete, so a demoted
|
||||
* moderator loses the right at once.
|
||||
*/
|
||||
role?: Role;
|
||||
avatarMime?: string;
|
||||
/** Set by {@link updateClientAvatar}, for the client's avatar URLs. */
|
||||
avatarVersion?: number;
|
||||
/** Undefined until the first ping, so a new socket isn't closed at once. */
|
||||
pongReceived?: boolean;
|
||||
/**
|
||||
* Set by `chat_focus`: the chatbox is open and the tab visible, so the user
|
||||
* is reading the chat and gets no notification for a mention in it.
|
||||
*/
|
||||
chatOpen?: boolean;
|
||||
/** When the last `chat_typing` true arrived, in ms. */
|
||||
typingAt?: number;
|
||||
}
|
||||
|
||||
/** Every connected client. */
|
||||
const clients = new Set<WsClient>();
|
||||
|
||||
/** Adds a connected client. */
|
||||
/**
|
||||
* Adds a client once its socket opens. From then on, it receives the
|
||||
* broadcasts, and is shown online if it is signed in.
|
||||
*/
|
||||
export function register(client: WsClient): void {
|
||||
clients.add(client);
|
||||
}
|
||||
|
||||
/** Removes a disconnected client. */
|
||||
/**
|
||||
* Removes a client when its socket closes. The caller rebroadcasts presence.
|
||||
*/
|
||||
export function unregister(client: WsClient): void {
|
||||
clients.delete(client);
|
||||
}
|
||||
|
||||
/** Records a user's new avatar on their clients and rebroadcasts presence. */
|
||||
/**
|
||||
* Records `userId`'s new avatar on their clients, so presence and the typing
|
||||
* list show it, and rebroadcasts presence. `routes/avatars.ts` calls it on an
|
||||
* upload.
|
||||
*/
|
||||
export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||
const version = Date.now();
|
||||
for (const client of clients) {
|
||||
@@ -67,7 +83,10 @@ export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||
broadcastPresence();
|
||||
}
|
||||
|
||||
/** Returns each signed-in user with at least one socket, once. */
|
||||
/**
|
||||
* Returns each signed-in user with a socket open, once. The route sends it in
|
||||
* `welcome`, and {@link broadcastPresence} in `presence_update`.
|
||||
*/
|
||||
export function getOnlineUsers(): OnlineUser[] {
|
||||
const seen = new Map<string, OnlineUser>();
|
||||
for (const client of clients) {
|
||||
@@ -83,19 +102,18 @@ export function getOnlineUsers(): OnlineUser[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Sends an already serialized message, if the socket is open. */
|
||||
/** Sends `payload` unless the socket is closing, where `send` would throw. */
|
||||
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 `data` to every client `include` accepts, or to all of them. It is
|
||||
* serialized once, not once per socket.
|
||||
*/
|
||||
function broadcast(
|
||||
data: ServerToClientMessage,
|
||||
@@ -109,12 +127,18 @@ function broadcast(
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a message to every socket of `userId`. */
|
||||
/**
|
||||
* Sends `data` to every socket of `userId`. The notification service sends
|
||||
* `notification_created` with it, and the route `chat_read_update`.
|
||||
*/
|
||||
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. */
|
||||
/**
|
||||
* 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) {
|
||||
@@ -125,10 +149,10 @@ export function disconnectUser(userId: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the user's sockets without logging anyone out: each reconnects with
|
||||
* whatever token it holds, and /ws turns a revoked one into a force_logout.
|
||||
* Used on a password change, where the tab that made the change has already
|
||||
* swapped in a fresh token and must stay signed in.
|
||||
* 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:
|
||||
* the tab that made the change has a fresh one and stays signed in, while the
|
||||
* others connect anonymously and sign out on their next 401.
|
||||
*/
|
||||
export function closeUserSockets(userId: string, reason: string): void {
|
||||
for (const client of clients) {
|
||||
@@ -136,29 +160,41 @@ export function closeUserSockets(userId: string, reason: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends everyone the list of online users. */
|
||||
/**
|
||||
* Sends everyone `presence_update`. The route calls it whenever a socket opens
|
||||
* or closes.
|
||||
*/
|
||||
export function broadcastPresence(): void {
|
||||
broadcast({ type: "presence_update", users: getOnlineUsers() });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump was posted. */
|
||||
/**
|
||||
* Sends everyone `dump_created`. The dump service also sends it when a private
|
||||
* dump goes public, since to everyone else that dump is new.
|
||||
*/
|
||||
export function broadcastNewDump(dump: Dump): void {
|
||||
broadcast({ type: "dump_created", dump });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump changed. */
|
||||
/**
|
||||
* Sends everyone `dump_updated`, for a public dump that changed. The dump
|
||||
* service calls it.
|
||||
*/
|
||||
export function broadcastDumpUpdated(dump: Dump): void {
|
||||
broadcast({ type: "dump_updated", dump });
|
||||
}
|
||||
|
||||
/** Tells everyone a dump was deleted. */
|
||||
/**
|
||||
* Sends everyone `dump_deleted`. The dump service also sends it when a public
|
||||
* dump goes private, so that everyone else drops it.
|
||||
*/
|
||||
export function broadcastDumpDeleted(dumpId: string): void {
|
||||
broadcast({ type: "dump_deleted", dumpId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells everyone a dump's vote count changed. With `onlyUserId`, a private
|
||||
* dump's count goes to that user's sockets alone.
|
||||
* Sends `votes_update` after the route stored a vote, to everyone, or with
|
||||
* `onlyUserId` to that user's sockets alone (for a private dump).
|
||||
*/
|
||||
export function broadcastVoteUpdate(
|
||||
dumpId: string,
|
||||
@@ -174,8 +210,9 @@ export function broadcastVoteUpdate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells everyone a comment's like count changed. With `onlyUserId`, a like on
|
||||
* a private dump's comment goes to that user alone.
|
||||
* Sends `comment_likes_update` after the route stored a like, to everyone, or
|
||||
* with `onlyUserId` to that user's sockets alone (for a comment on a private
|
||||
* dump).
|
||||
*/
|
||||
export function broadcastCommentLikeUpdate(
|
||||
commentId: string,
|
||||
@@ -190,7 +227,7 @@ export function broadcastCommentLikeUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
/** Sends a message to everyone for a public playlist, to its owner otherwise. */
|
||||
/** Sends `data` to everyone for a public playlist, else to its owner alone. */
|
||||
function sendToPlaylistAudience(
|
||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
||||
data: ServerToClientMessage,
|
||||
@@ -201,16 +238,18 @@ function sendToPlaylistAudience(
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells a new playlist's audience it was created. */
|
||||
/**
|
||||
* Sends `playlist_created` to the new playlist's audience. The playlist service
|
||||
* calls it.
|
||||
*/
|
||||
export function broadcastPlaylistCreated(playlist: Playlist): void {
|
||||
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Sends `playlist_updated` to the playlist's audience. `wasPublic` is its
|
||||
* visibility before the change: a playlist that just went private still goes
|
||||
* to everyone, so that the others can drop it.
|
||||
*/
|
||||
export function broadcastPlaylistUpdated(
|
||||
playlist: Playlist,
|
||||
@@ -222,7 +261,7 @@ export function broadcastPlaylistUpdated(
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells a playlist's audience it was deleted. */
|
||||
/** Sends `playlist_deleted` to the audience the playlist had. */
|
||||
export function broadcastPlaylistDeleted(
|
||||
playlistId: string,
|
||||
userId: string,
|
||||
@@ -236,9 +275,9 @@ export function broadcastPlaylistDeleted(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Sends `playlist_dumps_updated` to the playlist's audience, each socket with
|
||||
* only the dumps its user may see, as `GET` would list them. The private dumps
|
||||
* in a public playlist stay hidden from everyone but their posters.
|
||||
*/
|
||||
export function broadcastPlaylistDumpsUpdated(
|
||||
playlist: Playlist,
|
||||
@@ -257,17 +296,23 @@ export function broadcastPlaylistDumpsUpdated(
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells everyone a user's profile changed. */
|
||||
/**
|
||||
* Sends everyone `user_updated`, after a profile edit, a role change or an
|
||||
* avatar upload.
|
||||
*/
|
||||
export function broadcastUserUpdated(user: PublicUser): void {
|
||||
broadcast({ type: "user_updated", user });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was posted. */
|
||||
/**
|
||||
* Sends everyone `comment_created`. The comments route calls it only for a
|
||||
* comment on a public dump, as it does the two below.
|
||||
*/
|
||||
export function broadcastCommentCreated(comment: Comment): void {
|
||||
broadcast({ type: "comment_created", comment });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was deleted. */
|
||||
/** Sends everyone `comment_deleted`. */
|
||||
export function broadcastCommentDeleted(
|
||||
commentId: string,
|
||||
dumpId: string,
|
||||
@@ -275,22 +320,23 @@ export function broadcastCommentDeleted(
|
||||
broadcast({ type: "comment_deleted", commentId, dumpId });
|
||||
}
|
||||
|
||||
/** Tells everyone a comment was edited. */
|
||||
/** Sends everyone `comment_updated`. */
|
||||
export function broadcastCommentUpdated(comment: Comment): void {
|
||||
broadcast({ type: "comment_updated", comment });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Sends `data` to signed-in sockets only. The chat's history needs a session,
|
||||
* so its live messages do too.
|
||||
*/
|
||||
function sendToMembers(data: ServerToClientMessage): void {
|
||||
broadcast(data, (client) => !!client.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new chat message to the members. `nonce` is the sender's id for its
|
||||
* pending copy; harmless to everyone else.
|
||||
* Sends `chat_message` to every signed-in socket, the sender's included.
|
||||
* `nonce` comes from the sender's `chat_send`, and lets it swap its pending
|
||||
* copy for the message.
|
||||
*/
|
||||
export function broadcastChatMessage(
|
||||
message: ChatMessage,
|
||||
@@ -299,27 +345,25 @@ export function broadcastChatMessage(
|
||||
sendToMembers({ type: "chat_message", message, nonce });
|
||||
}
|
||||
|
||||
/** Tells the members a chat message was edited. */
|
||||
/** Sends `chat_message_updated` to every signed-in socket. */
|
||||
export function broadcastChatMessageUpdated(message: ChatMessage): void {
|
||||
sendToMembers({ type: "chat_message_updated", message });
|
||||
}
|
||||
|
||||
/** Tells the members a chat message was deleted. */
|
||||
/** Sends `chat_message_deleted` to every signed-in socket. */
|
||||
export function broadcastChatMessageDeleted(id: string): void {
|
||||
sendToMembers({ type: "chat_message_deleted", id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* How long a `chat_typing` true lasts. It must exceed how often the chatbox
|
||||
* resends it (`TYPING_REFRESH`, 3 s), or a steady typist flickers out.
|
||||
*/
|
||||
const TYPING_TTL = 6_000;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns each user who sent `chat_typing` true within the last
|
||||
* {@link TYPING_TTL}, once, for {@link broadcastTyping}.
|
||||
*/
|
||||
export function getTypingUsers(): OnlineUser[] {
|
||||
const now = Date.now();
|
||||
@@ -340,27 +384,29 @@ export function getTypingUsers(): OnlineUser[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Sends the members the list of users typing. */
|
||||
/**
|
||||
* Sends `chat_typing_update` to every signed-in socket. The route also calls
|
||||
* it when a typing user's socket closes.
|
||||
*/
|
||||
export function broadcastTyping(): void {
|
||||
sendToMembers({ type: "chat_typing_update", users: getTypingUsers() });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Records a client's `chat_typing`, and broadcasts the typing list when the
|
||||
* client starts or stops. The route also clears it when the client sends a
|
||||
* message. A client that stops without saying so is expired after
|
||||
* {@link TYPING_TTL}.
|
||||
*/
|
||||
export function setClientTyping(client: WsClient, typing: boolean): void {
|
||||
const wasTyping = !!client.typingAt;
|
||||
client.typingAt = typing ? Date.now() : undefined;
|
||||
// Only rebroadcast on an actual edge to avoid a flood of identical updates
|
||||
// from the periodic typing pings.
|
||||
// The chatbox resends true every 3 s while typing, which changes nothing.
|
||||
if (typing !== wasTyping) broadcastTyping();
|
||||
}
|
||||
|
||||
// Sweep stale typing entries (clients that stopped pinging without a clean
|
||||
// stop) and rebroadcast when the visible set shrinks.
|
||||
// Expires the typing users who went quiet without a false, such as by closing
|
||||
// the tab.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
@@ -374,9 +420,9 @@ setInterval(() => {
|
||||
}, 2_000);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks whether `userId` is reading the chat on any of their sockets. The
|
||||
* route passes it to `notifyMentions`, which skips those users: they saw the
|
||||
* mention live, and their client chimes for it.
|
||||
*/
|
||||
export function isUserChatOpen(userId: string): boolean {
|
||||
for (const client of clients) {
|
||||
@@ -387,17 +433,16 @@ export function isUserChatOpen(userId: string): boolean {
|
||||
|
||||
export { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
||||
|
||||
/** Records that a client answered the keepalive ping. */
|
||||
/** Records a client's `pong`, which keeps its socket open. */
|
||||
export function handleClientPong(client: WsClient): void {
|
||||
client.pongReceived = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* How often every client is pinged. One that hasn't answered the previous ping
|
||||
* is disconnected.
|
||||
* is closed.
|
||||
*/
|
||||
const PING_INTERVAL = 30_000;
|
||||
/** The serialized ping. */
|
||||
const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage);
|
||||
|
||||
setInterval(() => {
|
||||
@@ -406,7 +451,7 @@ setInterval(() => {
|
||||
clients.delete(client);
|
||||
continue;
|
||||
}
|
||||
// Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle)
|
||||
// Undefined, not false, before the first ping.
|
||||
if (client.pongReceived === false) {
|
||||
client.socket.close(1001, "Ping timeout");
|
||||
clients.delete(client);
|
||||
|
||||
Reference in New Issue
Block a user