@aglyn/aglyn 1.0.0-beta.288 → 1.0.0-beta.290

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 (37) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/docs-help-sections.generated.js +5 -0
  3. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  4. package/src/lib/app-utils/docs-help.generated.d.ts +7 -1
  5. package/src/lib/app-utils/docs-help.generated.js +29 -0
  6. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  7. package/src/lib/app-utils/docs-index.generated.js +131 -12
  8. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  9. package/src/lib/app-utils/element-motion.js +1 -0
  10. package/src/lib/app-utils/element-motion.js.map +1 -1
  11. package/src/lib/app-utils/host-status.d.ts +2 -1
  12. package/src/lib/app-utils/host-status.js +10 -0
  13. package/src/lib/app-utils/host-status.js.map +1 -1
  14. package/src/lib/app-utils/screen-slug.d.ts +5 -5
  15. package/src/lib/app-utils/screen-slug.js +5 -5
  16. package/src/lib/app-utils/screen-slug.js.map +1 -1
  17. package/src/lib/app-utils/search-indexing.d.ts +18 -0
  18. package/src/lib/app-utils/search-indexing.js +16 -2
  19. package/src/lib/app-utils/search-indexing.js.map +1 -1
  20. package/src/lib/app-utils/server.d.ts +1 -0
  21. package/src/lib/app-utils/server.js +2 -0
  22. package/src/lib/app-utils/server.js.map +1 -1
  23. package/src/lib/app-utils/site-preview.d.ts +153 -0
  24. package/src/lib/app-utils/site-preview.js +167 -0
  25. package/src/lib/app-utils/site-preview.js.map +1 -0
  26. package/src/lib/app-utils/upload-inspection.js +36 -0
  27. package/src/lib/app-utils/upload-inspection.js.map +1 -1
  28. package/src/lib/app-utils/visitor-upload-client.d.ts +47 -0
  29. package/src/lib/app-utils/visitor-upload-client.js +63 -0
  30. package/src/lib/app-utils/visitor-upload-client.js.map +1 -0
  31. package/src/lib/app-utils/visitor-upload.d.ts +140 -0
  32. package/src/lib/app-utils/visitor-upload.js +312 -0
  33. package/src/lib/app-utils/visitor-upload.js.map +1 -0
  34. package/src/lib/foundation/definitions/platform.types.d.ts +27 -0
  35. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  36. package/src/lib/plugin-manager/feature-plugins.d.ts +17 -1
  37. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * FILES A VISITOR SENDS A BUSINESS (AGL-4096).
