Public: anyone can read this; the crew can change it.
A font file (ttf, otf, woff, woff2) in a repository renders as a specimen #10374
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10282-a-font-file-ttf-otf-woff-woff2-in-a-repo → main 5 commits fork 3 threads · 3 unresolved
11 files
changed +313
−3
docs/design/areas/code.md +2
| @@ -10,3 +10,5 @@ | ||
| 10 | 10 | - `.card.bare` — a card with its frame taken off (`card({ extra: "bare" })`): no border, shadow, fill or padding, no rules between rows, head and foot as plain lines. The repository page's file listing, README, Following panel and fork line use it; the markup, and so every hook, stays the same. |
| 11 | 11 | |
| 12 | 12 | - `.moved-from` (#10109) — the one muted line "Moved from <old path> on <date>" under a repository head for 30 days after a move; the path wraps anywhere, so no sideways scroll at 390px. |
| 13 | ||
| 14 | A font specimen (`render/font.ts`): `.font-view` sits in a bare card (`card({ extra: "bare" })`, no box of its own); `.font-name` its family heading, `.font-line` one line of sample text, and `.font-s`, `.font-m`, `.font-l` its three sizes. The small size is 14px; the larger two are display sizes, off the UI scale on purpose. |
docs/features/font.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # Font specimens | |
| 2 | ||
| 3 | Open a `.ttf`, `.otf`, `.woff` or `.woff2` file in a repository and the blob page draws a specimen (`src/web/render/font.ts`): the family name, then the alphabet, digits and a pangram at three sizes, set in the font itself. Detection is by magic bytes, so a renamed non-font stays an ordinary file. | |
| 4 | ||
| 5 | The family comes from the `name` table of a TrueType/OpenType file (bounds-checked, escaped); a WOFF file compresses its tables, so it is named by its file name. A small script loads the font through `FontFace` from the same-origin media route; without script the specimen shows in the page's own face. Only this page's policy gains `font-src 'self'` (never `data:`), on first and kept renders alike. `?view=source`, Raw and Download show the bytes untouched. | |
| 6 | ||
| 7 | Terminal (SSH): there is no screen for it. A terminal draws in its own fixed face and cannot load a font, so a specimen would show nothing the file view does not; the file view lists it as a binary file with its size. |
src/web/layout.ts +7 −1
| @@ -150,6 +150,12 @@ export interface PageOptions { | ||
| 150 | 150 | * blob page of a PDF asks; no other page's policy changes. |
| 151 | 151 | */ |
| 152 | 152 | objectSrc?: boolean; |
| 153 | /** | |
| 154 | * The page sets a font file in the font itself (`render/font.ts`): the | |
| 155 | * browser may load a font from this origin (`font-src 'self'`). Only the | |
| 156 | * blob page of a font asks; no other page's policy changes. | |
| 157 | */ | |
| 158 | fontSrc?: boolean; | |
| 153 | 159 | /** |
| 154 | 160 | * The page carries a Cloudflare Turnstile widget (`/signup`): its script, |
| 155 | 161 | * frame and verification call may come from challenges.cloudflare.com, and |
| @@ -882,7 +888,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 882 | 888 | // The hashed stylesheet and scripts are 'self'; the two inline <style>s |
| 883 | 889 | // are named by hash. connect-src 'self' lets the live event stream |
| 884 | 890 | // (EventSource) and fragment fetches reach same-origin. |
| 885 | "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.wasm ? " 'wasm-unsafe-eval'" : ""}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${frameSources(options)}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`, | |
| 891 | "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.wasm ? " 'wasm-unsafe-eval'" : ""}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${frameSources(options)}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}${options.fontSrc ? "font-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`, | |
| 886 | 892 | "referrer-policy": "same-origin", |
| 887 | 893 | "x-content-type-options": "nosniff", |
| 888 | 894 | // Markup is revalidated every time; only the hashed assets it names are |
src/web/pages/repo.ts +17 −2
| @@ -86,6 +86,7 @@ import { flashTo, fragment, page, redirect } from "../layout.ts"; | ||
| 86 | 86 | import { renderMarkdown } from "../markdown.ts"; |
| 87 | 87 | import { overSize } from "../render/archive.ts"; |
| 88 | 88 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| 89 | import { fontJs } from "../render/font.ts"; | |
| 89 | 90 | import { hasTable } from "../render/sqlite.ts"; |
| 90 | 91 | import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts"; |
| 91 | 92 | import { RENDERERS, rendererFor } from "../render/renderers.ts"; |
| @@ -1113,6 +1114,15 @@ export function drawsPdf(scope: RepoScope, path: string, blob: Blob, view: BlobV | ||
| 1113 | 1114 | ); |
| 1114 | 1115 | } |
| 1115 | 1116 | |
| 1117 | /** Whether the blob page of this file sets a font in itself, and so needs `font-src`. */ | |
| 1118 | export function drawsFont(scope: RepoScope, path: string, blob: Blob, view: BlobView): boolean { | |
| 1119 | return ( | |
| 1120 | view !== "source" && | |
| 1121 | RENDERERS.font !== undefined && | |
| 1122 | blobRenderer(scope, path, blob)?.render === RENDERERS.font | |
| 1123 | ); | |
| 1124 | } | |
| 1125 | ||
| 1116 | 1126 | /** |
| 1117 | 1127 | * One file, framed by the same path header the tree uses. The header is |
| 1118 | 1128 | * sticky and the whole pane is one element, because a script swapping a |
| @@ -1499,6 +1509,8 @@ export function codePage( | ||
| 1499 | 1509 | before?: Html; |
| 1500 | 1510 | /** The page draws a PDF in an `<object>` (`PageOptions.objectSrc`). */ |
| 1501 | 1511 | objectSrc?: boolean; |
| 1512 | /** The page sets a font in itself (`PageOptions.fontSrc`). */ | |
| 1513 | fontSrc?: boolean; | |
| 1502 | 1514 | /** Script only some files need (the CSV table's sort and filter). */ |
| 1503 | 1515 | extraJs?: string; |
| 1504 | 1516 | }, |
| @@ -1507,6 +1519,7 @@ export function codePage( | ||
| 1507 | 1519 | ctx, |
| 1508 | 1520 | title: o.title, |
| 1509 | 1521 | ...(o.objectSrc ? { objectSrc: true } : {}), |
| 1522 | ...(o.fontSrc ? { fontSrc: true } : {}), | |
| 1510 | 1523 | crumbs: scope.crumbs, |
| 1511 | 1524 | palette: scope.palette, |
| 1512 | 1525 | body: html`${o.header ?? scopeHeader(ctx, scope, "code", { compact: true })} |
| @@ -1669,7 +1682,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1669 | 1682 | explorer: hit.explorer, |
| 1670 | 1683 | content: hit.content, |
| 1671 | 1684 | objectSrc: hit.objectSrc, |
| 1672 | ...(hit.csv ? { extraJs: csvJs } : {}), | |
| 1685 | ...(hit.font ? { fontSrc: true, extraJs: fontJs } : hit.csv ? { extraJs: csvJs } : {}), | |
| 1673 | 1686 | })), |
| 1674 | 1687 | ); |
| 1675 | 1688 | } |
| @@ -1727,12 +1740,14 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1727 | 1740 | ), |
| 1728 | 1741 | }); |
| 1729 | 1742 | const objectSrc = drawsPdf(scope, path, shown, view); |
| 1743 | const font = drawsFont(scope, path, shown, view); | |
| 1730 | 1744 | if (kept && keepable) |
| 1731 | 1745 | keepBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all, { |
| 1732 | 1746 | title: `${filename} · ${repo.name}`, |
| 1733 | 1747 | explorer: explorerHtml, |
| 1734 | 1748 | content: pane, |
| 1735 | 1749 | objectSrc, |
| 1750 | font, | |
| 1736 | 1751 | csv: isCsvPath(path), |
| 1737 | 1752 | }); |
| 1738 | 1753 | const response = await keepFinished(codePage(ctx, scope, { |
| @@ -1741,7 +1756,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1741 | 1756 | explorer: explorerHtml, |
| 1742 | 1757 | content: pane, |
| 1743 | 1758 | objectSrc, |
| 1744 | ...(isCsvPath(path) ? { extraJs: csvJs } : {}), | |
| 1759 | ...(font ? { fontSrc: true, extraJs: fontJs } : isCsvPath(path) ? { extraJs: csvJs } : {}), | |
| 1745 | 1760 | })); |
| 1746 | 1761 | notePhase(ctx.request, "render", paneMs + performance.now() - pageStart); |
| 1747 | 1762 | return immutableWhenAddressed(ctx.request, ctx.auth.user === undefined, repo, ref, response); |
src/web/pages/tree_cache.ts +1
| @@ -114,6 +114,7 @@ export interface RenderedBlob extends RenderedTree { | ||
| 114 | 114 | readonly title: string; |
| 115 | 115 | readonly objectSrc: boolean; |
| 116 | 116 | readonly csv: boolean; |
| 117 | readonly font: boolean; | |
| 117 | 118 | } |
| 118 | 119 | |
| 119 | 120 | const blobKey = (repoId: string, commit: string, path: string, view: string, all: boolean): string => |
src/web/render/font.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * A font file drawn as a specimen: its family name, then the alphabet, the | |
| 3 | * digits and one pangram at three sizes, set in the font itself. | |
| 4 | * | |
| 5 | * The family comes from the `name` table of a TrueType or OpenType file; a | |
| 6 | * WOFF file compresses its tables, so it is named by its file name. The font | |
| 7 | * is loaded by `fontJs` (a `FontFace` from the same-origin media route), so | |
| 8 | * the page's policy gains `font-src 'self'` and nothing else (`layout.ts` | |
| 9 | * `fontSrc`); never a `data:` URL. Without script the specimen shows in the | |
| 10 | * page's own face, still named and sized. | |
| 11 | */ | |
| 12 | ||
| 13 | import { html } from "../html.ts"; | |
| 14 | import { card, fileSize } from "../ui.ts"; | |
| 15 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 16 | ||
| 17 | const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz"; | |
| 18 | const DIGITS = "0123456789 .,;:!?&@#%()[]{}"; | |
| 19 | const PANGRAM = "Sphinx of black quartz, judge my vow."; | |
| 20 | ||
| 21 | const u16 = (v: DataView, at: number): number => v.getUint16(at, false); | |
| 22 | ||
| 23 | /** | |
| 24 | * The family name of a TrueType/OpenType file, or undefined when the bytes | |
| 25 | * do not hold one. Every offset is bounds-checked: the file is untrusted. | |
| 26 | */ | |
| 27 | export function fontFamily(body: Uint8Array): string | undefined { | |
| 28 | if (body.length < 12) return undefined; | |
| 29 | const v = new DataView(body.buffer, body.byteOffset, body.byteLength); | |
| 30 | const tables = Math.min(u16(v, 4), 128); | |
| 31 | for (let i = 0; i < tables; i++) { | |
| 32 | const rec = 12 + i * 16; | |
| 33 | if (rec + 16 > body.length) return undefined; | |
| 34 | if (String.fromCharCode(...body.subarray(rec, rec + 4)) !== "name") continue; | |
| 35 | const base = v.getUint32(rec + 8, false); | |
| 36 | const length = v.getUint32(rec + 12, false); | |
| 37 | if (base + 6 > body.length || length < 6) return undefined; | |
| 38 | const count = Math.min(u16(v, base + 2), 2048); | |
| 39 | const strings = base + u16(v, base + 4); | |
| 40 | let best: { rank: number; text: string } | undefined; | |
| 41 | for (let n = 0; n < count; n++) { | |
| 42 | const r = base + 6 + n * 12; | |
| 43 | if (r + 12 > body.length) break; | |
| 44 | const platform = u16(v, r); | |
| 45 | const id = u16(v, r + 6); | |
| 46 | const len = u16(v, r + 8); | |
| 47 | const from = strings + u16(v, r + 10); | |
| 48 | if ((id !== 16 && id !== 1) || from + len > body.length || len === 0) continue; | |
| 49 | let text: string; | |
| 50 | if (platform === 3 || platform === 0) { | |
| 51 | if (len % 2 !== 0) continue; | |
| 52 | text = ""; | |
| 53 | for (let o = from; o < from + len; o += 2) text += String.fromCharCode(u16(v, o)); | |
| 54 | } else if (platform === 1) { | |
| 55 | text = String.fromCharCode(...body.subarray(from, from + len)); | |
| 56 | } else continue; | |
| 57 | text = text.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 100); | |
| 58 | if (text === "") continue; | |
| 59 | // The typographic family (16) over the legacy one (1); Windows over Mac. | |
| 60 | const rank = (id === 16 ? 2 : 0) + (platform === 1 ? 0 : 1); | |
| 61 | if (best === undefined || rank > best.rank) best = { rank, text }; | |
| 62 | } | |
| 63 | return best?.text; | |
| 64 | } | |
| 65 | return undefined; | |
| 66 | } | |
| 67 | ||
| 68 | /** The file name without directory or extension, for a font that names no family. */ | |
| 69 | const stem = (path: string): string => { | |
| 70 | const name = path.slice(path.lastIndexOf("/") + 1); | |
| 71 | const dot = name.lastIndexOf("."); | |
| 72 | return dot > 0 ? name.slice(0, dot) : name; | |
| 73 | }; | |
| 74 | ||
| 75 | export function font(input: RenderInput): Rendered { | |
| 76 | const isSfnt = input.body.length >= 4 && !/^(wOFF|wOF2)/.test(String.fromCharCode(...input.body.subarray(0, 4))); | |
| 77 | const family = (isSfnt ? fontFamily(input.body) : undefined) ?? stem(input.path); | |
| 78 | const line = (size: string, text: string) => html`<p class="font-line font-${size}">${text}</p>`; | |
| 79 | return { | |
| 80 | lines: 0, | |
| 81 | summary: `${fileSize(input.size)} · font`, | |
| 82 | content: card(html`<div class="font-view" data-font="${input.media}"> | |
outdated
Principles 6 and 7: a bordered, shadowed card around the specimen is a box around a thing. Use card({ extra: "bare" }) as the rest of the code area does, and keep the rows as plain lines. |
||
| 83 | <h2 class="font-name">${family}</h2> | |
| 84 | ${line("s", ALPHABET)} ${line("s", DIGITS)} ${line("s", PANGRAM)} | |
| 85 | ${line("m", ALPHABET)} ${line("m", DIGITS)} ${line("m", PANGRAM)} | |
| 86 | ${line("l", "Aa Bb Cc 123")} ${line("l", PANGRAM)} | |
| 87 | </div>`, { extra: "bare", pad: false }), | |
| 88 | }; | |
| 89 | } | |
| 90 | ||
| 91 | /** Loads the font from the media route and lets the specimen use it. Trusted static code. */ | |
| 92 | export const fontJs = /* js */ ` | |
| 93 | (function () { | |
| 94 | var box = document.querySelector('.font-view[data-font]'); | |
| 95 | if (!box || !window.FontFace) return; | |
| 96 | var face = new FontFace('ForgeSpecimen', 'url(' + JSON.stringify(box.getAttribute('data-font')) + ')'); | |
| 97 | face.load().then(function () { | |
| 98 | document.fonts.add(face); | |
| 99 | box.classList.add('font-ready'); | |
| 100 | }, function () {}); | |
| 101 | })(); | |
| 102 | `; |
src/web/render/kinds.ts +6
| @@ -22,6 +22,7 @@ export type FileKind = | ||
| 22 | 22 | | "sqlite" |
| 23 | 23 | | "model3d" |
| 24 | 24 | | "archive" |
| 25 | | "font" | |
| 25 | 26 | | "config" |
| 26 | 27 | | "markdown" |
| 27 | 28 | | "log" |
| @@ -47,6 +48,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 47 | 48 | sqlite: 5 * MB, |
| 48 | 49 | model3d: 100 * MB, |
| 49 | 50 | archive: 50 * MB, |
| 51 | font: 20 * MB, | |
| 50 | 52 | config: 1 * MB, |
| 51 | 53 | markdown: 1 * MB, |
| 52 | 54 | log: 1 * MB, |
| @@ -126,6 +128,10 @@ const MEDIA: Readonly<Record<string, Media>> = { | ||
| 126 | 128 | sqlite: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") }, |
| 127 | 129 | sqlite3: { kind: "sqlite", name: "SQLite", type: "application/vnd.sqlite3", magic: ascii("SQLite format 3\0") }, |
| 128 | 130 | stl: { kind: "model3d", name: "STL", type: "model/stl" }, |
| 131 | ttf: { kind: "font", name: "TrueType", type: "font/ttf", magic: bytes(0x00, 0x01, 0x00, 0x00) }, | |
| 132 | otf: { kind: "font", name: "OpenType", type: "font/otf", magic: ascii("OTTO") }, | |
| 133 | woff: { kind: "font", name: "WOFF", type: "font/woff", magic: ascii("wOFF") }, | |
| 134 | woff2: { kind: "font", name: "WOFF2", type: "font/woff2", magic: ascii("wOF2") }, | |
| 129 | 135 | glb: { kind: "model3d", name: "glTF binary", type: "model/gltf-binary", magic: ascii("glTF") }, |
| 130 | 136 | }; |
| 131 | 137 |
src/web/render/renderers.ts +2
| @@ -11,6 +11,7 @@ import { audio } from "./audio.ts"; | ||
| 11 | 11 | import { config } from "./config.ts"; |
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | 13 | import { csv } from "./csv.ts"; |
| 14 | import { font } from "./font.ts"; | |
| 14 | 15 | import { image } from "./image.ts"; |
| 15 | 16 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 16 | 17 | import { log, hasEscape } from "./log.ts"; |
| @@ -65,6 +66,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 65 | 66 | |
| 66 | 67 | subtitles, |
| 67 | 68 | archive, |
| 69 | font, | |
| 68 | 70 | }; |
| 69 | 71 | |
| 70 | 72 | /** |
src/web/style/content.ts +8
| @@ -475,6 +475,14 @@ form.inline { display: inline; margin-left: auto; } | ||
| 475 | 475 | /* A phone: a shorter frame, so a thumb can scroll the page past it. */ |
| 476 | 476 | @media (max-width: 600px) { .pdf-frame { height: 60vh; min-height: 360px; } } |
| 477 | 477 | |
| 478 | /* A font specimen: set in the font itself once the script has loaded it from | |
| 479 | the media route; until then (or without script) in the page's own face. */ | |
| 480 | .font-view { padding: 16px 20px; } | |
| 481 | .font-name { margin: 0 0 12px; font-size: 1.75rem; line-height: 1.3; overflow-wrap: anywhere; } | |
| 482 | .font-line { margin: 0 0 10px; line-height: 1.4; overflow-wrap: anywhere; } | |
| 483 | .font-s { font-size: 14px; } .font-m { font-size: 1.5rem; } .font-l { font-size: 3rem; line-height: 1.2; } | |
| 484 | .font-view.font-ready .font-name, .font-view.font-ready .font-line { font-family: "ForgeSpecimen", sans-serif; } | |
| 485 | ||
| 478 | 486 | /* ---- image view ---- */ |
| 479 | 487 | .imgview-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 16px; font-size: 12.5px; border-bottom: 1px solid var(--border); } |
| 480 | 488 | .imgview-dim { min-width: 9ch; } |
test/render-font.test.ts added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | /** A font file on the blob page: kind, family name, specimen, and a CSP that only it relaxes. */ | |
Add a second-request (kept page) assertion for font-src and the script, and docs/features/font.md with the how-to. The terminal door (src/tui/) is also missing. |
||
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { fileKind } from "../src/web/render/kinds.ts"; | |
| 11 | import { fontFamily } from "../src/web/render/font.ts"; | |
| 12 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | /** A minimal sfnt: one `name` table holding a Windows (UTF-16BE) family name. */ | |
| 18 | function sfnt(magic: number[], family: string): Uint8Array { | |
| 19 | const text = new Uint8Array(family.length * 2); | |
| 20 | for (let i = 0; i < family.length; i++) text[i * 2 + 1] = family.charCodeAt(i); | |
| 21 | const table = new Uint8Array(6 + 12 + text.length); | |
| 22 | const t = new DataView(table.buffer); | |
| 23 | t.setUint16(2, 1); // one record | |
| 24 | t.setUint16(4, 18); // string storage offset | |
| 25 | t.setUint16(6, 3); // platform: Windows | |
| 26 | t.setUint16(8, 1); | |
| 27 | t.setUint16(10, 0x409); | |
| 28 | t.setUint16(12, 1); // nameID 1: family | |
| 29 | t.setUint16(14, text.length); | |
| 30 | t.setUint16(16, 0); | |
| 31 | table.set(text, 18); | |
| 32 | const out = new Uint8Array(12 + 16 + table.length); | |
| 33 | out.set(magic, 0); | |
| 34 | const v = new DataView(out.buffer); | |
| 35 | v.setUint16(4, 1); | |
| 36 | out.set(new TextEncoder().encode("name"), 12); | |
| 37 | v.setUint32(20, 28); | |
| 38 | v.setUint32(24, table.length); | |
| 39 | out.set(table, 28); | |
| 40 | return out; | |
| 41 | } | |
| 42 | ||
| 43 | const TTF = sfnt([0, 1, 0, 0], "Forge <Sans>"); | |
| 44 | const OTF = sfnt([0x4f, 0x54, 0x54, 0x4f], "Forge Serif"); | |
| 45 | const WOFF = new Uint8Array([0x77, 0x4f, 0x46, 0x46, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]); | |
| 46 | const FAKE = new TextEncoder().encode("this is plainly not a font\n"); | |
| 47 | ||
| 48 | describe("font kind and family", () => { | |
| 49 | test("detected by magic bytes, and a renamed non-font is not a font", () => { | |
| 50 | expect(fileKind("a.ttf", TTF)).toBe("font"); | |
| 51 | expect(fileKind("a.otf", OTF)).toBe("font"); | |
| 52 | expect(fileKind("a.woff", WOFF)).toBe("font"); | |
| 53 | expect(fileKind("a.woff2", new TextEncoder().encode("wOF2\0\0"))).toBe("font"); | |
| 54 | expect(fileKind("fake.ttf", FAKE)).toBe("text"); | |
| 55 | expect(fileKind("fake.ttf", new Uint8Array([1, 2, 0, 3]))).toBe("binary"); | |
| 56 | expect(fileKind("a.otf", TTF)).not.toBe("font"); | |
| 57 | }); | |
| 58 | ||
| 59 | test("the family is read from the name table", () => { | |
| 60 | expect(fontFamily(TTF)).toBe("Forge <Sans>"); | |
| 61 | expect(fontFamily(OTF)).toBe("Forge Serif"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a truncated or hostile table is no family, not a crash", () => { | |
| 65 | expect(fontFamily(TTF.subarray(0, 30))).toBeUndefined(); | |
| 66 | const bad = TTF.slice(); | |
| 67 | new DataView(bad.buffer).setUint32(20, 0xfffffff0); | |
| 68 | expect(fontFamily(bad)).toBeUndefined(); | |
| 69 | expect(fontFamily(new Uint8Array(3))).toBeUndefined(); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | let dataDir = ""; | |
| 74 | let app: App; | |
| 75 | let servers: Servers; | |
| 76 | ||
| 77 | beforeAll(async () => { | |
| 78 | dataDir = await mkdtemp(join(tmpdir(), "forge-font-")); | |
| 79 | app = createApp({ dataDir, port: 0 }); | |
| 80 | servers = await serve(app); | |
| 81 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-font-passphrase" }); | |
| 82 | if (!a.ok) throw new Error("signup failed"); | |
| 83 | const repo = await createRepo(app, a.value, { name: "type", visibility: "public" }); | |
| 84 | if (!repo.ok) throw new Error("repo failed"); | |
| 85 | const made = await commitToBranch(app, a.value, repo.value, { | |
| 86 | message: "fonts", | |
| 87 | files: [ | |
| 88 | { path: "a.ttf", content: TTF }, | |
| 89 | { path: "b.woff", content: WOFF }, | |
| 90 | { path: "fake.ttf", content: FAKE }, | |
| 91 | { path: "n.txt", content: new TextEncoder().encode("hi\n") }, | |
| 92 | ], | |
| 93 | }); | |
| 94 | if (!made.ok) throw new Error("commit failed"); | |
| 95 | }); | |
| 96 | ||
| 97 | afterAll(async () => { | |
| 98 | await servers.stop(); | |
| 99 | app.close(); | |
| 100 | await rm(dataDir, { recursive: true, force: true }); | |
| 101 | }); | |
| 102 | ||
| 103 | const get = (path: string) => fetch(`${servers.url}/alice/type/${path}`); | |
| 104 | ||
| 105 | describe("font blob pages", () => { | |
| 106 | test("a ttf is a specimen named by its family, escaped, with script and font-src", async () => { | |
| 107 | const res = await get("blob/main/a.ttf"); | |
| 108 | const html = (await res.text()).replace(/\s+/g, " "); | |
| 109 | expect(html).toContain('<h2 class="font-name">Forge <Sans></h2>'); | |
| 110 | expect(html).toContain('data-font="/alice/type/media/main/a.ttf"'); | |
| 111 | expect(html).toContain("Sphinx of black quartz"); | |
| 112 | for (const size of ["font-s", "font-m", "font-l"]) expect(html).toContain(size); | |
| 113 | const csp = res.headers.get("content-security-policy")!; | |
| 114 | expect(csp).toContain("font-src 'self';"); | |
| 115 | expect(csp).not.toMatch(/font-src[^;]*data:/); | |
| 116 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the kept page, served again, still has font-src and the script", async () => { | |
| 120 | const first = await get("blob/main/a.ttf"); | |
| 121 | await first.text(); | |
| 122 | const res = await get("blob/main/a.ttf"); | |
| 123 | const html = await res.text(); | |
| 124 | expect(res.headers.get("content-security-policy")).toContain("font-src 'self';"); | |
| 125 | expect(html).toContain('data-font="/alice/type/media/main/a.ttf"'); | |
| 126 | // The script is a hashed asset; the kept page must name the one that draws the specimen. | |
| 127 | const src = /<script src="(\/assets\/page-[^"]+\.js)"/.exec(html)?.[1]; | |
| 128 | expect(src).toBeDefined(); | |
| 129 | expect(await (await fetch(`${servers.url}${src}`)).text()).toContain("FontFace"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a woff is named by its file name", async () => { | |
| 133 | const html = await (await get("blob/main/b.woff")).text(); | |
| 134 | expect(html).toContain('<h2 class="font-name">b</h2>'); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the bytes are served as a font type", async () => { | |
| 138 | const res = await get("media/main/a.ttf"); | |
| 139 | expect(res.headers.get("content-type")).toBe("font/ttf"); | |
| 140 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a renamed non-font is plain source and its media is octet-stream", async () => { | |
| 144 | const res = await get("blob/main/fake.ttf"); | |
| 145 | const html = await res.text(); | |
| 146 | expect(html).not.toContain("font-view"); | |
| 147 | expect(res.headers.get("content-security-policy")).not.toContain("font-src"); | |
| 148 | expect((await get("media/main/fake.ttf")).headers.get("content-type")).toBe("application/octet-stream"); | |
| 149 | }); | |
| 150 | ||
| 151 | test("no other page gains font-src, the source view included", async () => { | |
| 152 | for (const path of ["blob/main/n.txt", "blob/main/a.ttf?view=source", ""]) { | |
| 153 | const csp = (await get(path)).headers.get("content-security-policy")!; | |
| 154 | expect(csp, path).not.toContain("font-src"); | |
| 155 | } | |
| 156 | }); | |
| 157 | }); |
test/render-kinds.test.ts +4
| @@ -52,6 +52,10 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 52 | 52 | toml: [enc("a = 1\n"), "config", "application/octet-stream"], |
| 53 | 53 | ini: [enc("[a]\n"), "config", "application/octet-stream"], |
| 54 | 54 | conf: [enc("a 1\n"), "config", "application/octet-stream"], |
| 55 | ttf: [of(0, 1, 0, 0, 0, 0), "font", "font/ttf"], | |
| 56 | otf: [enc("OTTO\0\0"), "font", "font/otf"], | |
| 57 | woff: [enc("wOFF\0\0"), "font", "font/woff"], | |
| 58 | woff2: [enc("wOF2\0\0"), "font", "font/woff2"], | |
| 55 | 59 | zip: [of(0x50, 0x4b, 3, 4, 0), "archive", "application/octet-stream"], |
| 56 | 60 | tar: [enc("x".repeat(257) + "ustar\0"), "archive", "application/octet-stream"], |
| 57 | 61 | tgz: [of(0x1f, 0x8b, 8), "archive", "application/octet-stream"], |
font is optional while objectSrc and csv beside it are required. Make it required so a cached entry cannot silently lose font-src, and add a test that hits the cache.