v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Middleware gating routes on authentication, the admin role, or a
|
||||
* permission.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
type Next,
|
||||
type RouteParams,
|
||||
@@ -11,18 +18,28 @@ import type { AuthPayload } from "../model/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import type { Permission } from "../../shared/permissions.ts";
|
||||
|
||||
/** The request state once {@link authMiddleware} has run. */
|
||||
export interface AuthState extends State {
|
||||
/** The authenticated caller, with their live role. */
|
||||
user: AuthPayload;
|
||||
}
|
||||
|
||||
// A router context whose state is guaranteed to carry the authenticated user
|
||||
// (populated by authMiddleware). Generic over the route path so `ctx.params`
|
||||
// stays typed per-route — e.g. `AuthContext<"/:userId/role">`.
|
||||
/**
|
||||
* A router context whose state is guaranteed to carry the authenticated user
|
||||
* (populated by {@link authMiddleware}). Generic over the route path so
|
||||
* `ctx.params` stays typed per-route — e.g. `AuthContext<"/:userId/role">`.
|
||||
*/
|
||||
export type AuthContext<
|
||||
R extends string = string,
|
||||
P extends RouteParams<R> = RouteParams<R>,
|
||||
> = RouterContext<R, P, AuthState>;
|
||||
|
||||
/**
|
||||
* Requires a valid session token in the `Authorization` header and puts its
|
||||
* caller in `ctx.state.user`.
|
||||
*
|
||||
* @throws {APIException} 401 when the token is missing or invalid.
|
||||
*/
|
||||
export async function authMiddleware<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
@@ -51,6 +68,11 @@ export async function authMiddleware<R extends string>(
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the caller to be an admin. Must run after {@link authMiddleware}.
|
||||
*
|
||||
* @throws {APIException} 403 when the caller isn't an admin.
|
||||
*/
|
||||
export async function adminOnlyMiddleware<R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
@@ -66,9 +88,15 @@ export async function adminOnlyMiddleware<R extends string>(
|
||||
await next();
|
||||
}
|
||||
|
||||
// Gates a route on a named permission. Must run after authMiddleware. Generic
|
||||
// over the route path so it composes with path-typed route handlers.
|
||||
export function requirePermission(permission: Permission) {
|
||||
/**
|
||||
* Returns a middleware requiring the caller to hold `permission`, which throws
|
||||
* an {@link APIException} (403) when they don't. Must run after
|
||||
* {@link authMiddleware}. Generic over the route path so it composes with
|
||||
* path-typed route handlers.
|
||||
*/
|
||||
export function requirePermission(
|
||||
permission: Permission,
|
||||
): <R extends string>(ctx: AuthContext<R>, next: Next) => Promise<void> {
|
||||
return async <R extends string>(
|
||||
ctx: AuthContext<R>,
|
||||
next: Next,
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
/**
|
||||
* Turns errors thrown while handling a request into `APIFailure` responses.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Context, Next } from "@oak/oak";
|
||||
|
||||
import { APIErrorCode, APIFailure } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
|
||||
export async function errorMiddleware(ctx: Context, next: Next) {
|
||||
/**
|
||||
* Answers an {@link APIException} with its status and code, and anything else
|
||||
* with a 500 `SERVER_ERROR`, logging both.
|
||||
*/
|
||||
export async function errorMiddleware(
|
||||
ctx: Context,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/**
|
||||
* Open Graph and Twitter card tags for shared links: a dump, user or public
|
||||
* playlist page is answered with `index.html` carrying that page's title,
|
||||
* description and image, so link unfurlers that don't run the app see them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import { getUserByUsername } from "../services/user-service.ts";
|
||||
import { getPlaylistById } from "../services/playlist-service.ts";
|
||||
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
|
||||
import { emojiToCodepoint } from "../lib/site-icons.ts";
|
||||
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
import { OG_SITE_NAME } from "../config.ts";
|
||||
import { loadIndexHtml } from "../lib/index-html.ts";
|
||||
|
||||
interface OGMeta {
|
||||
title: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
/** True only for real artwork — a square site icon must not claim a wide card. */
|
||||
/**
|
||||
* True only for real artwork — a square site icon must not claim a wide
|
||||
* card.
|
||||
*/
|
||||
imageIsWide?: boolean;
|
||||
url: string;
|
||||
}
|
||||
@@ -58,34 +67,34 @@ function inject(html: string, meta: OGMeta): string {
|
||||
.replace("</head>", ` ${buildTags(meta)}\n </head>`);
|
||||
}
|
||||
|
||||
let cachedHtml: string | null | undefined; // undefined = not yet loaded, null = not found
|
||||
|
||||
async function loadIndexHtml(): Promise<string | null> {
|
||||
if (cachedHtml !== undefined) return cachedHtml;
|
||||
for (
|
||||
const path of [`${Deno.cwd()}/dist/index.html`, `${Deno.cwd()}/index.html`]
|
||||
) {
|
||||
/**
|
||||
* Returns the filled `index.html` of the build, or of the working directory
|
||||
* when there is no build; null when there is neither.
|
||||
*/
|
||||
async function findIndexHtml(): Promise<string | null> {
|
||||
for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) {
|
||||
try {
|
||||
cachedHtml = (await Deno.readTextFile(path))
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
||||
return cachedHtml;
|
||||
return await loadIndexHtml(root);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cachedHtml = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||
// Allow an optional `/~/<tab>` suffix so linkable profile tabs keep OG meta.
|
||||
/** A profile, with an optional `/~/<tab>` suffix so its tabs keep OG meta. */
|
||||
const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/;
|
||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||
|
||||
export async function ogMiddleware(ctx: Context, next: Next) {
|
||||
/**
|
||||
* Serves `index.html` with OG tags for a dump, user or public playlist page
|
||||
* that exists, and passes every other request on.
|
||||
*/
|
||||
export async function ogMiddleware(
|
||||
ctx: Context,
|
||||
next: Next,
|
||||
): Promise<unknown> {
|
||||
const { pathname } = ctx.request.url;
|
||||
|
||||
if (pathname.startsWith("/api/") || pathname.includes(".")) {
|
||||
@@ -153,7 +162,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
||||
|
||||
if (!meta) return await next();
|
||||
|
||||
const html = await loadIndexHtml();
|
||||
const html = await findIndexHtml();
|
||||
if (!html) return await next();
|
||||
|
||||
ctx.response.headers.set("Content-Type", "text/html; charset=utf-8");
|
||||
|
||||
Reference in New Issue
Block a user