@postedin/cms-client 0.1.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.
Files changed (73) hide show
  1. package/README.md +66 -0
  2. package/bin/dissect/cli.mjs +138 -0
  3. package/bin/dissect/dissect.mjs +290 -0
  4. package/bin/profile/build.mjs +106 -0
  5. package/bin/profile/fetch-log.mjs +298 -0
  6. package/bin/profile/format.mjs +90 -0
  7. package/bin/profile/interference-summary.mjs +558 -0
  8. package/bin/profile/interference.mjs +604 -0
  9. package/bin/profile/measure.mjs +137 -0
  10. package/bin/profile/report.mjs +90 -0
  11. package/bin/profile/site-env.mjs +16 -0
  12. package/bin/profile/summarize.mjs +429 -0
  13. package/dist/browser.d.ts +145 -0
  14. package/dist/browser.js +11 -0
  15. package/dist/browser.js.map +1 -0
  16. package/dist/chunk-6V54ITTK.js +197 -0
  17. package/dist/chunk-6V54ITTK.js.map +1 -0
  18. package/dist/chunk-MNZ7DIGC.js +51 -0
  19. package/dist/chunk-MNZ7DIGC.js.map +1 -0
  20. package/dist/form-proxy/upload-policy.d.ts +40 -0
  21. package/dist/form-proxy/upload-policy.js +17 -0
  22. package/dist/form-proxy/upload-policy.js.map +1 -0
  23. package/dist/index.d.ts +570 -0
  24. package/dist/index.js +1636 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/payload-types.d.ts +8985 -0
  27. package/dist/payload-types.js +1 -0
  28. package/dist/payload-types.js.map +1 -0
  29. package/package.json +74 -0
  30. package/src/api.ts +387 -0
  31. package/src/blog-listing.ts +75 -0
  32. package/src/browser.ts +24 -0
  33. package/src/client.ts +144 -0
  34. package/src/cms-to-href.ts +70 -0
  35. package/src/cms.ts +86 -0
  36. package/src/collections/appearance.ts +94 -0
  37. package/src/collections/areas.ts +29 -0
  38. package/src/collections/authors.ts +27 -0
  39. package/src/collections/banners.ts +14 -0
  40. package/src/collections/categories.ts +111 -0
  41. package/src/collections/forms.ts +29 -0
  42. package/src/collections/header-footer.ts +19 -0
  43. package/src/collections/image-links.ts +14 -0
  44. package/src/collections/media.ts +18 -0
  45. package/src/collections/options.ts +10 -0
  46. package/src/collections/pages.ts +83 -0
  47. package/src/collections/posts.ts +249 -0
  48. package/src/collections/project.ts +16 -0
  49. package/src/collections/questions.ts +35 -0
  50. package/src/collections/seo.ts +10 -0
  51. package/src/collections/tags.ts +25 -0
  52. package/src/collections/team-members.ts +79 -0
  53. package/src/config-time.ts +98 -0
  54. package/src/context.ts +12 -0
  55. package/src/decode-html.ts +8 -0
  56. package/src/form-proxy/cms-client.ts +95 -0
  57. package/src/form-proxy/cms-errors.ts +73 -0
  58. package/src/form-proxy/cms-write.ts +44 -0
  59. package/src/form-proxy/http.ts +96 -0
  60. package/src/form-proxy/index.ts +73 -0
  61. package/src/form-proxy/rate-limit.ts +46 -0
  62. package/src/form-proxy/submissions.ts +88 -0
  63. package/src/form-proxy/types.ts +23 -0
  64. package/src/form-proxy/upload-policy.ts +92 -0
  65. package/src/form-proxy/uploads.ts +81 -0
  66. package/src/home-page.ts +83 -0
  67. package/src/index.ts +68 -0
  68. package/src/loader.ts +83 -0
  69. package/src/locales.ts +80 -0
  70. package/src/payload-types.ts +10854 -0
  71. package/src/placeholder.ts +9 -0
  72. package/src/resolve-menu-items.ts +184 -0
  73. package/src/routes.ts +184 -0
