@semics-tech/emdash-esign 0.0.0-stage → 1.0.0-rc.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.
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +346 -0
- package/dist/manifest.json +354 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.mjs +38 -0
- package/dist/ts/astro/lib/call.d.ts +56 -0
- package/dist/ts/astro/lib/call.js +138 -0
- package/dist/ts/integration/index.d.ts +32 -0
- package/dist/ts/integration/index.js +49 -0
- package/dist/ts/ui/index.d.ts +6 -0
- package/dist/ts/ui/index.js +35 -0
- package/emdash-plugin.jsonc +144 -0
- package/package.json +68 -3
- package/src/astro/SelfServeBlock.astro +84 -0
- package/src/astro/SignPage.astro +53 -0
- package/src/astro/SignedCopyPage.astro +48 -0
- package/src/astro/SigningCeremony.astro +264 -0
- package/src/astro/SnapshotBlocks.astro +35 -0
- package/src/astro/SnapshotInline.astro +28 -0
- package/src/astro/VerifyDocument.astro +70 -0
- package/src/astro/VerifyPage.astro +41 -0
- package/src/astro/index.js +21 -0
- package/src/astro/lib/assets.ts +156 -0
- package/src/astro/lib/call.ts +205 -0
- package/src/astro/lib/csp.ts +57 -0
- package/src/astro/lib/csrf.ts +33 -0
- package/src/astro/lib/markdown.ts +363 -0
- package/src/astro/lib/self-serve.ts +149 -0
- package/src/astro/lib/sign.ts +421 -0
- package/src/astro/self-serve-endpoint.ts +11 -0
- package/src/astro/virtual.d.ts +10 -0
- package/src/client/ceremony.d.ts +31 -0
- package/src/client/ceremony.js +218 -0
- package/src/client/index.ts +5 -0
- package/src/client/pad.d.ts +24 -0
- package/src/client/pad.js +232 -0
- package/src/client/verify.d.ts +3 -0
- package/src/client/verify.js +88 -0
- package/src/i18n/ar.ts +132 -0
- package/src/i18n/en.ts +136 -0
- package/src/i18n/index.ts +121 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The snapshot renderer's sanitiser (SU-FR-03, SU-NFR-01). A snapshot is sender-written,
|
|
3
|
+
* Markdown-ish template text whose `{{field}}` values were escaped by shared `renderTemplate`
|
|
4
|
+
* (`&<>"'` as entities, Markdown punctuation with a backslash). It is what `docSha256` covers, so
|
|
5
|
+
* it is shown as it is, never rewritten.
|
|
6
|
+
*
|
|
7
|
+
* Rather than producing HTML and cleaning it, this parses the text into a tree of a few allowed
|
|
8
|
+
* node kinds (headings, paragraphs, lists, quotes, code, rules; strong, emphasis, code spans, line
|
|
9
|
+
* breaks, links) holding plain strings. The Astro components render the tree through `{text}`
|
|
10
|
+
* interpolation, which escapes, so raw HTML in the text can only ever show up as text. The only
|
|
11
|
+
* attribute that comes from the text is a link's `href`, and only an absolute http(s) or mailto
|
|
12
|
+
* URL passes `safeHref`; anything else, images included, stays literal text. Work is linear in the
|
|
13
|
+
* input: failed delimiter searches are remembered and nesting is capped.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Inline =
|
|
17
|
+
| { type: "text"; text: string }
|
|
18
|
+
| { type: "strong"; children: Inline[] }
|
|
19
|
+
| { type: "em"; children: Inline[] }
|
|
20
|
+
| { type: "code"; text: string }
|
|
21
|
+
| { type: "link"; href: string; children: Inline[] }
|
|
22
|
+
| { type: "break" };
|
|
23
|
+
|
|
24
|
+
export type Block =
|
|
25
|
+
| { type: "heading"; level: 2 | 3 | 4 | 5 | 6; children: Inline[] }
|
|
26
|
+
| { type: "paragraph"; children: Inline[] }
|
|
27
|
+
| { type: "list"; ordered: boolean; start?: number; items: Inline[][] }
|
|
28
|
+
| { type: "quote"; children: Block[] }
|
|
29
|
+
| { type: "code"; text: string }
|
|
30
|
+
| { type: "rule" };
|
|
31
|
+
|
|
32
|
+
const MAX_INLINE_DEPTH = 6;
|
|
33
|
+
const MAX_QUOTE_DEPTH = 4;
|
|
34
|
+
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" };
|
|
35
|
+
const ENTITY = /^&(amp|lt|gt|quot|#39);/;
|
|
36
|
+
const PUNCT = /[!-/:-@[-`{-~]/;
|
|
37
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
38
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters never belong in a link.
|
|
39
|
+
const UNSAFE_URL_CHARS = /[\s"'<>`\\\u0000-\u001F\u007F]/;
|
|
40
|
+
const SAFE_URL_START = /^(https?:\/\/|mailto:)/i;
|
|
41
|
+
/** Every character the inline parser treats specially; anything else is plain text. */
|
|
42
|
+
const SPECIAL_CHARS = new Set(["\\", "&", "\n", "`", "*", "_", "[", "!", "<"]);
|
|
43
|
+
const SPECIAL = /[\\&\n`*_[!<]/g;
|
|
44
|
+
|
|
45
|
+
/** An absolute http, https or mailto URL, normalised; anything else is `undefined`. */
|
|
46
|
+
export function safeHref(raw: string): string | undefined {
|
|
47
|
+
if (raw.length === 0 || UNSAFE_URL_CHARS.test(raw) || !SAFE_URL_START.test(raw)) return undefined;
|
|
48
|
+
try {
|
|
49
|
+
// The scheme is the checked prefix, so the parsed protocol is http:, https: or mailto:.
|
|
50
|
+
return new URL(raw).href;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Undoes `escapeValue`: the five entities it writes and backslash-escaped punctuation. */
|
|
57
|
+
export function decodeText(text: string): string {
|
|
58
|
+
return text.replace(/\\([!-/:-@[-`{-~])|&(amp|lt|gt|quot|#39);/g, (_all, punct?: string, entity?: string) =>
|
|
59
|
+
punct !== undefined ? punct : (ENTITIES[entity as string] as string),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- inline ---
|
|
64
|
+
|
|
65
|
+
type Found = { inline?: Inline; text?: string; end: number };
|
|
66
|
+
|
|
67
|
+
/** Index of the partner of every bracket (stack matching, escapes honoured), for `[]` and `()`. */
|
|
68
|
+
function pairs(src: string): Map<number, number> {
|
|
69
|
+
const out = new Map<number, number>();
|
|
70
|
+
const stacks: Record<string, number[]> = { "]": [], ")": [] };
|
|
71
|
+
const openerOf: Record<string, string> = { "[": "]", "(": ")" };
|
|
72
|
+
// Only brackets and backslashes matter: jump between them rather than walking every character.
|
|
73
|
+
const marks = /[\\[\]()]/g;
|
|
74
|
+
for (let found = marks.exec(src); found; found = marks.exec(src)) {
|
|
75
|
+
const j = found.index;
|
|
76
|
+
const c = found[0];
|
|
77
|
+
if (c === "\\") marks.lastIndex = j + 2;
|
|
78
|
+
else if (c in openerOf) stacks[openerOf[c] as string]?.push(j);
|
|
79
|
+
else {
|
|
80
|
+
const open = stacks[c]?.pop();
|
|
81
|
+
if (open !== undefined) out.set(open, j);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class InlineParser {
|
|
88
|
+
private readonly partner: Map<number, number>;
|
|
89
|
+
/** Per closer kind, the last search: closers do not depend on their opener, so it can be reused. */
|
|
90
|
+
private readonly memo = new Map<string, { from: number; at: number }>();
|
|
91
|
+
|
|
92
|
+
constructor(private readonly src: string) {
|
|
93
|
+
this.partner = pairs(src);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
parse(from: number, to: number, depth: number): Inline[] {
|
|
97
|
+
const { src } = this;
|
|
98
|
+
const out: Inline[] = [];
|
|
99
|
+
let buf = "";
|
|
100
|
+
const flush = () => {
|
|
101
|
+
if (buf) out.push({ type: "text", text: buf });
|
|
102
|
+
buf = "";
|
|
103
|
+
};
|
|
104
|
+
let i = from;
|
|
105
|
+
while (i < to) {
|
|
106
|
+
const ch = src[i] as string;
|
|
107
|
+
if (!SPECIAL_CHARS.has(ch)) {
|
|
108
|
+
// Plain text up to the next character any rule below looks at, in one slice (the signed PDF
|
|
109
|
+
// job parses whole documents, so this keeps it linear with a small constant; T117).
|
|
110
|
+
SPECIAL.lastIndex = i;
|
|
111
|
+
const next = SPECIAL.exec(src);
|
|
112
|
+
const stop = next && next.index < to ? next.index : to;
|
|
113
|
+
buf += src.slice(i, stop);
|
|
114
|
+
i = stop;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ch === "\\" && i + 1 < to && PUNCT.test(src[i + 1] as string)) {
|
|
118
|
+
buf += src[i + 1];
|
|
119
|
+
i += 2;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const entity = ch === "&" ? ENTITY.exec(src.slice(i, Math.min(to, i + 6))) : null;
|
|
123
|
+
if (entity) {
|
|
124
|
+
buf += ENTITIES[entity[1] as string];
|
|
125
|
+
i += entity[0].length;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (ch === "\n") {
|
|
129
|
+
flush();
|
|
130
|
+
out.push({ type: "break" });
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const found = depth < MAX_INLINE_DEPTH ? this.markup(i, to, depth) : undefined;
|
|
135
|
+
if (found?.inline) {
|
|
136
|
+
flush();
|
|
137
|
+
out.push(found.inline);
|
|
138
|
+
} else buf += found ? found.text : ch;
|
|
139
|
+
i = found ? found.end : i + 1;
|
|
140
|
+
}
|
|
141
|
+
flush();
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The markup starting at `i`, if any: a node, or literal text to keep (an image's alt text). */
|
|
146
|
+
private markup(i: number, to: number, depth: number): Found | undefined {
|
|
147
|
+
const ch = this.src[i];
|
|
148
|
+
if (ch === "`") return this.codeSpan(i, to);
|
|
149
|
+
if (ch === "*" || ch === "_") return this.emphasis(i, to, depth);
|
|
150
|
+
if (ch === "[") return this.link(i, to, depth, false);
|
|
151
|
+
if (ch === "!" && this.src[i + 1] === "[") return this.link(i + 1, to, depth, true);
|
|
152
|
+
if (ch === "<") return this.autolink(i, to);
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The first index at or after `from` where `valid` holds, or -1. `valid` must not depend on the
|
|
158
|
+
* opener, so a search for the same `key` from inside the last answered range has the same answer.
|
|
159
|
+
*/
|
|
160
|
+
private next(key: string, from: number, step: (at: number) => number, valid: (at: number) => boolean): number {
|
|
161
|
+
const last = this.memo.get(key);
|
|
162
|
+
if (last && from >= last.from && (last.at === -1 || from <= last.at)) return last.at;
|
|
163
|
+
let at = step(from);
|
|
164
|
+
while (at !== -1 && !valid(at)) at = step(at + 1);
|
|
165
|
+
this.memo.set(key, { from, at });
|
|
166
|
+
return at;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private escaped(at: number): boolean {
|
|
170
|
+
let slashes = 0;
|
|
171
|
+
for (let j = at - 1; j >= 0 && this.src[j] === "\\"; j--) slashes++;
|
|
172
|
+
return slashes % 2 === 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private codeSpan(i: number, to: number): Found {
|
|
176
|
+
const { src } = this;
|
|
177
|
+
let n = 1;
|
|
178
|
+
while (src[i + n] === "`") n++;
|
|
179
|
+
const fence = "`".repeat(n);
|
|
180
|
+
// A closer is a run of exactly n backticks.
|
|
181
|
+
const close = this.next(
|
|
182
|
+
`code${n}`,
|
|
183
|
+
i + n,
|
|
184
|
+
(at) => src.indexOf(fence, at),
|
|
185
|
+
(at) => src[at - 1] !== "`" && src[at + n] !== "`",
|
|
186
|
+
);
|
|
187
|
+
if (close === -1 || close + n > to) return { text: fence, end: i + n };
|
|
188
|
+
return { inline: { type: "code", text: decodeText(src.slice(i + n, close)) }, end: close + n };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private emphasis(i: number, to: number, depth: number): Found {
|
|
192
|
+
const { src } = this;
|
|
193
|
+
const ch = src[i] as string;
|
|
194
|
+
const delim = src[i + 1] === ch ? ch + ch : ch;
|
|
195
|
+
const start = i + delim.length;
|
|
196
|
+
const word = (at: number) => at >= 0 && at < src.length && WORD.test(src[at] as string);
|
|
197
|
+
const intraword = (at: number) => ch === "_" && word(at);
|
|
198
|
+
const literal = { text: delim, end: start };
|
|
199
|
+
if (intraword(i - 1) || start >= to || /\s/.test(src[start] as string)) return literal;
|
|
200
|
+
const close = this.next(
|
|
201
|
+
delim,
|
|
202
|
+
start + 1,
|
|
203
|
+
(at) => src.indexOf(delim, at),
|
|
204
|
+
(at) => !this.escaped(at) && !/\s/.test(src[at - 1] as string) && !intraword(at + delim.length),
|
|
205
|
+
);
|
|
206
|
+
if (close === -1 || close + delim.length > to) return literal;
|
|
207
|
+
const children = this.parse(start, close, depth + 1);
|
|
208
|
+
return { inline: { type: delim.length === 2 ? "strong" : "em", children }, end: close + delim.length };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `[text](url)`; with `image`, ``, of which only the alt text is shown. */
|
|
212
|
+
private link(i: number, to: number, depth: number, image: boolean): Found {
|
|
213
|
+
const { src } = this;
|
|
214
|
+
const literal = { text: image ? "![" : "[", end: i + 1 };
|
|
215
|
+
const closeText = this.partner.get(i);
|
|
216
|
+
if (closeText === undefined || closeText >= to || src[closeText + 1] !== "(") return literal;
|
|
217
|
+
const closeUrl = this.partner.get(closeText + 1);
|
|
218
|
+
if (closeUrl === undefined || closeUrl >= to) return literal;
|
|
219
|
+
const end = closeUrl + 1;
|
|
220
|
+
if (image) return { text: decodeText(src.slice(i + 1, closeText)), end };
|
|
221
|
+
const href = safeHref(decodeText(src.slice(closeText + 2, closeUrl)));
|
|
222
|
+
if (!href) return { text: decodeText(src.slice(i, end)), end };
|
|
223
|
+
return { inline: { type: "link", href, children: this.parse(i + 1, closeText, depth + 1) }, end };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private autolink(i: number, to: number): Found | undefined {
|
|
227
|
+
const match = /^<([A-Za-z][A-Za-z0-9+.-]*:[^\s<>]*)>/.exec(this.src.slice(i, Math.min(to, i + 2048)));
|
|
228
|
+
const href = match ? safeHref(match[1] as string) : undefined;
|
|
229
|
+
if (!match || !href) return undefined;
|
|
230
|
+
return {
|
|
231
|
+
inline: { type: "link", href, children: [{ type: "text", text: match[1] as string }] },
|
|
232
|
+
end: i + match[0].length,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function inline(text: string): Inline[] {
|
|
238
|
+
return new InlineParser(text).parse(0, text.length, 0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- blocks ---
|
|
242
|
+
|
|
243
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
244
|
+
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
|
|
245
|
+
const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
246
|
+
const QUOTE = /^ {0,3}> ?(.*)$/;
|
|
247
|
+
const BULLET = /^ {0,3}[-*+][ \t]+(.*)$/;
|
|
248
|
+
const ORDERED = /^ {0,3}(\d{1,9})[.)][ \t]+(.*)$/;
|
|
249
|
+
|
|
250
|
+
const blank = (line: string) => line.trim() === "";
|
|
251
|
+
const startsBlock = (line: string) =>
|
|
252
|
+
FENCE.test(line) ||
|
|
253
|
+
HEADING.test(line) ||
|
|
254
|
+
RULE.test(line) ||
|
|
255
|
+
QUOTE.test(line) ||
|
|
256
|
+
BULLET.test(line) ||
|
|
257
|
+
ORDERED.test(line);
|
|
258
|
+
|
|
259
|
+
function blocks(lines: string[], depth: number): Block[] {
|
|
260
|
+
const out: Block[] = [];
|
|
261
|
+
let i = 0;
|
|
262
|
+
while (i < lines.length) {
|
|
263
|
+
const line = lines[i] as string;
|
|
264
|
+
if (blank(line)) {
|
|
265
|
+
i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const fence = FENCE.exec(line);
|
|
269
|
+
if (fence) {
|
|
270
|
+
const marker = fence[1] as string;
|
|
271
|
+
const closer = new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*$`);
|
|
272
|
+
const body: string[] = [];
|
|
273
|
+
i++;
|
|
274
|
+
while (i < lines.length && !closer.test(lines[i] as string)) {
|
|
275
|
+
body.push(lines[i] as string);
|
|
276
|
+
i++;
|
|
277
|
+
}
|
|
278
|
+
i++;
|
|
279
|
+
out.push({ type: "code", text: decodeText(body.join("\n")) });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (RULE.test(line)) {
|
|
283
|
+
out.push({ type: "rule" });
|
|
284
|
+
i++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const heading = HEADING.exec(line);
|
|
288
|
+
if (heading) {
|
|
289
|
+
const level = Math.min(6, (heading[1] as string).length + 1) as 2 | 3 | 4 | 5 | 6;
|
|
290
|
+
out.push({ type: "heading", level, children: inline(heading[2] ?? "") });
|
|
291
|
+
i++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (QUOTE.test(line) && depth < MAX_QUOTE_DEPTH) {
|
|
295
|
+
const inner: string[] = [];
|
|
296
|
+
while (i < lines.length && QUOTE.test(lines[i] as string)) {
|
|
297
|
+
inner.push((QUOTE.exec(lines[i] as string) as RegExpExecArray)[1] as string);
|
|
298
|
+
i++;
|
|
299
|
+
}
|
|
300
|
+
out.push({ type: "quote", children: blocks(inner, depth + 1) });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const bullet = BULLET.test(line);
|
|
304
|
+
const ordered = ORDERED.exec(line);
|
|
305
|
+
if (bullet || ordered) {
|
|
306
|
+
const pattern = bullet ? BULLET : ORDERED;
|
|
307
|
+
const items: string[][] = [];
|
|
308
|
+
while (i < lines.length) {
|
|
309
|
+
const current = lines[i] as string;
|
|
310
|
+
const item = pattern.exec(current);
|
|
311
|
+
if (item) items.push([(bullet ? item[1] : item[2]) as string]);
|
|
312
|
+
else if (!blank(current) && /^ {2,}\S/.test(current) && !startsBlock(current.trimStart())) {
|
|
313
|
+
(items.at(-1) as string[]).push(current.trim());
|
|
314
|
+
} else break;
|
|
315
|
+
i++;
|
|
316
|
+
}
|
|
317
|
+
out.push({
|
|
318
|
+
type: "list",
|
|
319
|
+
ordered: !bullet,
|
|
320
|
+
...(ordered && ordered[1] !== "1" ? { start: Number(ordered[1]) } : {}),
|
|
321
|
+
items: items.map((item) => inline(item.join("\n"))),
|
|
322
|
+
});
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const para: string[] = [line.trim()];
|
|
326
|
+
i++;
|
|
327
|
+
while (i < lines.length && !blank(lines[i] as string) && !startsBlock(lines[i] as string)) {
|
|
328
|
+
para.push((lines[i] as string).trim());
|
|
329
|
+
i++;
|
|
330
|
+
}
|
|
331
|
+
out.push({ type: "paragraph", children: inline(para.join("\n")) });
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The snapshot as a tree of allowed nodes. Line endings are normalised; nothing else is changed. */
|
|
337
|
+
export function parseSnapshot(text: string): Block[] {
|
|
338
|
+
return blocks(text.replace(/\r\n?/g, "\n").split("\n"), 0);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The visible text of a tree (blocks separated by blank lines, breaks as newlines). */
|
|
342
|
+
export function textOf(tree: Block[]): string {
|
|
343
|
+
const ofInline = (nodes: Inline[]): string =>
|
|
344
|
+
nodes
|
|
345
|
+
.map((n) => (n.type === "text" || n.type === "code" ? n.text : n.type === "break" ? "\n" : ofInline(n.children)))
|
|
346
|
+
.join("");
|
|
347
|
+
return tree
|
|
348
|
+
.map((b) => {
|
|
349
|
+
switch (b.type) {
|
|
350
|
+
case "quote":
|
|
351
|
+
return textOf(b.children);
|
|
352
|
+
case "list":
|
|
353
|
+
return b.items.map(ofInline).join("\n");
|
|
354
|
+
case "code":
|
|
355
|
+
return b.text;
|
|
356
|
+
case "rule":
|
|
357
|
+
return "";
|
|
358
|
+
default:
|
|
359
|
+
return ofInline(b.children);
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
.join("\n\n");
|
|
363
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type MessageKey, t } from "../../i18n/index.ts";
|
|
2
|
+
import { callPublicRoute, type PublicRouteContext } from "./call.ts";
|
|
3
|
+
import { crossSiteResponse, isCrossSiteRequest } from "./csrf.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `esign-request` block's logic (ES-FR-18, SU-FR-12), kept out of the `.astro` file so it is testable.
|
|
7
|
+
* It only calls `public/self-serve/start`, which is a NOT_FOUND stub on Free: there the block renders its
|
|
8
|
+
* unavailable state and no form. The signing link goes to the visitor's inbox and is never in a response.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const START_PATH = "/_esign/start";
|
|
12
|
+
const ROUTE = "public/self-serve/start";
|
|
13
|
+
const STATE_PARAM = "esign_start";
|
|
14
|
+
const TEMPLATE_PARAM = "esign_t";
|
|
15
|
+
const FIELD_PREFIX = "field:";
|
|
16
|
+
|
|
17
|
+
export interface SelfServeTemplate {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
/** Placeholders the visitor fills in (name and email are always asked). */
|
|
22
|
+
fields: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SelfServeView =
|
|
26
|
+
| { state: "ready"; template: SelfServeTemplate; turnstileSiteKey?: string }
|
|
27
|
+
| { state: "unavailable" };
|
|
28
|
+
|
|
29
|
+
/** Describes the block's template through the core; any refusal (Free's stub, no licence, not offered) is `unavailable`. */
|
|
30
|
+
export async function loadSelfServe(
|
|
31
|
+
ctx: PublicRouteContext,
|
|
32
|
+
options: { templateId: string; pluginId?: string },
|
|
33
|
+
): Promise<SelfServeView> {
|
|
34
|
+
const templateId = options.templateId.trim();
|
|
35
|
+
if (!templateId) return { state: "unavailable" };
|
|
36
|
+
const res = await callPublicRoute<{ template?: SelfServeTemplate; turnstileSiteKey?: string }>(ctx, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
path: ROUTE,
|
|
39
|
+
body: { templateId, describe: true },
|
|
40
|
+
...(options.pluginId ? { pluginId: options.pluginId } : {}),
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok || !res.data.template) return { state: "unavailable" };
|
|
43
|
+
const { template, turnstileSiteKey } = res.data;
|
|
44
|
+
return { state: "ready", template, ...(turnstileSiteKey ? { turnstileSiteKey } : {}) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const text = (form: FormData, key: string): string => {
|
|
48
|
+
const v = form.get(key);
|
|
49
|
+
return typeof v === "string" ? v.trim() : "";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A same-site path, so a post can never redirect elsewhere. */
|
|
53
|
+
function safeReturnTo(raw: string, origin: string): string | undefined {
|
|
54
|
+
if (!raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return undefined;
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(raw, origin);
|
|
57
|
+
if (url.origin !== origin || url.pathname.startsWith("//") || url.pathname.startsWith("/_esign/")) return undefined;
|
|
58
|
+
url.searchParams.delete(STATE_PARAM);
|
|
59
|
+
url.searchParams.delete(TEMPLATE_PARAM);
|
|
60
|
+
return `${url.pathname}${url.search}`;
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const anchorOf = (templateId: string) => `esign-request-${templateId}`;
|
|
67
|
+
|
|
68
|
+
function backTo(returnTo: string, templateId: string, state: string): string {
|
|
69
|
+
const url = new URL(returnTo, "https://x.invalid");
|
|
70
|
+
url.searchParams.set(STATE_PARAM, state);
|
|
71
|
+
url.searchParams.set(TEMPLATE_PARAM, templateId);
|
|
72
|
+
return `${url.pathname}${url.search}#${anchorOf(templateId)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type StartResult = { location: string } | { status: number; message: string };
|
|
76
|
+
|
|
77
|
+
/** Posts the visitor's details to the core and says where to go next. Values are never put in the location. */
|
|
78
|
+
export async function handleSelfServeForm(
|
|
79
|
+
ctx: PublicRouteContext,
|
|
80
|
+
form: FormData,
|
|
81
|
+
options: { pluginId?: string },
|
|
82
|
+
): Promise<StartResult> {
|
|
83
|
+
const templateId = text(form, "templateId");
|
|
84
|
+
// i18n-ignore: a malformed post (not the block's form) gets a plain refusal.
|
|
85
|
+
if (!templateId) return { status: 400, message: "Missing template." };
|
|
86
|
+
const returnTo = safeReturnTo(text(form, "returnTo"), ctx.url.origin) ?? "/";
|
|
87
|
+
const fields: Record<string, string> = {};
|
|
88
|
+
for (const [key, value] of form.entries()) {
|
|
89
|
+
if (key.startsWith(FIELD_PREFIX) && typeof value === "string") fields[key.slice(FIELD_PREFIX.length)] = value;
|
|
90
|
+
}
|
|
91
|
+
const res = await callPublicRoute(ctx, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
path: ROUTE,
|
|
94
|
+
...(options.pluginId ? { pluginId: options.pluginId } : {}),
|
|
95
|
+
body: {
|
|
96
|
+
templateId,
|
|
97
|
+
name: text(form, "name"),
|
|
98
|
+
email: text(form, "email"),
|
|
99
|
+
fields,
|
|
100
|
+
turnstileToken: text(form, "cf-turnstile-response"),
|
|
101
|
+
website: text(form, "website"),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return { location: backTo(returnTo, templateId, res.ok ? "sent" : res.error.code) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What `POST /_esign/start` gets from Astro. */
|
|
108
|
+
export interface StartEndpointContext {
|
|
109
|
+
request: Request;
|
|
110
|
+
url: URL;
|
|
111
|
+
locals: PublicRouteContext["locals"];
|
|
112
|
+
redirect(location: string, status?: number): Response;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `POST /_esign/start`: refuse cross-site posts first, then run the form and redirect (303). */
|
|
116
|
+
export async function handleStartRequest(
|
|
117
|
+
{ request, url, locals, redirect }: StartEndpointContext,
|
|
118
|
+
options: { pluginId?: string },
|
|
119
|
+
): Promise<Response> {
|
|
120
|
+
if (isCrossSiteRequest(request, url)) return crossSiteResponse();
|
|
121
|
+
const form = await request.formData().catch(() => new FormData());
|
|
122
|
+
const result = await handleSelfServeForm({ locals, url, request }, form, options);
|
|
123
|
+
if ("location" in result) return redirect(result.location, 303);
|
|
124
|
+
return new Response(result.message, {
|
|
125
|
+
status: result.status,
|
|
126
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The state the block reads back from its page URL, for its own template only. */
|
|
131
|
+
export function readStartState(url: URL, templateId: string): { sent: boolean; code?: string } {
|
|
132
|
+
if (url.searchParams.get(TEMPLATE_PARAM) !== templateId) return { sent: false };
|
|
133
|
+
const state = url.searchParams.get(STATE_PARAM);
|
|
134
|
+
if (state === "sent") return { sent: true };
|
|
135
|
+
return state ? { sent: false, code: state } : { sent: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const CODES = [
|
|
139
|
+
"TURNSTILE_FAILED",
|
|
140
|
+
"RATE_LIMITED",
|
|
141
|
+
"SELF_SERVE_FULL",
|
|
142
|
+
"BAD_REQUEST",
|
|
143
|
+
"TEMPLATE_NOT_FOUND",
|
|
144
|
+
"SITE_URL_MISSING",
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/** What the block says about a refused start, in the locale's language (T102). */
|
|
148
|
+
export const startMessage = (code: string, locale?: string): string =>
|
|
149
|
+
t(locale, `selfServe.error.${CODES.includes(code) ? code : "INTERNAL"}` as MessageKey);
|