Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
BUN_VERSION: 1.3.12
BUN_VERSION: 1.3.13

jobs:
build-and-push:
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM oven/bun:1.3.12-debian AS builder
FROM oven/bun:1.3.13-debian AS builder
WORKDIR /app

# Skip the Chromium auto-download triggered by Playwright's postinstall —
Expand Down Expand Up @@ -35,14 +35,14 @@ RUN bun run build
# No copyleft. See THIRD_PARTY_LICENSES.md at the repo root for
# attribution.
# ---------------------------------------------------------------------
FROM rust:1.94-slim-bookworm AS mermaid-builder
FROM rust:1.95-slim-bookworm AS mermaid-builder
# `--locked` consumes the Cargo.lock published with the crate so the
# binary is reproducible; bumping ${MMDR_VERSION} is the only knob
# that should change the resulting bytes.
ARG MMDR_VERSION=0.2.2
RUN cargo install mermaid-rs-renderer --version ${MMDR_VERSION} --locked --root /out

FROM oven/bun:1.3.12-debian AS runner
FROM oven/bun:1.3.13-debian AS runner
WORKDIR /app

ENV NODE_ENV=production
Expand Down
6 changes: 3 additions & 3 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
"dependencies": {
"@marginalia/renderer": "workspace:*",
"@marginalia/themes": "workspace:*",
"hono": "^4.6.14",
"isomorphic-git": "^1.27.1",
"mermaid": "^11.4.1",
"hono": "^4.12.18",
"isomorphic-git": "^1.37.6",
"mermaid": "^11.14.0",
"playwright": "^1.59.1"
},
"devDependencies": {
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/anchoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@ export function reanchor(comment: CommentRow, blocks: BlockInfo[]): AnchorUpdate

// 3. prefix + quote + suffix fuzzy search — again, prefer the structurally
// closest candidate.
const context =
(comment.anchor_prefix ?? '') + quote + (comment.anchor_suffix ?? '');
const context = (comment.anchor_prefix ?? '') + quote + (comment.anchor_suffix ?? '');
if (context.length > quote.length) {
const ctxMatches = blocks
.filter((b) => b.text.includes(context))
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { extname, isAbsolute, join, normalize, relative } from 'node:path';
import type { Database } from 'bun:sqlite';
import { extname, isAbsolute, join, normalize, relative } from 'node:path';
import type { ServerWebSocket } from 'bun';
import { Hono } from 'hono';
import { createBunWebSocket } from 'hono/bun';
import { createBlobStore } from './blob-store.js';
import type { ServerConfig } from './config.js';
import { dropLegacyProposalColumns, openDatabase } from './db.js';
import { closeExportBrowser } from './export/pdf.js';
import { GitStore } from './git-store.js';
import { backfillProposalBranches } from './proposal-branch-backfill.js';
import { Realtime } from './realtime.js';
import { closeExportBrowser } from './export/pdf.js';
import { assetsRouter } from './routes/assets.js';
import { documentsRouter } from './routes/documents.js';
import { eventsRouter } from './routes/events.js';
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function authorize(

const invSession = inviteSessionToken ? readSession(db, inviteSessionToken) : null;
const validInviteSession = invSession?.doc_uid === doc.uid ? invSession : null;
const isInviteSession = !!(validInviteSession?.invite_role);
const isInviteSession = !!validInviteSession?.invite_role;

// Password gate is unconditional — invites (including admin) and invite
// sessions never bypass it. Only a password-type session satisfies it.
Expand Down Expand Up @@ -219,7 +219,13 @@ export function authorize(
}
}
const identity = clientId && resolvedName ? { clientId, displayName: resolvedName } : null;
return recordAndReturn(db, doc.uid, { ok: true, role, identity, invite: null, isInviteSession: true });
return recordAndReturn(db, doc.uid, {
ok: true,
role,
identity,
invite: null,
isInviteSession: true,
});
}

if (invite) {
Expand Down
17 changes: 7 additions & 10 deletions apps/server/src/concurrency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,13 @@ export async function mapWithConcurrency<T, R>(
}
const out: R[] = new Array(items.length);
let next = 0;
const workers = Array.from(
{ length: Math.min(Math.floor(limit), items.length) },
async () => {
while (true) {
const i = next++;
if (i >= items.length) return;
out[i] = await fn(items[i] as T, i);
}
},
);
const workers = Array.from({ length: Math.min(Math.floor(limit), items.length) }, async () => {
while (true) {
const i = next++;
if (i >= items.length) return;
out[i] = await fn(items[i] as T, i);
}
});
await Promise.all(workers);
return out;
}
8 changes: 2 additions & 6 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,7 @@ function parseBlobStorageEnv(): 'fs' | 's3' {
const normalized = raw.trim().toLowerCase();
if (normalized === '' || normalized === 'fs') return 'fs';
if (normalized === 's3') return 's3';
throw new Error(
`MARGINALIA_BLOB_STORAGE must be "fs" or "s3" (got: ${JSON.stringify(raw)}).`,
);
throw new Error(`MARGINALIA_BLOB_STORAGE must be "fs" or "s3" (got: ${JSON.stringify(raw)}).`);
}

/**
Expand All @@ -100,9 +98,7 @@ function loadS3ConfigFromEnv(): S3StorageConfig {
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) {
throw new Error(
`MARGINALIA_BLOB_STORAGE=s3 requires ${name} (see README for S3 config).`,
);
throw new Error(`MARGINALIA_BLOB_STORAGE=s3 requires ${name} (see README for S3 config).`);
}
return v;
}
2 changes: 1 addition & 1 deletion apps/server/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Database } from 'bun:sqlite';
import { dirname } from 'node:path';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';

const SCHEMA = `
CREATE TABLE IF NOT EXISTS documents (
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/export/html-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ export async function prerasterizeMermaid(
// `[\s\S]*?` lets the inner source span newlines without needing
// the `s` flag. The capture groups are: (1) the data-mermaid-index
// value, (2) the inner (escaped) source text.
const re = /<div\b[^>]*\bclass="mermaid"[^>]*\bdata-mermaid-index="(\d+)"[^>]*>([\s\S]*?)<\/div>/g;
const re =
/<div\b[^>]*\bclass="mermaid"[^>]*\bdata-mermaid-index="(\d+)"[^>]*>([\s\S]*?)<\/div>/g;
interface Hit {
start: number;
end: number;
Expand Down Expand Up @@ -293,7 +294,8 @@ export function countLiveMermaidBlocks(html: string): number {
// attribute somewhere in the tag. The lookahead doesn't anchor
// attribute order — both upstream plugins emit different orderings
// and we shouldn't depend on either.
const re = /<div\b[^>]*\bclass="[^"]*\bmermaid\b[^"]*"(?=[^>]*\bdata-mermaid-(?:index|mode)=)[^>]*>/g;
const re =
/<div\b[^>]*\bclass="[^"]*\bmermaid\b[^"]*"(?=[^>]*\bdata-mermaid-(?:index|mode)=)[^>]*>/g;
return (html.match(re) ?? []).length;
}

Expand Down Expand Up @@ -369,7 +371,7 @@ export async function inlineImageAssets(
// (e.g. `alt="logo.png image"` with `src="logo.png"` would target
// the alt text).
const hits: Hit[] = [];
const imgRe = /<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gd;
const imgRe = /<img\b[^>]*\bsrc="([^"]+)"[^>]*>/dg;
for (let m: RegExpExecArray | null; (m = imgRe.exec(html)); ) {
const src = m[1]!;
if (isAbsoluteUrl(src)) continue;
Expand Down Expand Up @@ -426,7 +428,7 @@ function toDataUrl(bytes: Uint8Array, mime: string): string {
function escapeMime(mime: string): string {
// Defensive: the attached.mime comes from the DB, but make sure a
// rogue value can't escape the data-URL grammar.
return mime.replace(/[^a-zA-Z0-9/+.\-]/g, '');
return mime.replace(/[^a-zA-Z0-9/+.-]/g, '');
}

function isAbsoluteUrl(src: string): boolean {
Expand Down
18 changes: 9 additions & 9 deletions apps/server/src/export/mermaid-chromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,6 @@
* `BrowserContext` so state never leaks across diagrams.
*/
import type { BrowserContext } from 'playwright';

import {
ALLOWED_EXPORT_HOSTS,
ExportEngineMissingError as PdfEngineMissingError,
getBrowser,
} from './pdf.js';
import { loadMermaidUmd } from './html-envelope.js';
import {
type MermaidImageFormat,
Expand All @@ -27,6 +21,11 @@ import {
MermaidRenderTimeoutError,
type RenderedMermaidImage,
} from './mermaid-rust.js';
import {
ALLOWED_EXPORT_HOSTS,
getBrowser,
ExportEngineMissingError as PdfEngineMissingError,
} from './pdf.js';

// ---------------------------------------------------------------------
// Configuration
Expand Down Expand Up @@ -63,8 +62,7 @@ let config: Config = readConfigFromEnv();

function readConfigFromEnv(): Config {
const t = process.env.MARGINALIA_MERMAID_CHROMIUM_TIMEOUT_MS;
const timeoutMs =
t && Number.isInteger(Number(t)) && Number(t) >= 100 ? Number(t) : 15_000;
const timeoutMs = t && Number.isInteger(Number(t)) && Number(t) >= 100 ? Number(t) : 15_000;
const s = process.env.MARGINALIA_MERMAID_CHROMIUM_PNG_SCALE;
const pngScale = s && Number.isFinite(Number(s)) && Number(s) > 0 ? Number(s) : 4;
return { timeoutMs, pngScale };
Expand Down Expand Up @@ -225,7 +223,9 @@ export async function renderMermaidWithChromium(
// instead of an opaque playwright one.
try {
await page.waitForFunction(
() => (window as unknown as { __marginaliaMermaidReady?: boolean }).__marginaliaMermaidReady === true,
() =>
(window as unknown as { __marginaliaMermaidReady?: boolean }).__marginaliaMermaidReady ===
true,
undefined,
{ timeout: config.timeoutMs },
);
Expand Down
11 changes: 2 additions & 9 deletions apps/server/src/export/mermaid-rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,7 @@ function buildArgv(outPath: string, format: MermaidImageFormat): string[] {
return ['-i', '-', '-o', outPath, '-e', format];
}

function runRenderer(
source: string,
outPath: string,
format: MermaidImageFormat,
): Promise<void> {
function runRenderer(source: string, outPath: string, format: MermaidImageFormat): Promise<void> {
return new Promise<void>((resolve, reject) => {
const argv = buildArgv(outPath, format);
const child = spawn(config.bin, argv, {
Expand Down Expand Up @@ -295,10 +291,7 @@ function runRenderer(
return;
}
reject(
new MermaidRenderError(
`Mermaid renderer exited with code ${code ?? '?'}`,
stderr.trim(),
),
new MermaidRenderError(`Mermaid renderer exited with code ${code ?? '?'}`, stderr.trim()),
);
});

Expand Down
45 changes: 22 additions & 23 deletions apps/server/src/export/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,7 @@ function parsePositiveIntEnv(name: string, defaultValue: number): number {
if (raw === undefined || raw.trim() === '') return defaultValue;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(
`${name} must be a positive integer; got ${JSON.stringify(raw)}`,
);
throw new Error(`${name} must be a positive integer; got ${JSON.stringify(raw)}`);
}
return parsed;
}
Expand Down Expand Up @@ -225,8 +223,7 @@ export async function getBrowser(): Promise<Browser> {
// `'chromium'` when debugging against a local full-Chrome
// install, or to `''` to let Playwright pick its default.
const channelOverride = process.env.MARGINALIA_PDF_CHANNEL;
const channel =
channelOverride === undefined ? 'chromium-headless-shell' : channelOverride;
const channel = channelOverride === undefined ? 'chromium-headless-shell' : channelOverride;
return await chromium.launch({
headless: true,
...(channel ? { channel } : {}),
Expand Down Expand Up @@ -463,26 +460,28 @@ export async function exportPdf(opts: ExportPdfOptions): Promise<Uint8Array> {
// Local asset refs never reach this layer — they were already
// inlined as `data:` URLs by inlineImageAssets() in the HTML
// envelope.
await race(page.route('**/*', (route) => {
const url = route.request().url();
if (url.startsWith('data:') || url.startsWith('about:')) {
return route.continue();
}
try {
const { protocol, hostname } = new URL(url);
// `https:` only — no cleartext HTTP even for allowlisted hosts.
// Protects against MITM-triggered content swaps and stops
// anyone with network-path access from coaxing the worker
// into a downgrade attack via a user-authored `http://…`
// asset that happens to resolve to an allowlist hostname.
if (protocol === 'https:' && ALLOWED_EXPORT_HOSTS.has(hostname)) {
await race(
page.route('**/*', (route) => {
const url = route.request().url();
if (url.startsWith('data:') || url.startsWith('about:')) {
return route.continue();
}
} catch {
// Unparseable URL — fall through to abort.
}
return route.abort('blockedbyclient');
}));
try {
const { protocol, hostname } = new URL(url);
// `https:` only — no cleartext HTTP even for allowlisted hosts.
// Protects against MITM-triggered content swaps and stops
// anyone with network-path access from coaxing the worker
// into a downgrade attack via a user-authored `http://…`
// asset that happens to resolve to an allowlist hostname.
if (protocol === 'https:' && ALLOWED_EXPORT_HOSTS.has(hostname)) {
return route.continue();
}
} catch {
// Unparseable URL — fall through to abort.
}
return route.abort('blockedbyclient');
}),
);

// Conditional spread — `mermaidUmd` is only present on the object
// when we actually loaded it, so `exactOptionalPropertyTypes`
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/export/theme-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url';
* `.exec()` loops would read / reset each other's `lastIndex` and
* miss imports). Constructing per call is the defensive fix.
*/
const RELATIVE_IMPORT_PATTERN = "@import\\s+(['\"])((?:\\.{1,2}/)[^'\"]+?)\\1\\s*;";
const RELATIVE_IMPORT_PATTERN = '@import\\s+([\'"])((?:\\.{1,2}/)[^\'"]+?)\\1\\s*;';

/** In-memory cache of resolved theme CSS keyed by theme name. Flipped
* per-process: the themes package only changes on deploy. */
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/git-store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import {
import fs, {
existsSync,
mkdirSync,
mkdtempSync,
Expand Down
9 changes: 5 additions & 4 deletions apps/server/src/proposal-branch-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ export async function backfillProposalBranches(
}
const rows = (
docUid === undefined
? db.prepare(
`SELECT cep.comment_id AS id,
? db
.prepare(
`SELECT cep.comment_id AS id,
c.doc_uid AS doc_uid,
c.anchor_block_id AS anchor_block_id,
c.author_client_id AS author_client_id,
Expand All @@ -45,7 +46,8 @@ export async function backfillProposalBranches(
AND cep.branch_ref IS NULL
AND c.deleted_at IS NULL
AND c.anchor_block_id IS NOT NULL`,
).all()
)
.all()
: db
.prepare(
`SELECT cep.comment_id AS id,
Expand Down Expand Up @@ -195,4 +197,3 @@ export async function backfillProposalBranches(

return { migrated, skipped };
}

Loading