@@ -0,0 +1,73 @@
1
+ // Which of a form's own fields the CMS refused, read out of a Payload error
2
+ // body.
3
+ //
4
+ // Since postedin/cms#279 a submission is validated before it is written:
5
+ // required answers, email shape, manual option lists and an upload id that
6
+ // must still name a live `pending` file of this form. A refusal is a `400`
7
+ // carrying a Payload `ValidationError`, which names each failed field in
8
+ // `errors[].path` as `data.<fieldName>` — the same name the browser posted.
9
+ //
10
+ // Only those names travel any further. The CMS's own wording does not: it is
11
+ // resolved in the CMS request's language rather than the visitor's — Payload
12
+ // takes `req.t` from the `payload-lng` cookie or the `Accept-Language` header,
13
+ // never from the `?locale` this proxy sends, so a Spanish visitor would be
14
+ // answered in English — and the rest of the body names internal fields. The
15
+ // browser already knows every field name it posted, so handing the names back
16
+ // discloses nothing, and the message the visitor reads is the site's own: form
17
+ // chrome, like every other validation string.
18
+
19
+ // A path this site can place against a field it rendered. `honeypot`, `form`
20
+ // and `data` are refusals about the request rather than about an answer, and
21
+ // are deliberately not matched: there is no field to hang them on.
22
+ const FIELD_PATH = /^data\.([A-Za-z0-9_-]+)$/;
23
+
24
+ // A form has tens of fields, not hundreds. The cap is for a body that is not
25
+ // what we think it is rather than for one of ours.
26
+ const MAX_FIELDS = 50;
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ // Payload nests a ValidationError's per-field errors under `data.errors`, and
33
+ // wraps that in the top-level `errors` array every REST failure carries. Both
34
+ // levels are read, so a plainer `{ errors: [{ path }] }` is understood too.
35
+ function pathsOf(entry: unknown): string[] {
36
+ if (!isRecord(entry)) {
37
+ return [];
38
+ }
39
+
40
+ const paths: string[] = [];
41
+ if (typeof entry.path === 'string') {
42
+ paths.push(entry.path);
43
+ }
44
+
45
+ const data = entry.data;
46
+ if (isRecord(data) && Array.isArray(data.errors)) {
47
+ for (const nested of data.errors) {
48
+ if (isRecord(nested) && typeof nested.path === 'string') {
49
+ paths.push(nested.path);
50
+ }
51
+ }
52
+ }
53
+
54
+ return paths;
55
+ }
56
+
57
+ export function refusedFieldNames(body: unknown): string[] {
58
+ if (!isRecord(body) || !Array.isArray(body.errors)) {
59
+ return [];
60
+ }
61
+
62
+ const names = new Set<string>();
63
+ for (const entry of body.errors) {
64
+ for (const path of pathsOf(entry)) {
65
+ const match = FIELD_PATH.exec(path);
66
+ if (match) {
67
+ names.add(match[1]);
68
+ }
69
+ }
70
+ }
71
+
72
+ return [...names].slice(0, MAX_FIELDS);
73
+ }
@@ -0,0 +1,44 @@
1
+ import { jsonResponse } from './http';
2
+ import type { CmsResult, FormOwnershipCheck } from './types';
3
+
4
+ export interface WriteShape {
5
+ // Body the browser gets on success; only the new document id goes in.
6
+ success(id: string | undefined): unknown;
7
+ // CMS statuses the client branches on, each with a fixed message.
8
+ passThrough: Record<number, string>;
9
+ // Fixed message for every other failure, answered as 502.
10
+ failed: string;
11
+ }
12
+
13
+ // Runs a CMS write on behalf of a visitor and shapes the outcome. Never echoes
14
+ // the CMS body: at depth > 0 it carries the populated project (deploy hooks,
15
+ // tokens) and its errors name internal fields.
16
+ export async function writeThroughCms(
17
+ cms: FormOwnershipCheck,
18
+ formId: string,
19
+ write: () => Promise<CmsResult>,
20
+ shape: WriteShape,
21
+ ): Promise<Response> {
22
+ let result: CmsResult;
23
+ try {
24
+ if (!(await cms.formBelongsToProject(formId))) {
25
+ return jsonResponse({ error: 'Invalid form id' }, 400);
26
+ }
27
+ result = await write();
28
+ } catch {
29
+ return jsonResponse({ error: shape.failed }, 502);
30
+ }
31
+
32
+ if (result.status >= 200 && result.status < 300) {
33
+ return jsonResponse(shape.success(result.id), 201);
34
+ }
35
+ const message = shape.passThrough[result.status];
36
+ if (message !== undefined) {
37
+ // Field *names* only, and only ones the CMS named: enough for the browser
38
+ // to put an error under the right input, while the message it shows stays
39
+ // the site's own. The CMS's wording never travels.
40
+ const fields = result.fields?.length ? { fields: result.fields } : {};
41
+ return jsonResponse({ error: message, ...fields }, result.status);
42
+ }
43
+ return jsonResponse({ error: shape.failed }, 502);
44
+ }
@@ -0,0 +1,96 @@
1
+ export function jsonResponse(body: unknown, status: number) {
2
+ return new Response(JSON.stringify(body), {
3
+ status,
4
+ headers: { 'Content-Type': 'application/json' },
5
+ });
6
+ }
7
+
8
+ function declaredLength(request: Request): number | null {
9
+ const raw = request.headers.get('Content-Length');
10
+ if (raw === null) {
11
+ return null;
12
+ }
13
+ const n = Number(raw);
14
+ return Number.isFinite(n) ? n : null;
15
+ }
16
+
17
+ export function exceedsLimit(request: Request, maxBytes: number): boolean {
18
+ const declared = declaredLength(request);
19
+ return declared !== null && declared > maxBytes;
20
+ }
21
+
22
+ export type BodyRead<T> =
23
+ | { ok: true; value: T }
24
+ | { ok: false; response: Response };
25
+
26
+ // Reads a body without trusting Content-Length: the declared size is a cheap
27
+ // early exit, the actual byte count is what enforces the cap.
28
+ export async function readBodyWithin(
29
+ request: Request,
30
+ maxBytes: number,
31
+ tooLarge: string,
32
+ ): Promise<BodyRead<ArrayBuffer>> {
33
+ if (exceedsLimit(request, maxBytes)) {
34
+ return { ok: false, response: jsonResponse({ error: tooLarge }, 413) };
35
+ }
36
+
37
+ const bytes = await request.arrayBuffer();
38
+ if (bytes.byteLength > maxBytes) {
39
+ return { ok: false, response: jsonResponse({ error: tooLarge }, 413) };
40
+ }
41
+ return { ok: true, value: bytes };
42
+ }
43
+
44
+ export async function readJsonBody(
45
+ request: Request,
46
+ maxBytes: number,
47
+ ): Promise<BodyRead<unknown>> {
48
+ const read = await readBodyWithin(request, maxBytes, 'Payload too large');
49
+ if (!read.ok) {
50
+ return read;
51
+ }
52
+
53
+ try {
54
+ return {
55
+ ok: true,
56
+ value: JSON.parse(new TextDecoder().decode(read.value)),
57
+ };
58
+ } catch {
59
+ return {
60
+ ok: false,
61
+ response: jsonResponse({ error: 'Invalid JSON' }, 400),
62
+ };
63
+ }
64
+ }
65
+
66
+ export async function readMultipartBody(
67
+ request: Request,
68
+ maxBytes: number,
69
+ ): Promise<BodyRead<FormData>> {
70
+ const contentType = request.headers.get('Content-Type') ?? '';
71
+ if (!contentType.includes('multipart/form-data')) {
72
+ return {
73
+ ok: false,
74
+ response: jsonResponse({ error: 'Expected multipart form data' }, 400),
75
+ };
76
+ }
77
+
78
+ const read = await readBodyWithin(request, maxBytes, 'File too large');
79
+ if (!read.ok) {
80
+ return read;
81
+ }
82
+
83
+ try {
84
+ // Re-wrapping the capped bytes keeps the multipart parser off the raw
85
+ // request stream, so it can never buffer more than we already allowed.
86
+ const value = await new Response(read.value, {
87
+ headers: { 'Content-Type': contentType },
88
+ }).formData();
89
+ return { ok: true, value };
90
+ } catch {
91
+ return {
92
+ ok: false,
93
+ response: jsonResponse({ error: 'Invalid multipart body' }, 400),
94
+ };
95
+ }
96
+ }
@@ -0,0 +1,73 @@
1
+ import type { Project } from '../payload-types';
2
+ import type { FormProxyCmsClient } from './cms-client';
3
+ import { createRateLimiter, type RateLimiter } from './rate-limit';
4
+ import { handleFormSubmission } from './submissions';
5
+ import type { ProxyDeps } from './types';
6
+ import { handleFormUpload } from './uploads';
7
+
8
+ export type { FormProxyCmsClient } from './cms-client';
9
+ export { createFormProxyCmsClient } from './cms-client';
10
+ export { refusedFieldNames } from './cms-errors';
11
+ export { createRateLimiter, type RateLimiter } from './rate-limit';
12
+ export {
13
+ handleFormSubmission,
14
+ MAX_SUBMISSION_BYTES,
15
+ type SubmissionsCmsClient,
16
+ } from './submissions';
17
+ export type { CmsResult, FormOwnershipCheck, ProxyDeps } from './types';
18
+ export { handleFormUpload, type UploadsCmsClient } from './uploads';
19
+
20
+ /**
21
+ * The part of an Astro `APIContext` the proxy reads. `clientAddress` is a
22
+ * getter that throws on a prerendered route, which is why it is read guarded.
23
+ */
24
+ export interface RequestContext {
25
+ readonly clientAddress: string;
26
+ }
27
+
28
+ function clientAddress(context: RequestContext): string {
29
+ try {
30
+ return context.clientAddress;
31
+ } catch {
32
+ return 'unknown';
33
+ }
34
+ }
35
+
36
+ export type FormProxy = ReturnType<typeof createFormProxy>;
37
+
38
+ export function createFormProxy(
39
+ cms: FormProxyCmsClient,
40
+ project: () => Promise<Project>,
41
+ locales: readonly string[],
42
+ ) {
43
+ // One instance per function container; see rate-limit.ts for the caveat.
44
+ const submissionLimiter = createRateLimiter({
45
+ limit: 10,
46
+ windowMs: 60_000,
47
+ });
48
+ const uploadLimiter = createRateLimiter({ limit: 20, windowMs: 60_000 });
49
+
50
+ // Everything a handler needs from the running site: the tenant our key
51
+ // writes into, the CMS client, and who is asking.
52
+ async function buildProxyDeps(
53
+ context: RequestContext,
54
+ rateLimiter: RateLimiter,
55
+ ): Promise<ProxyDeps<FormProxyCmsClient>> {
56
+ return {
57
+ projectId: (await project()).id,
58
+ cms,
59
+ clientAddress: clientAddress(context),
60
+ rateLimiter,
61
+ locales,
62
+ };
63
+ }
64
+
65
+ return {
66
+ cms,
67
+ submissionLimiter,
68
+ uploadLimiter,
69
+ buildProxyDeps,
70
+ handleFormSubmission,
71
+ handleFormUpload,
72
+ };
73
+ }
@@ -0,0 +1,46 @@
1
+ export interface RateLimiter {
2
+ allow(key: string): boolean;
3
+ }
4
+
5
+ interface Options {
6
+ limit: number;
7
+ windowMs: number;
8
+ now?: () => number;
9
+ }
10
+
11
+ // Fixed-window counter held in memory. On Vercel each function instance keeps
12
+ // its own table, so this is a best-effort brake on bursts from one client, not
13
+ // a global quota — the Vercel Firewall rate-limit rule is the authoritative one.
14
+ export function createRateLimiter({
15
+ limit,
16
+ windowMs,
17
+ now = Date.now,
18
+ }: Options): RateLimiter {
19
+ const windows = new Map<string, { start: number; count: number }>();
20
+
21
+ function sweep(current: number) {
22
+ for (const [key, entry] of windows) {
23
+ if (current - entry.start > windowMs) {
24
+ windows.delete(key);
25
+ }
26
+ }
27
+ }
28
+
29
+ return {
30
+ allow(key) {
31
+ const current = now();
32
+ const entry = windows.get(key);
33
+
34
+ if (!entry || current - entry.start > windowMs) {
35
+ if (windows.size > 10_000) {
36
+ sweep(current);
37
+ }
38
+ windows.set(key, { start: current, count: 1 });
39
+ return true;
40
+ }
41
+
42
+ entry.count += 1;
43
+ return entry.count <= limit;
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,88 @@
1
+ import type { Locale } from '../locales';
2
+ import { writeThroughCms } from './cms-write';
3
+ import { jsonResponse, readJsonBody } from './http';
4
+ import type { CmsResult, FormOwnershipCheck, ProxyDeps } from './types';
5
+
6
+ export interface SubmissionsCmsClient extends FormOwnershipCheck {
7
+ createSubmission(
8
+ payload: {
9
+ form: string;
10
+ project: string;
11
+ data: Record<string, unknown>;
12
+ honeypot: string;
13
+ },
14
+ locale?: Locale,
15
+ ): Promise<CmsResult>;
16
+ }
17
+
18
+ // Generous for a contact form, tiny for anything trying to hurt the CMS
19
+ export const MAX_SUBMISSION_BYTES = 64 * 1024;
20
+
21
+ // Every locale the CMS schema knows: the default when a site names none.
22
+ const CMS_LOCALES: readonly Locale[] = ['es', 'en'];
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
26
+ }
27
+
28
+ // The field ships empty; a real visitor never touches it. Anything else in
29
+ // there, whatever its type, was put there by a script.
30
+ function honeypotFilled(value: unknown): boolean {
31
+ return value !== undefined && value !== null && value !== '';
32
+ }
33
+
34
+ export async function handleFormSubmission(
35
+ request: Request,
36
+ deps: ProxyDeps<SubmissionsCmsClient>,
37
+ ): Promise<Response> {
38
+ if (deps.rateLimiter && !deps.rateLimiter.allow(deps.clientAddress)) {
39
+ return jsonResponse({ error: 'Too many requests' }, 429);
40
+ }
41
+
42
+ const read = await readJsonBody(request, MAX_SUBMISSION_BYTES);
43
+ if (!read.ok) {
44
+ return read.response;
45
+ }
46
+ const body = read.value;
47
+
48
+ if (!isRecord(body)) {
49
+ return jsonResponse({ error: 'Invalid data' }, 400);
50
+ }
51
+ if (typeof body.form !== 'string' || !body.form) {
52
+ return jsonResponse({ error: 'Invalid form id' }, 400);
53
+ }
54
+ // The browser does not get to pick the tenant our API key writes into
55
+ if (body.project !== deps.projectId) {
56
+ return jsonResponse({ error: 'Invalid project id' }, 400);
57
+ }
58
+ if (!isRecord(body.data)) {
59
+ return jsonResponse({ error: 'Invalid data' }, 400);
60
+ }
61
+ // A filled honeypot is a bot; don't spend a CMS call on it
62
+ if (honeypotFilled(body.honeypot)) {
63
+ return jsonResponse({ error: 'Invalid submission' }, 400);
64
+ }
65
+
66
+ const form = body.form;
67
+ const data = body.data;
68
+ const locales = deps.locales ?? CMS_LOCALES;
69
+ const locale =
70
+ typeof body.locale === 'string' && locales.includes(body.locale)
71
+ ? (body.locale as Locale)
72
+ : undefined;
73
+
74
+ return writeThroughCms(
75
+ deps.cms,
76
+ form,
77
+ () =>
78
+ deps.cms.createSubmission(
79
+ { form, project: deps.projectId, data, honeypot: '' },
80
+ locale,
81
+ ),
82
+ {
83
+ success: (id) => ({ ok: true, id }),
84
+ passThrough: { 400: 'Invalid submission' },
85
+ failed: 'Submission failed',
86
+ },
87
+ );
88
+ }
@@ -0,0 +1,23 @@
1
+ import type { RateLimiter } from './rate-limit';
2
+
3
+ // What a CMS write gives back to the handlers: the status to branch on, the
4
+ // new document id, and — when the CMS refused the write field by field — the
5
+ // names of the form's own fields it would not accept. Never the document
6
+ // itself, and never the CMS's own wording; see cms-errors.ts.
7
+ export type CmsResult = { status: number; id?: string; fields?: string[] };
8
+
9
+ export interface FormOwnershipCheck {
10
+ formBelongsToProject(formId: string): Promise<boolean>;
11
+ }
12
+
13
+ export interface ProxyDeps<TClient extends FormOwnershipCheck> {
14
+ projectId: string;
15
+ cms: TClient;
16
+ clientAddress: string;
17
+ rateLimiter?: RateLimiter;
18
+ /**
19
+ * The locales a submission may name. Defaults to every locale the CMS
20
+ * schema knows.
21
+ */
22
+ locales?: readonly string[];
23
+ }
@@ -0,0 +1,92 @@
1
+ // What the CMS's `form-uploads` collection accepts, mirrored here so the
2
+ // browser, the proxy route and the CMS agree about the same file.
3
+ //
4
+ // The CMS is the authority: `src/blocks/Form/uploadPolicy.ts` there declares
5
+ // the size limit and the MIME map, and since postedin/cms#278 it enforces both
6
+ // — a file over the limit is answered 413, a type outside the list 400. It also
7
+ // *sniffs the bytes*, which nothing on this side can do, so a file whose
8
+ // contents do not match its name passes every check here and is still refused.
9
+ // Everything below is therefore an early, friendlier "no", never a guarantee of
10
+ // a "yes".
11
+ //
12
+ // A leaf module with no imports: the React form islands read it in the browser
13
+ // and the proxy handler reads it on the server.
14
+
15
+ /** 10 MB, matching `MAX_UPLOAD_FILE_SIZE` in the CMS. */
16
+ export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
17
+
18
+ /**
19
+ * Room for the multipart envelope around a max-size file.
20
+ *
21
+ * Here rather than beside the handler that reads it so that every limit the
22
+ * upload path is held to is in one file, which is also the file the browser
23
+ * imports — importing it from the handler would pull the handler into the
24
+ * client bundle.
25
+ */
26
+ export const MAX_REQUEST_BYTES = MAX_UPLOAD_BYTES + 64 * 1024;
27
+
28
+ /**
29
+ * The MIME types behind each choice in a file field's `allowedFileTypes`, and
30
+ * — flattened — the whole of what the CMS accepts.
31
+ *
32
+ * `application/x-cfb` is what a legacy `.doc` or `.xls` sniffs as; the CMS
33
+ * carries it for that reason and it is kept here so the two lists can be
34
+ * compared line by line.
35
+ */
36
+ export const FILE_TYPE_MIME_MAP: Record<string, string[]> = {
37
+ images: [
38
+ 'image/jpeg',
39
+ 'image/png',
40
+ 'image/gif',
41
+ 'image/webp',
42
+ 'image/svg+xml',
43
+ ],
44
+ documents: [
45
+ 'application/pdf',
46
+ 'application/msword',
47
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
48
+ 'application/vnd.ms-excel',
49
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
50
+ 'application/x-cfb',
51
+ 'text/plain',
52
+ ],
53
+ videos: ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'],
54
+ audio: ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm'],
55
+ };
56
+
57
+ export const ALLOWED_UPLOAD_MIME_TYPES: string[] = [
58
+ ...new Set(Object.values(FILE_TYPE_MIME_MAP).flat()),
59
+ ];
60
+
61
+ /** The `accept` attribute for a file field's declared `allowedFileTypes`. */
62
+ export function acceptAttribute(types?: string[] | null): string {
63
+ if (!types || types.length === 0) {
64
+ return '';
65
+ }
66
+ return types.flatMap((type) => FILE_TYPE_MIME_MAP[type] ?? []).join(',');
67
+ }
68
+
69
+ /** Why the CMS would refuse this file, as far as the browser can tell. */
70
+ export type UploadRefusal = 'size' | 'type';
71
+
72
+ /**
73
+ * The refusal a file would earn before it is sent, or `null` to send it.
74
+ *
75
+ * A file the browser reports no type for is sent: an empty `type` is what
76
+ * Chrome gives an extension it does not recognise, and the CMS sniffs the bytes
77
+ * anyway, so guessing here would refuse files the CMS would have taken. The
78
+ * types a field does not ask for are left to the `accept` attribute and to the
79
+ * CMS; this only holds a file to what the collection accepts at all.
80
+ */
81
+ export function uploadRefusal(file: {
82
+ size: number;
83
+ type: string;
84
+ }): UploadRefusal | null {
85
+ if (file.size > MAX_UPLOAD_BYTES) {
86
+ return 'size';
87
+ }
88
+ if (file.type && !ALLOWED_UPLOAD_MIME_TYPES.includes(file.type)) {
89
+ return 'type';
90
+ }
91
+ return null;
92
+ }
@@ -0,0 +1,81 @@
1
+ import { writeThroughCms } from './cms-write';
2
+ import { jsonResponse, readMultipartBody } from './http';
3
+ import { MAX_REQUEST_BYTES, MAX_UPLOAD_BYTES } from './upload-policy';
4
+ import type { CmsResult, FormOwnershipCheck, ProxyDeps } from './types';
5
+
6
+ export interface UploadsCmsClient extends FormOwnershipCheck {
7
+ createUpload(
8
+ file: File,
9
+ payload: { form: string; project: string },
10
+ ): Promise<CmsResult>;
11
+ }
12
+
13
+ function parsePayload(
14
+ raw: FormDataEntryValue | null,
15
+ ): { form: string; project: unknown } | null {
16
+ if (typeof raw !== 'string') {
17
+ return null;
18
+ }
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ if (
22
+ typeof parsed !== 'object' ||
23
+ parsed === null ||
24
+ typeof parsed.form !== 'string' ||
25
+ !parsed.form
26
+ ) {
27
+ return null;
28
+ }
29
+ return { form: parsed.form, project: parsed.project };
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function handleFormUpload(
36
+ request: Request,
37
+ deps: ProxyDeps<UploadsCmsClient>,
38
+ ): Promise<Response> {
39
+ if (deps.rateLimiter && !deps.rateLimiter.allow(deps.clientAddress)) {
40
+ return jsonResponse({ error: 'Too many requests' }, 429);
41
+ }
42
+
43
+ const read = await readMultipartBody(request, MAX_REQUEST_BYTES);
44
+ if (!read.ok) {
45
+ return read.response;
46
+ }
47
+ const formData = read.value;
48
+
49
+ const file = formData.get('file');
50
+ if (!(file instanceof File)) {
51
+ return jsonResponse({ error: 'Missing file' }, 400);
52
+ }
53
+ if (file.size > MAX_UPLOAD_BYTES) {
54
+ return jsonResponse({ error: 'File too large' }, 413);
55
+ }
56
+
57
+ const payload = parsePayload(formData.get('_payload'));
58
+ if (!payload) {
59
+ return jsonResponse({ error: 'Invalid form id' }, 400);
60
+ }
61
+ // The browser does not get to pick the tenant our API key writes into
62
+ if (payload.project !== deps.projectId) {
63
+ return jsonResponse({ error: 'Invalid project id' }, 400);
64
+ }
65
+
66
+ // The client reads doc.id and branches on 400 (type) / 413 (size)
67
+ return writeThroughCms(
68
+ deps.cms,
69
+ payload.form,
70
+ () =>
71
+ deps.cms.createUpload(file, {
72
+ form: payload.form,
73
+ project: deps.projectId,
74
+ }),
75
+ {
76
+ success: (id) => ({ doc: { id } }),
77
+ passThrough: { 400: 'Invalid file', 413: 'File too large' },
78
+ failed: 'Upload failed',
79
+ },
80
+ );
81
+ }