hecks 3.9.1 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +106 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +16 -3
- data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
- data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
- data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/hecks/hecks.world +2 -1
- data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
- data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
- data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
- data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
- data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
- data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
- data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
- data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
- data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
- data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
- data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
- data/lib/hecks/projections/site/cms_editor.rb +107 -0
- data/lib/hecks/projections/site/role_probe.rb +72 -0
- data/lib/hecks/site/bluebook/site.bluebook +28 -1
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/tools/site_routes/cli.rb +1 -1
- data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
- data/lib/hecks/tools/site_routes.rb +14 -7
- data/lib/hecks/version.rb +1 -1
- data/prebuilt/{verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
- data/rust/Cargo.toml +3 -1
- data/rust/codegen/src/bridging.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +9 -9
- data/rust/codegen/src/main.rs +1 -1
- data/rust/codegen/src/mutations.rs +46 -1
- data/rust/codegen/src/naming.rs +30 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/src/kernel/expr.rs +7 -1
- data/rust/src/kernel/expression_operators/presence.rs +41 -3
- metadata +41 -5
- /data/prebuilt/{6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
- /data/prebuilt/{0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal → cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal} +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// The editor's own session: a signed cookie naming who signed in and until when. It is not the
|
|
4
|
+
// host's session cookie and carries no authority by itself; every request still asks the membership
|
|
5
|
+
// list (membership.ts). The value has the host's account-token format, signed with the shared secret
|
|
6
|
+
// plus a suffix, so a host token cannot be passed off as an editor session.
|
|
7
|
+
import { accountToken, cookieValue, verifyAccountToken } from "@hecks/client";
|
|
8
|
+
|
|
9
|
+
import { EDITOR } from "../config.ts";
|
|
10
|
+
|
|
11
|
+
const SUFFIX = ":editor-session";
|
|
12
|
+
|
|
13
|
+
/** The cookie value for `email`, good for the configured session age. */
|
|
14
|
+
export function signSession(secret: string, email: string, now: () => number = Date.now): string {
|
|
15
|
+
return accountToken(secret + SUFFIX, email, EDITOR.sessionMaxAge, { now });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The email a `Cookie` header's session names, or null when it is absent, forged or expired. */
|
|
19
|
+
export function readSession(header: string | null, secret: string, now: () => number = Date.now): string | null {
|
|
20
|
+
const value = cookieValue(header, EDITOR.sessionCookie);
|
|
21
|
+
if (!value || !secret) return null;
|
|
22
|
+
return verifyAccountToken(value, secret + SUFFIX, { now, normalizeEmail: true })?.email ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ATTRIBUTES = `Path=${EDITOR.basePath}; HttpOnly; SameSite=Lax`;
|
|
26
|
+
|
|
27
|
+
/** The `Set-Cookie` value that starts a session. `Secure` is added outside local development. */
|
|
28
|
+
export function sessionCookie(value: string): string {
|
|
29
|
+
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
|
30
|
+
return `${EDITOR.sessionCookie}=${value}; ${ATTRIBUTES}; Max-Age=${EDITOR.sessionMaxAge}${secure}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `Set-Cookie` value that ends a session. */
|
|
34
|
+
export function clearedCookie(): string {
|
|
35
|
+
return `${EDITOR.sessionCookie}=; ${ATTRIBUTES}; Max-Age=0`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// The sign-in. The site's admin hand-off sends a signed-in admin here with a short-lived account
|
|
4
|
+
// token from the host (the same hand-off that opens a content system). The token proves an account,
|
|
5
|
+
// not an editor, so nobody gets a session unless the membership list admits them now.
|
|
6
|
+
import { verifyAccountToken } from "@hecks/client";
|
|
7
|
+
|
|
8
|
+
import { EDITOR } from "../config.ts";
|
|
9
|
+
import type { Membership } from "./membership.ts";
|
|
10
|
+
import { signSession, sessionCookie } from "./session.ts";
|
|
11
|
+
|
|
12
|
+
export type SignIn = { ok: true; location: string; cookie: string } | { ok: false; status: number; message: string };
|
|
13
|
+
|
|
14
|
+
/** Checks the hand-off token and starts a session for the person it names. */
|
|
15
|
+
export async function signIn(token: string | null, secret: string, membership: Membership, now: () => number = Date.now): Promise<SignIn> {
|
|
16
|
+
const claims = token && secret ? verifyAccountToken(token, secret, { now, normalizeEmail: true }) : null;
|
|
17
|
+
if (!claims) return { ok: false, status: 401, message: "Invalid or expired sign-in token." };
|
|
18
|
+
if (!(await membership.isAdmitted(claims.email))) return { ok: false, status: 403, message: "This account is not an editor." };
|
|
19
|
+
return { ok: true, location: EDITOR.basePath, cookie: sessionCookie(signSession(secret, claims.email, now)) };
|
|
20
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// Running one command and judging what came of it. The host answers HTTP 200 whether or not the
|
|
4
|
+
// domain refused, and its `refusals` list can carry entries replayed from history, so the outcome is
|
|
5
|
+
// judged by the state that comes back: the instance has to show the change. Only when the state did
|
|
6
|
+
// not change at all (a repeated command, a lifecycle move to the state it is already in) does the
|
|
7
|
+
// answer's last refusal decide, and only if it names this verb.
|
|
8
|
+
import type { Answer, HostClient } from "@hecks/client";
|
|
9
|
+
import { DomainUnavailable } from "@hecks/client";
|
|
10
|
+
|
|
11
|
+
import type { Aggregate, Command } from "./schema.ts";
|
|
12
|
+
|
|
13
|
+
export type State = Record<string, unknown>;
|
|
14
|
+
export type Outcome = { ok: true; id: string } | { ok: false; message: string };
|
|
15
|
+
|
|
16
|
+
/** The text an identity value holds: the part of a value object, or the plain value. */
|
|
17
|
+
export function leaf(value: unknown): string | null {
|
|
18
|
+
if (value && typeof value === "object") return leaf(Object.values(value as object).find((part) => part !== null && part !== undefined));
|
|
19
|
+
return value === null || value === undefined ? null : String(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The id an instance is addressed by. */
|
|
23
|
+
export const idOf = (agg: Aggregate, state: State | undefined): string | null => leaf(state?.[agg.identity]);
|
|
24
|
+
|
|
25
|
+
/** The state of one instance in an answer, or undefined when the answer holds none by that id. */
|
|
26
|
+
export function instanceIn(client: HostClient, answer: Answer, agg: Aggregate, id: string): State | undefined {
|
|
27
|
+
return client.instancesOf(answer, agg.name).find(([key]) => key === id)?.[1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when everything `sent` carries is in `held`; numbers and their text compare equal. */
|
|
31
|
+
export function covers(sent: unknown, held: unknown): boolean {
|
|
32
|
+
if (Array.isArray(sent)) return Array.isArray(held) && sent.length === held.length && sent.every((item, at) => covers(item, held[at]));
|
|
33
|
+
if (sent && typeof sent === "object") {
|
|
34
|
+
const keys = Object.keys(sent);
|
|
35
|
+
if (!held || typeof held !== "object") return keys.length === 1 && covers((sent as State)[keys[0]], held);
|
|
36
|
+
return keys.every((key) => covers((sent as State)[key], (held as State)[key]));
|
|
37
|
+
}
|
|
38
|
+
return String(sent) === String(held);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sameState = (a: State | undefined, b: State | undefined): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
42
|
+
|
|
43
|
+
function refusedLast(answer: Answer, agg: Aggregate, command: Command): boolean {
|
|
44
|
+
const last = (answer.refusals ?? []).at(-1);
|
|
45
|
+
return last !== undefined && String(last.verb ?? "").replace(/^.*::/, "") === `${agg.name}.${command.name}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether `after` shows the command took effect, given the instance as it was `before`. */
|
|
49
|
+
export function applied(agg: Aggregate, command: Command, args: State, before: State | undefined, after: State | undefined, answer: Answer): boolean {
|
|
50
|
+
if (!after) return false;
|
|
51
|
+
if (sameState(before, after)) return !refusedLast(answer, agg, command);
|
|
52
|
+
const move = agg.lifecycle?.transitions.find((transition) => transition.verb === command.name);
|
|
53
|
+
if (move && agg.lifecycle) return after[agg.lifecycle.field] === move.to;
|
|
54
|
+
return covers(args, after);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The words for a command that changed nothing: the host's last refusal, or a plain statement. */
|
|
58
|
+
export function refusalMessage(answer: Answer, command: Command): string {
|
|
59
|
+
const last = (answer.refusals ?? []).at(-1);
|
|
60
|
+
return last?.error ?? `The domain did not apply ${command.name}.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Dispatches `command` as the role it declares and judges the outcome. `to` is the instance a
|
|
65
|
+
* non-creating command acts on; a creating command's instance is addressed by what it was given.
|
|
66
|
+
*/
|
|
67
|
+
export async function runCommand(client: HostClient, agg: Aggregate, command: Command, args: State, to?: string): Promise<Outcome> {
|
|
68
|
+
try {
|
|
69
|
+
const id = to ?? idOf(agg, args);
|
|
70
|
+
const before = id ? instanceIn(client, await client.read(), agg, id) : undefined;
|
|
71
|
+
const answer = await client.dispatch(`${agg.name}.${command.name}`, args, to, command.role ?? undefined);
|
|
72
|
+
const after = id ? instanceIn(client, answer, agg, id) : undefined;
|
|
73
|
+
if (id && applied(agg, command, args, before, after, answer)) return { ok: true, id };
|
|
74
|
+
return { ok: false, message: refusalMessage(answer, command) };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err instanceof DomainUnavailable) return { ok: false, message: `The domain could not be reached: ${err.message}` };
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// What the editor is built from: the project's Editor row, defaults filled. Change the row and run
|
|
4
|
+
// `hecks site site_projection.project_site`; with `--check` it fails when this file is out of date.
|
|
5
|
+
|
|
6
|
+
export const EDITOR = {
|
|
7
|
+
title: __TITLE__,
|
|
8
|
+
basePath: __BASE_PATH__,
|
|
9
|
+
ssoPath: __SSO_PATH__,
|
|
10
|
+
login: __LOGIN__,
|
|
11
|
+
sessionCookie: __SESSION_COOKIE__,
|
|
12
|
+
sessionMaxAge: 28800,
|
|
13
|
+
hostCookie: __HOST_COOKIE__,
|
|
14
|
+
hostEnv: __HOST_ENV__,
|
|
15
|
+
hostDefault: __HOST_DEFAULT__,
|
|
16
|
+
roles: __ROLES__,
|
|
17
|
+
mediaDir: __MEDIA_DIR__,
|
|
18
|
+
mediaMaxBytes: __MEDIA_MAX_BYTES__,
|
|
19
|
+
accountPath: "/accounts/me",
|
|
20
|
+
membersPath: "/members",
|
|
21
|
+
rememberMs: 60000,
|
|
22
|
+
timeoutMs: 5000,
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// The domain host, reached the way every server-side caller reaches it: @hecks/client's HostClient
|
|
4
|
+
// posting to /dispatch from this machine. The host accepts that protocol from a loopback peer only,
|
|
5
|
+
// which is why this server, not the browser, talks to it; it authenticates each person itself
|
|
6
|
+
// (auth/) before any of these calls is made on their behalf.
|
|
7
|
+
import { HostClient } from "@hecks/client";
|
|
8
|
+
|
|
9
|
+
import { EDITOR } from "./config.ts";
|
|
10
|
+
import { SCHEMA } from "./schema.ts";
|
|
11
|
+
|
|
12
|
+
export interface HostOptions {
|
|
13
|
+
/** The host's address, when not read from the environment variable the Editor row names. */
|
|
14
|
+
url?: string;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The host's base URL, without a trailing slash. */
|
|
19
|
+
export function hostUrl(options: HostOptions = {}): string {
|
|
20
|
+
return (options.url ?? (process.env[EDITOR.hostEnv] || EDITOR.hostDefault)).replace(/\/+$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A client for the domain this editor edits. */
|
|
24
|
+
export function hostClient(options: HostOptions = {}): HostClient {
|
|
25
|
+
return new HostClient({ domain: SCHEMA.domain, url: hostUrl(options), fetch: options.fetch, timeoutMs: EDITOR.timeoutMs });
|
|
26
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// The pictures: `POST <base>/media` takes an upload, `GET <base>/media` lists the registered
|
|
4
|
+
// pictures, and `GET <base>/media/<key>` serves one. Every request reaching here is already
|
|
5
|
+
// authenticated by app.ts; nothing is read or written before that.
|
|
6
|
+
//
|
|
7
|
+
// An upload is checked before anything is kept: a bounded read of the body, an allow-list of raster
|
|
8
|
+
// types decided by the picture's own first bytes, a size cap from the Editor row, and alt text. The
|
|
9
|
+
// bytes then go through the storage port under a key made from their own digest, and the domain's
|
|
10
|
+
// register-picture command records the picture (key, alt text, type, size). The domain never holds
|
|
11
|
+
// the bytes, and a client-supplied filename is never used.
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
import { DomainRefusal, rowsOf } from "@hecks/client";
|
|
15
|
+
import type { HostClient } from "@hecks/client";
|
|
16
|
+
|
|
17
|
+
import { instanceIn, leaf, runCommand } from "../commands.ts";
|
|
18
|
+
import type { State } from "../commands.ts";
|
|
19
|
+
import { EDITOR } from "../config.ts";
|
|
20
|
+
import { SCHEMA } from "../schema.ts";
|
|
21
|
+
import type { Aggregate, Media, MediaField } from "../schema.ts";
|
|
22
|
+
import { boundaryOf, parts } from "./multipart.ts";
|
|
23
|
+
import { EXTENSION_OF, KEY_PATTERN, TYPE_OF, sizeOf, sniff } from "./sniff.ts";
|
|
24
|
+
import { diskStorage } from "./storage.ts";
|
|
25
|
+
import type { MediaStorage } from "./storage.ts";
|
|
26
|
+
|
|
27
|
+
/** The room the form's framing takes beyond the picture's own size cap. */
|
|
28
|
+
const OVERHEAD_BYTES = 65_536;
|
|
29
|
+
|
|
30
|
+
const json = (value: unknown, status = 200): Response =>
|
|
31
|
+
new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" } });
|
|
32
|
+
|
|
33
|
+
const refuse = (status: number, error: string): Response => json({ error }, status);
|
|
34
|
+
|
|
35
|
+
/** The body, or null when it is larger than `limit`: no more than `limit` bytes are ever held. */
|
|
36
|
+
async function readBounded(request: Request, limit: number): Promise<Uint8Array | null> {
|
|
37
|
+
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
38
|
+
if (declared > limit) return null;
|
|
39
|
+
const reader = request.body?.getReader();
|
|
40
|
+
const chunks: Uint8Array[] = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
while (reader) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
size += value.length;
|
|
46
|
+
if (size > limit) {
|
|
47
|
+
await reader.cancel();
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
chunks.push(value);
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat(chunks);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A value as the command takes it: in the value object's part when the attribute is one. */
|
|
56
|
+
function sent(field: MediaField, value: string | number): unknown {
|
|
57
|
+
return field.wrap ? { [field.wrap]: value } : value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function recordOf(media: Media, key: string, alt: string, mime: string, bytes: Uint8Array): State {
|
|
61
|
+
const record: State = {
|
|
62
|
+
[media.fields.key.name]: sent(media.fields.key, key),
|
|
63
|
+
[media.fields.alt.name]: sent(media.fields.alt, alt),
|
|
64
|
+
[media.fields.mime.name]: sent(media.fields.mime, mime),
|
|
65
|
+
};
|
|
66
|
+
const size = sizeOf(bytes, mime);
|
|
67
|
+
const { width, height } = media.fields;
|
|
68
|
+
if (size && width) record[width.name] = sent(width, size.width);
|
|
69
|
+
if (size && height) record[height.name] = sent(height, size.height);
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function picture(storage: MediaStorage, media: Media, state: State) {
|
|
74
|
+
const key = leaf(state[media.fields.key.name]);
|
|
75
|
+
return key === null ? null : { key, alt: leaf(state[media.fields.alt.name]) ?? "", mime: leaf(state[media.fields.mime.name]) ?? "", url: storage.url(key) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function aggregateOf(media: Media): Aggregate {
|
|
79
|
+
return SCHEMA.aggregates.find((candidate) => candidate.name === media.aggregate) as Aggregate;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What was uploaded, or the refusal to answer with. */
|
|
83
|
+
async function received(request: Request): Promise<{ file: Uint8Array; alt: string; error?: undefined } | { error: Response }> {
|
|
84
|
+
const limit = EDITOR.mediaMaxBytes;
|
|
85
|
+
const body = await readBounded(request, limit + OVERHEAD_BYTES);
|
|
86
|
+
if (!body) return { error: refuse(413, `A picture may be at most ${limit} bytes.`) };
|
|
87
|
+
const boundary = boundaryOf(request.headers.get("content-type"));
|
|
88
|
+
const sections = boundary ? parts(body, boundary) : [];
|
|
89
|
+
const file = sections.find((part) => part.name === "file" && part.filename !== null);
|
|
90
|
+
if (!file || file.data.length === 0) return { error: refuse(400, "Choose a picture to upload.") };
|
|
91
|
+
if (file.data.length > limit) return { error: refuse(413, `A picture may be at most ${limit} bytes.`) };
|
|
92
|
+
const alt = Buffer.from(sections.find((part) => part.name === "alt")?.data ?? []).toString("utf8").trim();
|
|
93
|
+
return { file: file.data, alt };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function upload(client: HostClient, storage: MediaStorage, request: Request, media: Media): Promise<Response> {
|
|
97
|
+
const got = await received(request);
|
|
98
|
+
if (got.error) return got.error;
|
|
99
|
+
const mime = sniff(got.file);
|
|
100
|
+
if (!mime) return refuse(415, "Only JPEG, PNG, WebP, GIF and AVIF pictures are accepted.");
|
|
101
|
+
if (got.alt === "") return refuse(422, "Describe the picture in alt text before adding it.");
|
|
102
|
+
const key = `${createHash("sha256").update(got.file).digest("hex")}.${EXTENSION_OF[mime]}`;
|
|
103
|
+
const agg = aggregateOf(media);
|
|
104
|
+
const answer = await client.read();
|
|
105
|
+
const known = instanceIn(client, answer, agg, key);
|
|
106
|
+
if (known) return json({ ...picture(storage, media, known), registered: false });
|
|
107
|
+
await storage.put(key, got.file, mime);
|
|
108
|
+
const command = agg.commands.find((candidate) => candidate.name === media.command) as NonNullable<typeof agg.commands[number]>;
|
|
109
|
+
const outcome = await runCommand(client, agg, command, recordOf(media, key, got.alt, mime, got.file));
|
|
110
|
+
if (!outcome.ok) return refuse(422, outcome.message);
|
|
111
|
+
return json({ key, alt: got.alt, mime, url: storage.url(key), registered: true }, 201);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function stateOf(client: HostClient, media: Media): Promise<State[]> {
|
|
115
|
+
const agg = aggregateOf(media);
|
|
116
|
+
if (!media.listing) return client.instancesOf(await client.read(), agg.name).map(([, state]) => state);
|
|
117
|
+
const name = `${agg.name}.${media.listing}`;
|
|
118
|
+
return (rowsOf(await client.query(name, {}), name) ?? []) as State[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function listing(client: HostClient, storage: MediaStorage, media: Media): Promise<Response> {
|
|
122
|
+
try {
|
|
123
|
+
const pictures = (await stateOf(client, media)).flatMap((state) => picture(storage, media, state) ?? []);
|
|
124
|
+
return json({ pictures });
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err instanceof DomainRefusal) return refuse(422, err.message);
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function serve(storage: MediaStorage, key: string): Promise<Response> {
|
|
132
|
+
const bytes = KEY_PATTERN.test(key) ? await storage.read(key) : null;
|
|
133
|
+
if (!bytes) return refuse(404, "No such picture.");
|
|
134
|
+
return new Response(bytes as BodyInit, {
|
|
135
|
+
headers: {
|
|
136
|
+
"Content-Type": TYPE_OF[key.slice(key.lastIndexOf(".") + 1)],
|
|
137
|
+
"Content-Length": String(bytes.length),
|
|
138
|
+
"X-Content-Type-Options": "nosniff",
|
|
139
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
140
|
+
"Cache-Control": "private, max-age=300",
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The handler of everything under `<base>/media`, given the path segments after `media`. */
|
|
146
|
+
export function mediaHandler(storage: MediaStorage = diskStorage()) {
|
|
147
|
+
const media = SCHEMA.media as Media;
|
|
148
|
+
return (client: HostClient, request: Request, rest: string[]): Promise<Response> | Response => {
|
|
149
|
+
if (rest.length === 0 && request.method === "POST") return upload(client, storage, request, media);
|
|
150
|
+
if (rest.length === 0 && request.method === "GET") return listing(client, storage, media);
|
|
151
|
+
if (rest.length === 1 && request.method === "GET") return serve(storage, rest[0]);
|
|
152
|
+
return refuse(404, "No such page.");
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// A `multipart/form-data` body read into its parts. The body has already been read within the size
|
|
4
|
+
// cap by the time it gets here, so this only splits it: no part is written anywhere, and a part's
|
|
5
|
+
// filename is returned for information and never used as a path.
|
|
6
|
+
|
|
7
|
+
export interface Part {
|
|
8
|
+
name: string;
|
|
9
|
+
filename: string | null;
|
|
10
|
+
data: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The most parts a body may have; an upload form has a handful. */
|
|
14
|
+
const MAX_PARTS = 8;
|
|
15
|
+
|
|
16
|
+
/** The boundary a `Content-Type` header names, or null when it is not a multipart type with one. */
|
|
17
|
+
export function boundaryOf(contentType: string | null): string | null {
|
|
18
|
+
if (!contentType || !/^multipart\/form-data\s*;/i.test(contentType)) return null;
|
|
19
|
+
const found = /boundary=(?:"([^"]{1,70})"|([^;\s]{1,70}))/i.exec(contentType);
|
|
20
|
+
return found ? (found[1] ?? found[2]) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function header(head: string, key: string): string | null {
|
|
24
|
+
const found = new RegExp(`${key}="([^"]*)"`, "i").exec(head);
|
|
25
|
+
return found ? found[1] : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function partOf(chunk: Buffer): Part | null {
|
|
29
|
+
const split = chunk.indexOf("\r\n\r\n");
|
|
30
|
+
if (split < 0) return null;
|
|
31
|
+
const head = chunk.subarray(0, split).toString("latin1");
|
|
32
|
+
const name = header(head, "name");
|
|
33
|
+
if (name === null) return null;
|
|
34
|
+
return { name, filename: header(head, "filename"), data: chunk.subarray(split + 4) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The parts of `body`, split on `boundary`; a body that is not well formed gives an empty list. */
|
|
38
|
+
export function parts(body: Uint8Array, boundary: string): Part[] {
|
|
39
|
+
const buffer = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
40
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
41
|
+
const found: Part[] = [];
|
|
42
|
+
let at = buffer.indexOf(delimiter);
|
|
43
|
+
while (at >= 0 && found.length < MAX_PARTS) {
|
|
44
|
+
const start = at + delimiter.length;
|
|
45
|
+
if (buffer.subarray(start, start + 2).toString() === "--") break;
|
|
46
|
+
const next = buffer.indexOf(delimiter, start);
|
|
47
|
+
if (next < 0) return [];
|
|
48
|
+
const part = partOf(buffer.subarray(start + 2, Math.max(start + 2, next - 2)));
|
|
49
|
+
if (part) found.push(part);
|
|
50
|
+
at = next;
|
|
51
|
+
}
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// What a picture's bytes are. The type an upload claims is never trusted: the first bytes decide,
|
|
4
|
+
// against an allow-list of raster formats (JPEG, PNG, WebP, GIF, AVIF). SVG, which can carry script,
|
|
5
|
+
// and anything else is refused. The same bytes give the picture's size when the format makes it cheap
|
|
6
|
+
// to read, and the storage key is the bytes' own digest with the format's extension, so a key is never
|
|
7
|
+
// built from anything the client sent.
|
|
8
|
+
|
|
9
|
+
/** The extension each allowed type is stored under. */
|
|
10
|
+
export const EXTENSION_OF: Readonly<Record<string, string>> = {
|
|
11
|
+
"image/jpeg": "jpg",
|
|
12
|
+
"image/png": "png",
|
|
13
|
+
"image/webp": "webp",
|
|
14
|
+
"image/gif": "gif",
|
|
15
|
+
"image/avif": "avif",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The type a stored key's extension says it is. */
|
|
19
|
+
export const TYPE_OF: Readonly<Record<string, string>> = Object.fromEntries(Object.entries(EXTENSION_OF).map(([type, ext]) => [ext, type]));
|
|
20
|
+
|
|
21
|
+
/** A key the editor generated: a SHA-256 digest in hex, a dot, and an allowed extension. */
|
|
22
|
+
export const KEY_PATTERN = /^[0-9a-f]{64}\.(jpg|png|webp|gif|avif)$/;
|
|
23
|
+
|
|
24
|
+
const ascii = (bytes: Uint8Array, from: number, to: number): string => String.fromCharCode(...bytes.subarray(from, to));
|
|
25
|
+
const be16 = (b: Uint8Array, at: number): number => (b[at] << 8) | b[at + 1];
|
|
26
|
+
const be32 = (b: Uint8Array, at: number): number => ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0;
|
|
27
|
+
const le16 = (b: Uint8Array, at: number): number => b[at] | (b[at + 1] << 8);
|
|
28
|
+
const le24 = (b: Uint8Array, at: number): number => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16);
|
|
29
|
+
|
|
30
|
+
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
31
|
+
|
|
32
|
+
/** AVIF is an ISO file whose first box names an avif brand, as its major brand or a compatible one. */
|
|
33
|
+
function isAvif(bytes: Uint8Array): boolean {
|
|
34
|
+
if (bytes.length < 16 || ascii(bytes, 4, 8) !== "ftyp") return false;
|
|
35
|
+
const end = Math.min(be32(bytes, 0), 64, bytes.length);
|
|
36
|
+
for (let at = 8; at + 4 <= end; at += 4) {
|
|
37
|
+
const brand = ascii(bytes, at, at + 4);
|
|
38
|
+
if (brand === "avif" || brand === "avis") return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The allowed type the bytes are, or null when they are none of them. */
|
|
44
|
+
export function sniff(bytes: Uint8Array): string | null {
|
|
45
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
46
|
+
if (bytes.length >= 8 && PNG.every((value, at) => bytes[at] === value)) return "image/png";
|
|
47
|
+
if (bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(ascii(bytes, 0, 6))) return "image/gif";
|
|
48
|
+
if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
|
|
49
|
+
return isAvif(bytes) ? "image/avif" : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Size {
|
|
53
|
+
width: number;
|
|
54
|
+
height: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function jpegSize(bytes: Uint8Array): Size | null {
|
|
58
|
+
let at = 2;
|
|
59
|
+
while (at + 9 < bytes.length) {
|
|
60
|
+
if (bytes[at] !== 0xff) return null;
|
|
61
|
+
const marker = bytes[at + 1];
|
|
62
|
+
if (marker === 0xff) {
|
|
63
|
+
at += 1;
|
|
64
|
+
} else if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
65
|
+
return { height: be16(bytes, at + 5), width: be16(bytes, at + 7) };
|
|
66
|
+
} else if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
67
|
+
at += 2;
|
|
68
|
+
} else {
|
|
69
|
+
at += 2 + be16(bytes, at + 2);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function webpSize(bytes: Uint8Array): Size | null {
|
|
76
|
+
const chunk = ascii(bytes, 12, 16);
|
|
77
|
+
if (chunk === "VP8X" && bytes.length >= 30) return { width: le24(bytes, 24) + 1, height: le24(bytes, 27) + 1 };
|
|
78
|
+
if (chunk === "VP8 " && bytes.length >= 30) return { width: le16(bytes, 26) & 0x3fff, height: le16(bytes, 28) & 0x3fff };
|
|
79
|
+
if (chunk === "VP8L" && bytes.length >= 25) {
|
|
80
|
+
const bits = (bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24)) >>> 0;
|
|
81
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The picture's size in pixels when the format makes it cheap to read (not AVIF), else null. */
|
|
87
|
+
export function sizeOf(bytes: Uint8Array, type: string): Size | null {
|
|
88
|
+
let size: Size | null = null;
|
|
89
|
+
if (type === "image/png" && bytes.length >= 24) size = { width: be32(bytes, 16), height: be32(bytes, 20) };
|
|
90
|
+
else if (type === "image/gif" && bytes.length >= 10) size = { width: le16(bytes, 6), height: le16(bytes, 8) };
|
|
91
|
+
else if (type === "image/jpeg") size = jpegSize(bytes);
|
|
92
|
+
else if (type === "image/webp") size = webpSize(bytes);
|
|
93
|
+
return size && size.width > 0 && size.height > 0 ? size : null;
|
|
94
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// Where a picture's bytes are kept. The domain holds a picture's record (key, alt text, type, size)
|
|
4
|
+
// and never its bytes; the bytes go through this port. `diskStorage` writes them under the directory
|
|
5
|
+
// the Editor row names (`media_dir`). A project that keeps pictures in an object store implements the
|
|
6
|
+
// same interface (`put` a key's bytes, `read` them back, and `url` where a browser can show one) and
|
|
7
|
+
// passes it to `createApp({ storage })`; nothing else in the editor changes.
|
|
8
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { EDITOR } from "../config.ts";
|
|
12
|
+
import { KEY_PATTERN } from "./sniff.ts";
|
|
13
|
+
|
|
14
|
+
export interface MediaStorage {
|
|
15
|
+
/** Keeps `bytes` under `key`; keeping the same key again keeps the same bytes. */
|
|
16
|
+
put(key: string, bytes: Uint8Array, mime: string): Promise<void>;
|
|
17
|
+
/** The address a signed-in editor's browser shows the picture from. */
|
|
18
|
+
url(key: string): string;
|
|
19
|
+
/** The bytes kept under `key`, or null when there are none. */
|
|
20
|
+
read(key: string): Promise<Uint8Array | null>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The directory's file for `key`; a key that is not one this editor generates is refused. */
|
|
24
|
+
function fileFor(directory: string, key: string): string {
|
|
25
|
+
if (!KEY_PATTERN.test(key)) throw new RangeError("not a picture key");
|
|
26
|
+
return join(directory, key);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Pictures on the local disk, under `directory` (resolved from the working directory). */
|
|
30
|
+
export function diskStorage(directory: string = EDITOR.mediaDir): MediaStorage {
|
|
31
|
+
const root = resolve(directory);
|
|
32
|
+
return {
|
|
33
|
+
async put(key, bytes) {
|
|
34
|
+
const file = fileFor(root, key);
|
|
35
|
+
await mkdir(root, { recursive: true });
|
|
36
|
+
const partial = `${file}.${process.pid}.part`;
|
|
37
|
+
await writeFile(partial, bytes);
|
|
38
|
+
await rename(partial, file);
|
|
39
|
+
},
|
|
40
|
+
url: (key) => `${EDITOR.basePath}/media/${key}`,
|
|
41
|
+
async read(key) {
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(fileFor(root, key));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT" || err instanceof RangeError) return null;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// The domain's shape, read from its bluebook: every aggregate with its attributes, value objects,
|
|
4
|
+
// lifecycle, commands and queries. The pages, the forms and the input parser all read this one
|
|
5
|
+
// constant, so the editor can edit any aggregate; none of it is written by hand.
|
|
6
|
+
|
|
7
|
+
/** How an attribute's value is entered: `object` is a value object, the rest are single values. */
|
|
8
|
+
export type Kind = "text" | "integer" | "number" | "boolean" | "object" | "reference";
|
|
9
|
+
|
|
10
|
+
export interface Attr {
|
|
11
|
+
name: string;
|
|
12
|
+
/** The type as the bluebook spells it ("String", "Byline"). */
|
|
13
|
+
type: string;
|
|
14
|
+
kind: Kind;
|
|
15
|
+
optional: boolean;
|
|
16
|
+
/** True for `list_of`: the attribute holds a list of `type`. */
|
|
17
|
+
list: boolean;
|
|
18
|
+
/** `body` for an attribute whose value object has the shape of a structured document (blocks of spans). */
|
|
19
|
+
widget?: "body";
|
|
20
|
+
/** The aggregate a `reference` points at. */
|
|
21
|
+
target?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Transition {
|
|
25
|
+
verb: string;
|
|
26
|
+
from: string[];
|
|
27
|
+
to: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Lifecycle {
|
|
31
|
+
field: string;
|
|
32
|
+
default: string;
|
|
33
|
+
transitions: Transition[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Command {
|
|
37
|
+
name: string;
|
|
38
|
+
goal: string | null;
|
|
39
|
+
role: string | null;
|
|
40
|
+
/** True when the command makes a new instance; false when it acts on one (`reference_to`). */
|
|
41
|
+
creates: boolean;
|
|
42
|
+
/** The aggregate the command acts on, when it is not a creating command. */
|
|
43
|
+
on: string | null;
|
|
44
|
+
attributes: Attr[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Query {
|
|
48
|
+
name: string;
|
|
49
|
+
description: string | null;
|
|
50
|
+
attributes: Attr[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Aggregate {
|
|
54
|
+
name: string;
|
|
55
|
+
description: string | null;
|
|
56
|
+
identity: string;
|
|
57
|
+
lifecycle: Lifecycle | null;
|
|
58
|
+
attributes: Attr[];
|
|
59
|
+
valueObjects: Record<string, Attr[]>;
|
|
60
|
+
commands: Command[];
|
|
61
|
+
queries: Query[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** How one part of a picture's record is sent: `wrap` is the part of a value object that holds the value. */
|
|
65
|
+
export interface MediaField {
|
|
66
|
+
name: string;
|
|
67
|
+
wrap: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The aggregate that registers pictures, when the chapter has one: found by shape, never by name. */
|
|
71
|
+
export interface Media {
|
|
72
|
+
aggregate: string;
|
|
73
|
+
command: string;
|
|
74
|
+
fields: { key: MediaField; alt: MediaField; mime: MediaField; width?: MediaField; height?: MediaField };
|
|
75
|
+
/** The query that lists the pictures, or null when every instance is read. */
|
|
76
|
+
listing: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Schema {
|
|
80
|
+
domain: string;
|
|
81
|
+
aggregates: Aggregate[];
|
|
82
|
+
media?: Media;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const SCHEMA: Schema = __SCHEMA__;
|