18
+ *
19
+ * Artwork for a quote, a logo with a request form: files someone without an
20
+ * account hands a business through its site. This module is the policy, pure
21
+ * and shared by the browser and the server:
22
+ *
23
+ * - which files are taken ({@link VISITOR_UPLOAD_TYPES}: PNG, JPEG, WebP,
24
+ * GIF, PDF, SVG, AI, EPS, PSD, TIFF), how big ({@link VISITOR_UPLOAD_MAX_BYTES})
25
+ * and how many at once ({@link VISITOR_UPLOAD_MAX_FILES});
26
+ * - what a file's type IS ({@link resolveVisitorUploadType}): read from its
27
+ * name and checked against what the browser said, so a `.psd` a browser
28
+ * labels `application/octet-stream` is still a Photoshop file and a `.png`
29
+ * labelled `text/html` is nothing;
30
+ * - where its bytes live ({@link visitorUploadPendingPath} while it is on its
31
+ * way, {@link visitorUploadFinalPath} once it is checked);
32
+ * - the record a finished upload is ({@link VisitorUploadFile}).
33
+ *
34
+ * The server half (`@aglyn/tenant-data-admin/server/visitor-upload`) signs the
35
+ * upload, inspects the bytes and moves them; the browser half
36
+ * (`visitor-upload-client.ts`) sends them. A plugin that takes files (commerce
37
+ * for a quote's artwork, forms for a request) names its own `purpose` and the
38
+ * record it is for, and decides for itself who may upload for it.
39
+ *=========================================*/ /** The most one file may weigh. */ export const VISITOR_UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
40
+ /** The most files one submission carries. */ export const VISITOR_UPLOAD_MAX_FILES = 10;
41
+ /** How long the signed upload a ticket carries can be used, from its minting. */ export const VISITOR_UPLOAD_TICKET_TTL_MS = 15 * 60 * 1000;
42
+ /**
43
+ * Where uploads wait until they are checked. Top-level, never under `hosts/`:
44
+ * the bucket's lifecycle deletes whatever is still here a day after it
45
+ * arrived (`cloud/media-bucket-lifecycle.json`), which no rule may do under a
46
+ * prefix that holds live customer files.
47
+ */ export const VISITOR_UPLOAD_PENDING_PREFIX = 'visitor-uploads-pending/';
48
+ /** The honeypot field a form posts beside its files, as the forms plugin names it. */ export const VISITOR_UPLOAD_HONEYPOT_FIELD = 'website';
49
+ export const VISITOR_UPLOAD_TYPES = [
50
+ {
51
+ contentType: 'image/png',
52
+ extensions: [
53
+ '.png'
54
+ ],
55
+ label: 'PNG',
56
+ inline: true
57
+ },
58
+ {
59
+ contentType: 'image/jpeg',
60
+ extensions: [
61
+ '.jpg',
62
+ '.jpeg'
63
+ ],
64
+ label: 'JPEG',
65
+ inline: true,
66
+ aliases: [
67
+ 'image/jpg',
68
+ 'image/pjpeg'
69
+ ]
70
+ },
71
+ {
72
+ contentType: 'image/webp',
73
+ extensions: [
74
+ '.webp'
75
+ ],
76
+ label: 'WebP',
77
+ inline: true
78
+ },
79
+ {
80
+ contentType: 'image/gif',
81
+ extensions: [
82
+ '.gif'
83
+ ],
84
+ label: 'GIF',
85
+ inline: true
86
+ },
87
+ {
88
+ contentType: 'application/pdf',
89
+ extensions: [
90
+ '.pdf'
91
+ ],
92
+ label: 'PDF',
93
+ inline: false,
94
+ aliases: [
95
+ 'application/x-pdf'
96
+ ]
97
+ },
98
+ // Stripped of script on the way in, and still downloaded rather than drawn.
99
+ {
100
+ contentType: 'image/svg+xml',
101
+ extensions: [
102
+ '.svg'
103
+ ],
104
+ label: 'SVG',
105
+ inline: false
106
+ },
107
+ {
108
+ contentType: 'application/illustrator',
109
+ extensions: [
110
+ '.ai'
111
+ ],
112
+ label: 'AI',
113
+ inline: false,
114
+ aliases: [
115
+ 'application/postscript',
116
+ 'application/pdf',
117
+ 'application/vnd.adobe.illustrator'
118
+ ]
119
+ },
120
+ {
121
+ contentType: 'application/postscript',
122
+ extensions: [
123
+ '.eps'
124
+ ],
125
+ label: 'EPS',
126
+ inline: false,
127
+ aliases: [
128
+ 'application/eps',
129
+ 'image/eps',
130
+ 'image/x-eps',
131
+ 'application/x-eps'
132
+ ]
133
+ },
134
+ {
135
+ contentType: 'image/vnd.adobe.photoshop',
136
+ extensions: [
137
+ '.psd'
138
+ ],
139
+ label: 'PSD',
140
+ inline: false,
141
+ aliases: [
142
+ 'application/x-photoshop',
143
+ 'application/photoshop',
144
+ 'application/psd',
145
+ 'image/psd',
146
+ 'image/x-photoshop'
147
+ ]
148
+ },
149
+ {
150
+ contentType: 'image/tiff',
151
+ extensions: [
152
+ '.tif',
153
+ '.tiff'
154
+ ],
155
+ label: 'TIFF',
156
+ inline: false,
157
+ aliases: [
158
+ 'image/x-tiff'
159
+ ]
160
+ }
161
+ ];
162
+ /** Types a browser sends when it does not know; the name decides then. */ const UNKNOWN_TYPES = new Set([
163
+ '',
164
+ 'application/octet-stream',
165
+ 'binary/octet-stream',
166
+ 'application/unknown'
167
+ ]);
168
+ /** `PNG, JPEG, WebP, GIF, PDF, SVG, AI, EPS, PSD or TIFF`. */ export function describeVisitorUploadTypes() {
169
+ const labels = VISITOR_UPLOAD_TYPES.map((type)=>type.label);
170
+ return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`;
171
+ }
172
+ /** The file picker's `accept`: every extension and type taken. */ export const VISITOR_UPLOAD_ACCEPT = VISITOR_UPLOAD_TYPES.flatMap((type)=>[
173
+ ...type.extensions,
174
+ type.contentType
175
+ ]).join(',');
176
+ /** `25 MB`. */ export function formatVisitorUploadBytes(bytes) {
177
+ if (!(bytes > 0)) return '0 KB';
178
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
179
+ const mb = bytes / (1024 * 1024);
180
+ return `${mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`;
181
+ }
182
+ /** The extension of a file name, lower-case with its dot, or ''. */ function extensionOf(name) {
183
+ const dot = name.lastIndexOf('.');
184
+ return dot > 0 ? name.slice(dot).toLowerCase() : '';
185
+ }
186
+ /**
187
+ * The type a file is taken as, or null when it is not one this takes. The
188
+ * NAME decides, because a browser reports most design files as nothing or as
189
+ * something generic; the reported type only has to agree with it (be this
190
+ * type, one it is known by, or unknown). The bytes are checked against the
191
+ * answer after they arrive.
192
+ */ export function resolveVisitorUploadType(name, reportedType) {
193
+ var _byName_aliases;
194
+ const extension = extensionOf(String(name != null ? name : ''));
195
+ const reported = String(reportedType != null ? reportedType : '').split(';')[0].trim().toLowerCase();
196
+ const byName = VISITOR_UPLOAD_TYPES.find((type)=>type.extensions.includes(extension));
197
+ if (!byName) return null;
198
+ if (UNKNOWN_TYPES.has(reported) || reported === byName.contentType) return byName;
199
+ return ((_byName_aliases = byName.aliases) == null ? void 0 : _byName_aliases.includes(reported)) ? byName : null;
200
+ }
201
+ /** A stored type, as the list of types knows it; null for anything else. */ export function visitorUploadTypeOf(contentType) {
202
+ var _VISITOR_UPLOAD_TYPES_find;
203
+ return (_VISITOR_UPLOAD_TYPES_find = VISITOR_UPLOAD_TYPES.find((type)=>type.contentType === contentType)) != null ? _VISITOR_UPLOAD_TYPES_find : null;
204
+ }
205
+ /** Whether a stored file may be shown as a picture rather than downloaded. */ export function visitorUploadIsInline(contentType) {
206
+ var _visitorUploadTypeOf;
207
+ return ((_visitorUploadTypeOf = visitorUploadTypeOf(contentType)) == null ? void 0 : _visitorUploadTypeOf.inline) === true;
208
+ }
209
+ /**
210
+ * A file name safe to keep and to put in a download's header: no folders, no
211
+ * control or quoting characters, at most 120 characters, and ending in the
212
+ * extension of the type it was taken as.
213
+ */ export function safeVisitorUploadName(name, type) {
214
+ var _String_split_pop;
215
+ const last = (_String_split_pop = String(name != null ? name : '').split(/[\\/]/).pop()) != null ? _String_split_pop : '';
216
+ // Control characters, quotes and the characters a header or a path would read.
217
+ const base = Array.from(last).filter((character)=>{
218
+ const code = character.charCodeAt(0);
219
+ return code >= 32 && code !== 127 && !'"<>|:*?%;'.includes(character);
220
+ }).join('').replace(/\s+/g, ' ').trim();
221
+ const extension = extensionOf(base);
222
+ const stem = (type.extensions.includes(extension) ? base.slice(0, -extension.length) : base).trim();
223
+ const kept = (stem || 'file').slice(0, 120 - 6).trim() || 'file';
224
+ return `${kept}${type.extensions.includes(extension) ? extension : type.extensions[0]}`;
225
+ }
226
+ /**
227
+ * Checks what the browser asks to send, before anything is signed: one of
228
+ * the types taken, not empty, not over the size. The answer's `error` names
229
+ * the file and what to do.
230
+ */ export function checkVisitorUploadRequest(raw, options = {}) {
231
+ var _options_maxBytes;
232
+ var _name_split_pop;
233
+ const source = raw && typeof raw === 'object' ? raw : {};
234
+ const name = typeof source['name'] === 'string' ? source['name'].slice(0, 400) : '';
235
+ const size = Number(source['size']);
236
+ const shown = ((_name_split_pop = name.split(/[\\/]/).pop()) == null ? void 0 : _name_split_pop.trim()) || 'This file';
237
+ const type = resolveVisitorUploadType(name, typeof source['contentType'] === 'string' ? source['contentType'] : '');
238
+ if (!type) {
239
+ return {
240
+ ok: false,
241
+ code: 'type',
242
+ error: `${shown} can't be sent. Send a ${describeVisitorUploadTypes()} file.`
243
+ };
244
+ }
245
+ if (!Number.isSafeInteger(size) || size <= 0) {
246
+ return {
247
+ ok: false,
248
+ code: 'empty',
249
+ error: `${shown} is empty.`
250
+ };
251
+ }
252
+ const maxBytes = Math.min((_options_maxBytes = options.maxBytes) != null ? _options_maxBytes : VISITOR_UPLOAD_MAX_BYTES, VISITOR_UPLOAD_MAX_BYTES);
253
+ if (size > maxBytes) {
254
+ return {
255
+ ok: false,
256
+ code: 'size',
257
+ error: `${shown} is ${formatVisitorUploadBytes(size)}. Files can be up to ${formatVisitorUploadBytes(maxBytes)}.`
258
+ };
259
+ }
260
+ return {
261
+ ok: true,
262
+ upload: {
263
+ name: safeVisitorUploadName(name, type),
264
+ type,
265
+ size
266
+ }
267
+ };
268
+ }
269
+ /** The record without its storage path. */ export function visitorUploadFileView(file) {
270
+ return {
271
+ id: file.id,
272
+ name: file.name,
273
+ contentType: file.contentType,
274
+ size: file.size,
275
+ uploadedAtMs: file.uploadedAtMs
276
+ };
277
+ }
278
+ /** A purpose or a record id as a path segment: letters, digits, `-` and `_`, and not too long. */ const SEGMENT = /^[A-Za-z0-9_-]{1,128}$/;
279
+ export function isVisitorUploadSegment(value) {
280
+ return typeof value === 'string' && SEGMENT.test(value);
281
+ }
282
+ /** Whether every part of a scope can be a path segment. */ export function isVisitorUploadScope(scope) {
283
+ return isVisitorUploadSegment(scope.hostId) && isVisitorUploadSegment(scope.purpose) && isVisitorUploadSegment(scope.purposeId);
284
+ }
285
+ /** Where a file waits between the browser's upload and its check. */ export function visitorUploadPendingPath(scope, uploadId) {
286
+ return `${VISITOR_UPLOAD_PENDING_PREFIX}${scope.hostId}/${scope.purpose}/${scope.purposeId}/${uploadId}`;
287
+ }
288
+ /** The folder every kept file of one purpose and record is in. */ export function visitorUploadFolder(scope) {
289
+ return `hosts/${scope.hostId}/private/uploads/${scope.purpose}/${scope.purposeId}/`;
290
+ }
291
+ /** Where a checked file is kept: private to the site, with no download token. */ export function visitorUploadFinalPath(scope, uploadId) {
292
+ return `${visitorUploadFolder(scope)}${uploadId}`;
293
+ }
294
+ /**
295
+ * The files a browser picked, checked before any is sent: how many, and each
296
+ * one's type and size. `already` is how many the submission holds.
297
+ */ export function checkVisitorUploadSelection(files, already = 0, maxFiles = VISITOR_UPLOAD_MAX_FILES) {
298
+ if (already + files.length > maxFiles) {
299
+ return `Send up to ${maxFiles} files at a time.`;
300
+ }
301
+ for (const file of files){
302
+ const checked = checkVisitorUploadRequest({
303
+ name: file.name,
304
+ contentType: file.type,
305
+ size: file.size
306
+ });
307
+ if ('error' in checked) return checked.error;
308
+ }
309
+ return null;
310
+ }
311
+
312
+ //# sourceMappingURL=visitor-upload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/visitor-upload.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * FILES A VISITOR SENDS A BUSINESS (AGL-4096).\n *\n * Artwork for a quote, a logo with a request form: files someone without an\n * account hands a business through its site. This module is the policy, pure\n * and shared by the browser and the server:\n *\n * - which files are taken ({@link VISITOR_UPLOAD_TYPES}: PNG, JPEG, WebP,\n * GIF, PDF, SVG, AI, EPS, PSD, TIFF), how big ({@link VISITOR_UPLOAD_MAX_BYTES})\n * and how many at once ({@link VISITOR_UPLOAD_MAX_FILES});\n * - what a file's type IS ({@link resolveVisitorUploadType}): read from its\n * name and checked against what the browser said, so a `.psd` a browser\n * labels `application/octet-stream` is still a Photoshop file and a `.png`\n * labelled `text/html` is nothing;\n * - where its bytes live ({@link visitorUploadPendingPath} while it is on its\n * way, {@link visitorUploadFinalPath} once it is checked);\n * - the record a finished upload is ({@link VisitorUploadFile}).\n *\n * The server half (`@aglyn/tenant-data-admin/server/visitor-upload`) signs the\n * upload, inspects the bytes and moves them; the browser half\n * (`visitor-upload-client.ts`) sends them. A plugin that takes files (commerce\n * for a quote's artwork, forms for a request) names its own `purpose` and the\n * record it is for, and decides for itself who may upload for it.\n *=========================================*/\n\n/** The most one file may weigh. */\nexport const VISITOR_UPLOAD_MAX_BYTES = 25 * 1024 * 1024\n\n/** The most files one submission carries. */\nexport const VISITOR_UPLOAD_MAX_FILES = 10\n\n/** How long the signed upload a ticket carries can be used, from its minting. */\nexport const VISITOR_UPLOAD_TICKET_TTL_MS = 15 * 60 * 1000\n\n/**\n * Where uploads wait until they are checked. Top-level, never under `hosts/`:\n * the bucket's lifecycle deletes whatever is still here a day after it\n * arrived (`cloud/media-bucket-lifecycle.json`), which no rule may do under a\n * prefix that holds live customer files.\n */\nexport const VISITOR_UPLOAD_PENDING_PREFIX = 'visitor-uploads-pending/'\n\n/** The honeypot field a form posts beside its files, as the forms plugin names it. */\nexport const VISITOR_UPLOAD_HONEYPOT_FIELD = 'website'\n\n/** One kind of file a visitor may send. */\nexport interface VisitorUploadType {\n /** The type the file is stored and served as. */\n contentType: string\n /** Lower-case, with the dot. The first is the one a file is renamed to when it has none. */\n extensions: readonly string[]\n /** What a person calls it. */\n label: string\n /**\n * Shown in the page as a picture. Only raster formats a browser draws and\n * that cannot carry script; everything else downloads.\n */\n inline: boolean\n /** Other types a browser reports for this kind of file. */\n aliases?: readonly string[]\n}\n\nexport const VISITOR_UPLOAD_TYPES: readonly VisitorUploadType[] = [\n { contentType: 'image/png', extensions: ['.png'], label: 'PNG', inline: true },\n { contentType: 'image/jpeg', extensions: ['.jpg', '.jpeg'], label: 'JPEG', inline: true, aliases: ['image/jpg', 'image/pjpeg'] },\n { contentType: 'image/webp', extensions: ['.webp'], label: 'WebP', inline: true },\n { contentType: 'image/gif', extensions: ['.gif'], label: 'GIF', inline: true },\n { contentType: 'application/pdf', extensions: ['.pdf'], label: 'PDF', inline: false, aliases: ['application/x-pdf'] },\n // Stripped of script on the way in, and still downloaded rather than drawn.\n { contentType: 'image/svg+xml', extensions: ['.svg'], label: 'SVG', inline: false },\n {\n contentType: 'application/illustrator',\n extensions: ['.ai'],\n label: 'AI',\n inline: false,\n aliases: ['application/postscript', 'application/pdf', 'application/vnd.adobe.illustrator'],\n },\n { contentType: 'application/postscript', extensions: ['.eps'], label: 'EPS', inline: false, aliases: ['application/eps', 'image/eps', 'image/x-eps', 'application/x-eps'] },\n {\n contentType: 'image/vnd.adobe.photoshop',\n extensions: ['.psd'],\n label: 'PSD',\n inline: false,\n aliases: ['application/x-photoshop', 'application/photoshop', 'application/psd', 'image/psd', 'image/x-photoshop'],\n },\n { contentType: 'image/tiff', extensions: ['.tif', '.tiff'], label: 'TIFF', inline: false, aliases: ['image/x-tiff'] },\n]\n\n/** Types a browser sends when it does not know; the name decides then. */\nconst UNKNOWN_TYPES = new Set(['', 'application/octet-stream', 'binary/octet-stream', 'application/unknown'])\n\n/** `PNG, JPEG, WebP, GIF, PDF, SVG, AI, EPS, PSD or TIFF`. */\nexport function describeVisitorUploadTypes(): string {\n const labels = VISITOR_UPLOAD_TYPES.map((type) => type.label)\n return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`\n}\n\n/** The file picker's `accept`: every extension and type taken. */\nexport const VISITOR_UPLOAD_ACCEPT = VISITOR_UPLOAD_TYPES.flatMap((type) => [\n ...type.extensions,\n type.contentType,\n]).join(',')\n\n/** `25 MB`. */\nexport function formatVisitorUploadBytes(bytes: number): string {\n if (!(bytes > 0)) return '0 KB'\n if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`\n const mb = bytes / (1024 * 1024)\n return `${mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`\n}\n\n/** The extension of a file name, lower-case with its dot, or ''. */\nfunction extensionOf(name: string): string {\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot).toLowerCase() : ''\n}\n\n/**\n * The type a file is taken as, or null when it is not one this takes. The\n * NAME decides, because a browser reports most design files as nothing or as\n * something generic; the reported type only has to agree with it (be this\n * type, one it is known by, or unknown). The bytes are checked against the\n * answer after they arrive.\n */\nexport function resolveVisitorUploadType(name: string, reportedType: string): VisitorUploadType | null {\n const extension = extensionOf(String(name ?? ''))\n const reported = String(reportedType ?? '').split(';')[0].trim().toLowerCase()\n const byName = VISITOR_UPLOAD_TYPES.find((type) => type.extensions.includes(extension))\n if (!byName) return null\n if (UNKNOWN_TYPES.has(reported) || reported === byName.contentType) return byName\n return byName.aliases?.includes(reported) ? byName : null\n}\n\n/** A stored type, as the list of types knows it; null for anything else. */\nexport function visitorUploadTypeOf(contentType: string): VisitorUploadType | null {\n return VISITOR_UPLOAD_TYPES.find((type) => type.contentType === contentType) ?? null\n}\n\n/** Whether a stored file may be shown as a picture rather than downloaded. */\nexport function visitorUploadIsInline(contentType: string): boolean {\n return visitorUploadTypeOf(contentType)?.inline === true\n}\n\n/**\n * A file name safe to keep and to put in a download's header: no folders, no\n * control or quoting characters, at most 120 characters, and ending in the\n * extension of the type it was taken as.\n */\nexport function safeVisitorUploadName(name: string, type: VisitorUploadType): string {\n const last = String(name ?? '').split(/[\\\\/]/).pop() ?? ''\n // Control characters, quotes and the characters a header or a path would read.\n const base = Array.from(last)\n .filter((character) => {\n const code = character.charCodeAt(0)\n return code >= 32 && code !== 127 && !'\"<>|:*?%;'.includes(character)\n })\n .join('')\n .replace(/\\s+/g, ' ')\n .trim()\n const extension = extensionOf(base)\n const stem = (type.extensions.includes(extension) ? base.slice(0, -extension.length) : base).trim()\n const kept = (stem || 'file').slice(0, 120 - 6).trim() || 'file'\n return `${kept}${type.extensions.includes(extension) ? extension : type.extensions[0]}`\n}\n\n/** What the browser asks to upload: a file's name, its reported type and its size. */\nexport interface VisitorUploadRequest {\n name: string\n contentType: string\n size: number\n}\n\n/** A request, checked: the type it is taken as and a safe name. */\nexport interface CheckedVisitorUpload {\n name: string\n type: VisitorUploadType\n size: number\n}\n\n/**\n * Checks what the browser asks to send, before anything is signed: one of\n * the types taken, not empty, not over the size. The answer's `error` names\n * the file and what to do.\n */\nexport function checkVisitorUploadRequest(\n raw: unknown,\n options: { maxBytes?: number } = {},\n): { ok: true; upload: CheckedVisitorUpload } | { ok: false; error: string; code: 'type' | 'size' | 'empty' } {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n const name = typeof source['name'] === 'string' ? source['name'].slice(0, 400) : ''\n const size = Number(source['size'])\n const shown = name.split(/[\\\\/]/).pop()?.trim() || 'This file'\n const type = resolveVisitorUploadType(name, typeof source['contentType'] === 'string' ? source['contentType'] : '')\n if (!type) {\n return {\n ok: false,\n code: 'type',\n error: `${shown} can't be sent. Send a ${describeVisitorUploadTypes()} file.`,\n }\n }\n if (!Number.isSafeInteger(size) || size <= 0) {\n return { ok: false, code: 'empty', error: `${shown} is empty.` }\n }\n const maxBytes = Math.min(options.maxBytes ?? VISITOR_UPLOAD_MAX_BYTES, VISITOR_UPLOAD_MAX_BYTES)\n if (size > maxBytes) {\n return {\n ok: false,\n code: 'size',\n error: `${shown} is ${formatVisitorUploadBytes(size)}. Files can be up to ${formatVisitorUploadBytes(maxBytes)}.`,\n }\n }\n return { ok: true, upload: { name: safeVisitorUploadName(name, type), type, size } }\n}\n\n/** A file a visitor or a member sent, once it is checked and kept. */\nexport interface VisitorUploadFile {\n /** Unique within its purpose and record. */\n id: string\n /** The name it was sent with, made safe. */\n name: string\n contentType: string\n /** Bytes, as stored (an SVG may shrink when its script is stripped). */\n size: number\n /** The private Storage object. Never sent to a visitor. */\n path: string\n uploadedAtMs: number\n}\n\n/** A file as a visitor's page may see it: no storage path. */\nexport type VisitorUploadFileView = Omit<VisitorUploadFile, 'path'>\n\n/** The record without its storage path. */\nexport function visitorUploadFileView(file: VisitorUploadFile): VisitorUploadFileView {\n return { id: file.id, name: file.name, contentType: file.contentType, size: file.size, uploadedAtMs: file.uploadedAtMs }\n}\n\n/** A purpose or a record id as a path segment: letters, digits, `-` and `_`, and not too long. */\nconst SEGMENT = /^[A-Za-z0-9_-]{1,128}$/\n\nexport function isVisitorUploadSegment(value: unknown): value is string {\n return typeof value === 'string' && SEGMENT.test(value)\n}\n\n/** What an upload is for: the site, what kind of thing (`quote-artwork`), and which one. */\nexport interface VisitorUploadScope {\n hostId: string\n purpose: string\n purposeId: string\n}\n\n/** Whether every part of a scope can be a path segment. */\nexport function isVisitorUploadScope(scope: Partial<VisitorUploadScope>): scope is VisitorUploadScope {\n return isVisitorUploadSegment(scope.hostId) && isVisitorUploadSegment(scope.purpose) && isVisitorUploadSegment(scope.purposeId)\n}\n\n/** Where a file waits between the browser's upload and its check. */\nexport function visitorUploadPendingPath(scope: VisitorUploadScope, uploadId: string): string {\n return `${VISITOR_UPLOAD_PENDING_PREFIX}${scope.hostId}/${scope.purpose}/${scope.purposeId}/${uploadId}`\n}\n\n/** The folder every kept file of one purpose and record is in. */\nexport function visitorUploadFolder(scope: VisitorUploadScope): string {\n return `hosts/${scope.hostId}/private/uploads/${scope.purpose}/${scope.purposeId}/`\n}\n\n/** Where a checked file is kept: private to the site, with no download token. */\nexport function visitorUploadFinalPath(scope: VisitorUploadScope, uploadId: string): string {\n return `${visitorUploadFolder(scope)}${uploadId}`\n}\n\n/**\n * The files a browser picked, checked before any is sent: how many, and each\n * one's type and size. `already` is how many the submission holds.\n */\nexport function checkVisitorUploadSelection(\n files: ReadonlyArray<{ name: string; type: string; size: number }>,\n already = 0,\n maxFiles = VISITOR_UPLOAD_MAX_FILES,\n): string | null {\n if (already + files.length > maxFiles) {\n return `Send up to ${maxFiles} files at a time.`\n }\n for (const file of files) {\n const checked = checkVisitorUploadRequest({ name: file.name, contentType: file.type, size: file.size })\n if ('error' in checked) return checked.error\n }\n return null\n}\n"],"names":["VISITOR_UPLOAD_MAX_BYTES","VISITOR_UPLOAD_MAX_FILES","VISITOR_UPLOAD_TICKET_TTL_MS","VISITOR_UPLOAD_PENDING_PREFIX","VISITOR_UPLOAD_HONEYPOT_FIELD","VISITOR_UPLOAD_TYPES","contentType","extensions","label","inline","aliases","UNKNOWN_TYPES","Set","describeVisitorUploadTypes","labels","map","type","slice","join","length","VISITOR_UPLOAD_ACCEPT","flatMap","formatVisitorUploadBytes","bytes","Math","max","round","mb","extensionOf","name","dot","lastIndexOf","toLowerCase","resolveVisitorUploadType","reportedType","byName","extension","String","reported","split","trim","find","includes","has","visitorUploadTypeOf","visitorUploadIsInline","safeVisitorUploadName","last","pop","base","Array","from","filter","character","code","charCodeAt","replace","stem","kept","checkVisitorUploadRequest","raw","options","source","size","Number","shown","ok","error","isSafeInteger","maxBytes","min","upload","visitorUploadFileView","file","id","uploadedAtMs","SEGMENT","isVisitorUploadSegment","value","test","isVisitorUploadScope","scope","hostId","purpose","purposeId","visitorUploadPendingPath","uploadId","visitorUploadFolder","visitorUploadFinalPath","checkVisitorUploadSelection","files","already","maxFiles","checked"],"mappings":"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"}
@@ -507,6 +507,33 @@ export interface AglynHost extends AglynDocument {
507
507
  authScreens?: HostAuthScreens;
508
508
  /** Maintenance mode (AGL-131): every path renders the 503 screen. */
509
509
  maintenance?: boolean;
510
+ /**
511
+ * Preview (AGL-4086): only the site's team, and Aglyn staff, see the site;
512
+ * the public gets the 503 page or a "Coming soon" notice at every address.
513
+ * Unlike `maintenance`, which replaces the site for EVERYONE, this keeps the
514
+ * whole site working for the people building it.
515
+ *
516
+ * ADMIN-SDK ONLY: written by `/api/hosts/preview` (a site admin) and by
517
+ * `/api/hosts/create` (the create flow's Start in preview choice). The
518
+ * console never writes it from the client, because flipping it public is a
519
+ * launch: it reports `site_published` once and drops every cached page.
520
+ */
521
+ preview?: boolean;
522
+ /**
523
+ * The pages in preview on a site that is otherwise public (AGL-4086):
524
+ * screen id → `true`. A page in preview answers the public with the site's
525
+ * 404 and its team with the page. Kept on the host rather than the screen
526
+ * so the middleware's verdict, which holds only the host document, can
527
+ * route the team around the cache at exactly those addresses.
528
+ */
529
+ previewScreens?: Record<ScreenUid, boolean>;
530
+ /**
531
+ * When the site was first public after a stretch in preview, or when it
532
+ * was put into preview while public (AGL-4086). Its presence is what tells
533
+ * the switch the site has been public before, so `site_published` is
534
+ * reported once and never again. Epoch ms. Admin-SDK only, with `preview`.
535
+ */
536
+ publicSinceMs?: number;
510
537
  /**
511
538
  * STAFF host takedown (AGL-1501) — the host scope of the panic button,
512
539
  * for an infected or abusive site. The same field family as the org's