@aglyn/aglyn 1.0.0-beta.290 → 1.0.0-beta.291
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/package.json +11 -11
- package/src/lib/app-utils/docs-help-sections.generated.js +1 -0
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +5 -5
- package/src/lib/app-utils/docs-help.generated.js +17 -2
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +60 -4
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/element-motion.js +2 -0
- package/src/lib/app-utils/element-motion.js.map +1 -1
- package/src/lib/app-utils/forms.d.ts +87 -2
- package/src/lib/app-utils/forms.js +79 -5
- package/src/lib/app-utils/forms.js.map +1 -1
- package/src/lib/app-utils/host-tokens.d.ts +18 -0
- package/src/lib/app-utils/host-tokens.js +12 -0
- package/src/lib/app-utils/host-tokens.js.map +1 -1
- package/src/lib/app-utils/visitor-upload.d.ts +19 -3
- package/src/lib/app-utils/visitor-upload.js +62 -10
- package/src/lib/app-utils/visitor-upload.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/host-tokens.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\nimport { hostPublicOrigin } from './host-naming'\n\n/**\n * Host variables — publisher templates that fill themselves in (AGL-1022).\n *\n * A publisher writing an email template cannot reference the installing site,\n * so every install begins with the same chore: swap the placeholder logo, fix\n * the business name, correct the links. Miss one and the site sends an email\n * carrying someone else's branding.\n *\n * `host.*` gives artifacts a way to NAME the host instead of hard-coding it.\n * The author writes `{{host.businessName}}`; the installing site fills it in,\n * and it stays filled in when that site rebrands.\n *\n * ## The namespace IS the security boundary\n *\n * This is publisher-authored content resolving against tenant data, so the\n * registry is CLOSED and each token reads through a hand-written function\n * rather than a path into the document. That is the whole design: a token\n * cannot address `stripeAccountId`, or `memberRoles`, or anything else, because\n * there is no path syntax to express it — `host.` is followed by a key that\n * either appears in {@link HOST_TOKENS} or resolves to nothing at all. Adding a\n * token is a deliberate edit here, reviewable as a diff.\n *\n * A path-based registry (\"allow `host.seo.title`\") would have been shorter and\n * is the version to refuse: allowlisting prefixes is how a namespace grows a\n * hole, and the first `host.orgId` would be a data leak with a plausible commit\n * message.\n *\n * ## Resolve LATE\n *\n * At render and at send, never at install. Freezing values into the artifact\n * would make a rebrand stop propagating silently — the site would keep sending\n * last year's business name from a template nobody remembers is a template.\n *\n * ## Every token defines what \"empty\" does\n *\n * A site with no logo must produce something deliberate. Never an empty image,\n * and never the literal `{{host.logo}}` reaching a visitor — a leaked token is\n * the most embarrassing possible failure for a feature whose entire purpose is\n * to stop templates looking unfinished.\n */\n\n/**\n * What a token yields, so a renderer knows how to place it and the authoring\n * surface can refuse it in the wrong slot.\n *\n * `text` is the only type safe to drop into arbitrary copy. The others carry a\n * shape a slot has to understand — an `image` in a paragraph is a URL sitting\n * in the middle of a sentence, which is exactly the \"fails in a sent email\"\n * outcome this typing exists to move to authoring time.\n */\nexport type HostTokenType = 'text' | 'url' | 'email' | 'image' | 'address'\n\n/** What a renderer should do when a token resolves to nothing. */\nexport type HostTokenEmptyBehaviour =\n /** Render nothing in its place; the surrounding copy still makes sense. */\n | 'blank'\n /**\n * The block holding it should be dropped entirely. An `<img src=\"\">` is a\n * broken-image icon in every mail client, which is worse than no logo.\n */\n | 'omit-block'\n\n/** The site fields a token may read. A closed, explicit surface. */\nexport interface HostTokenSource {\n displayName?: string\n logoUrl?: string\n subdomain?: string\n cname?: string\n /** Contact details a publisher template can reference (AGL-1022). */\n business?: {\n supportEmail?: string\n address?: string\n socialLinks?: Array<{ label?: string; url?: string }>\n }\n seo?: { entity?: { name?: string; logo?: string } }\n}\n\nexport interface HostTokenDefinition {\n /** The key after `host.` */\n key: string\n type: HostTokenType\n label: string\n description: string\n /** Reads the value. Hand-written per token — never a path into the document. */\n resolve: (host: HostTokenSource) => string | undefined\n /**\n * A deliberate second choice when `resolve` finds nothing.\n *\n * Not a nicety: \"fall back to the business name\" is what turns a missing logo\n * from a hole in the page into a wordmark, which is what a careful designer\n * would have done by hand.\n */\n fallback?: (host: HostTokenSource) => string | undefined\n whenEmpty: HostTokenEmptyBehaviour\n}\n\nconst trimmed = (value: unknown): string | undefined => {\n const text = typeof value === 'string' ? value.trim() : ''\n return text || undefined\n}\n\n/**\n * The site's public URL.\n *\n * A custom domain wins when connected, because that is the address the site is\n * actually known by — a template linking to the aglyn.app subdomain of a site\n * with its own domain is technically correct and reads as a mistake.\n */\nfunction siteUrl(host: HostTokenSource): string | undefined {\n const custom = trimmed(host.cname)\n if (custom) return `https://${custom.replace(/^https?:\\/\\//, '')}`\n // `hostPublicOrigin`, not a fourth hand-rolled apex (AGL-2195). The cname\n // branch stays here because this one strips a scheme the token source may\n // carry; the subdomain branch has no such difference and had none — it was\n // the same two lines with `aglyn.app` written in, so an operator's own apex\n // was honoured everywhere except in the tokens a template renders.\n return hostPublicOrigin({ subdomain: trimmed(host.subdomain) })\n}\n\nfunction businessName(host: HostTokenSource): string | undefined {\n return trimmed(host.seo?.entity?.name) ?? trimmed(host.displayName)\n}\n\n/**\n * The closed registry.\n *\n * Ordered as an author would look for them. Every entry is reachable from a\n * field a site can actually set — a token that can never resolve is worse than\n * an absent one, because it teaches people the feature does not work.\n */\nexport const HOST_TOKENS: Record<string, HostTokenDefinition> = {\n businessName: {\n key: 'businessName',\n type: 'text',\n label: 'Business name',\n description: 'The site’s business or brand name.',\n resolve: businessName,\n whenEmpty: 'blank',\n },\n logo: {\n key: 'logo',\n type: 'image',\n label: 'Logo',\n description: 'The site’s logo image.',\n resolve: (host) =>\n trimmed(host.logoUrl) ?? trimmed(host.seo?.entity?.logo),\n // No image: the block is dropped and the business name carries the brand\n // instead, which is what the block was for.\n whenEmpty: 'omit-block',\n },\n url: {\n key: 'url',\n type: 'url',\n label: 'Site address',\n description: 'The site’s public URL — its custom domain when connected.',\n resolve: siteUrl,\n whenEmpty: 'blank',\n },\n supportEmail: {\n key: 'supportEmail',\n type: 'email',\n label: 'Support email',\n description: 'Where visitors should write for help.',\n resolve: (host) => trimmed(host.business?.supportEmail),\n whenEmpty: 'omit-block',\n },\n address: {\n key: 'address',\n type: 'address',\n label: 'Postal address',\n description: 'The business’s postal address, as one block of text.',\n resolve: (host) => trimmed(host.business?.address),\n whenEmpty: 'omit-block',\n },\n socialLinks: {\n key: 'socialLinks',\n type: 'text',\n label: 'Social links',\n description: 'The site’s social profiles, as a list of labeled links.',\n // Rendered as text here so a token in copy degrades to something readable;\n // a renderer wanting real links reads `host.business.socialLinks` itself.\n resolve: (host) => {\n const links = host.business?.socialLinks ?? []\n const labels = links\n .map((link) => trimmed(link?.label) ?? trimmed(link?.url))\n .filter((label): label is string => Boolean(label))\n return labels.length ? labels.join(' · ') : undefined\n },\n whenEmpty: 'omit-block',\n },\n}\n\n/**\n * The registry's entry for `key`, or undefined when it declares none.\n *\n * An OWN entry only. `HOST_TOKENS` is an object literal, so a bare index also\n * finds the `Object.prototype` members — `constructor`, `toString`, `valueOf` —\n * and every one of those names matches {@link HOST_TOKEN_PATTERN}. Read that\n * way, `{{host.constructor}}` answers the `Object` function where a definition\n * belongs, and calling its `resolve` throws in the middle of a render. A name\n * the registry does not declare resolves to nothing, whatever the name is.\n */\nexport function hostTokenDefinition(\n key: string,\n): HostTokenDefinition | undefined {\n return Object.prototype.hasOwnProperty.call(HOST_TOKENS, key)\n ? HOST_TOKENS[key]\n : undefined\n}\n\n/**\n * `{{host.key}}` — whitespace-tolerant, and deliberately NOT dotted beyond one\n * segment. `host.a.b` does not match, so there is no path syntax to abuse.\n */\nexport const HOST_TOKEN_PATTERN = /\\{\\{\\s*host\\.([a-zA-Z][a-zA-Z0-9_]*)\\s*\\}\\}/g\n\n/** Every `host.*` token in a string, in order, including unknown ones. */\nexport function hostTokensIn(text: string): string[] {\n if (typeof text !== 'string' || !text.includes('{{')) return []\n const keys: string[] = []\n for (const match of text.matchAll(HOST_TOKEN_PATTERN)) keys.push(match[1])\n return keys\n}\n\n/** The resolved value of one token, or undefined when the site has none. */\nexport function resolveHostToken(\n key: string,\n host: HostTokenSource | null | undefined,\n): string | undefined {\n const definition = hostTokenDefinition(key)\n if (!definition || !host) return undefined\n return definition.resolve(host) ?? definition.fallback?.(host)\n}\n\n/**\n * Replaces every `host.*` token with the installing site's value.\n *\n * An unresolved token — unknown key, or a site that has not set the field —\n * renders EMPTY, never the literal. Leaking `{{host.logo}}` into a sent email\n * is the failure this feature exists to prevent, so it cannot be the fallback\n * for the feature's own gaps. The authoring-time check\n * ({@link validateHostTokens}) is where a typo is meant to be caught, while the\n * writer can still fix it.\n */\nexport function resolveHostTokens(\n text: string,\n host: HostTokenSource | null | undefined,\n): string {\n if (typeof text !== 'string' || !text.includes('{{')) return text\n return text.replace(HOST_TOKEN_PATTERN, (_token, key) =>\n resolveHostToken(String(key), host) ?? '',\n )\n}\n\nexport interface HostTokenIssue {\n key: string\n /**\n * `unknown` — no such token; it will render empty.\n * `wrong-slot` — the token's type cannot be placed here.\n */\n kind: 'unknown' | 'wrong-slot'\n message: string\n}\n\n/**\n * Authoring-time check: catch a token before it reaches a visitor.\n *\n * The two things worth failing on are a typo (which would silently render\n * nothing) and a type mismatch (an image URL dropped into a sentence). Both are\n * invisible at authoring time without this and obvious afterwards, which is the\n * wrong way round for something that ends up in a sent email.\n *\n * `allow` names the types this slot can take. Omit it for free text, where only\n * `text` and the string-ish types make sense.\n */\nexport function validateHostTokens(\n text: string,\n options?: { allow?: readonly HostTokenType[] },\n): HostTokenIssue[] {\n const allow = options?.allow ?? ['text', 'url', 'email', 'address']\n const issues: HostTokenIssue[] = []\n const seen = new Set<string>()\n for (const key of hostTokensIn(text)) {\n if (seen.has(key)) continue\n seen.add(key)\n const definition = hostTokenDefinition(key)\n if (!definition) {\n issues.push({\n key,\n kind: 'unknown',\n message:\n `There is no host variable called \"${key}\". It will render as ` +\n 'nothing. Available: ' +\n Object.keys(HOST_TOKENS).join(', ') +\n '.',\n })\n continue\n }\n if (!allow.includes(definition.type)) {\n issues.push({\n key,\n kind: 'wrong-slot',\n message:\n `\"${definition.label}\" is ${\n definition.type === 'image' ? 'an image' : `a ${definition.type}`\n }, so it cannot go here. Put it in a slot that takes ` +\n `${definition.type === 'image' ? 'an image' : `a ${definition.type}`}.`,\n })\n }\n }\n return issues\n}\n\n/**\n * Whether a block containing these tokens should be dropped for this site.\n *\n * The other half of \"every token needs a defined empty behaviour\": a logo block\n * on a site with no logo should not render an empty image, and the renderer\n * cannot know that from the resolved string alone — `''` is both \"no value\" and\n * \"a value that happens to be empty\". Asking here keeps the rule with the\n * token definition rather than spread across the surfaces that render it.\n */\nexport function shouldOmitBlock(\n text: string,\n host: HostTokenSource | null | undefined,\n): boolean {\n for (const key of hostTokensIn(text)) {\n const definition = hostTokenDefinition(key)\n if (!definition || definition.whenEmpty !== 'omit-block') continue\n if (!resolveHostToken(key, host)) return true\n }\n return false\n}\n\n/**\n * Every token with its resolved value for one site, for an authoring picker\n * that shows what each will actually produce here.\n *\n * A picker listing token names alone makes the author guess; showing the value\n * this site would render is what turns \"is it businessName or business_name\"\n * into a question nobody has to ask.\n */\nexport function describeHostTokens(\n host: HostTokenSource | null | undefined,\n): Array<HostTokenDefinition & { token: string; value?: string; set: boolean }> {\n return Object.values(HOST_TOKENS).map((definition) => {\n const value = resolveHostToken(definition.key, host)\n return {\n ...definition,\n token: `{{host.${definition.key}}}`,\n value,\n set: Boolean(value),\n }\n })\n}\n\n/**\n * The `host.*` values as a merge-token map, for surfaces that already have a\n * token substituter of their own.\n *\n * This is how EMAIL gets host variables without the email library depending on\n * this one: `substituteMergeTokens` already matches `{{a.b}}`, so a caller\n * spreads this into the merge map it was passing anyway. One registry, two\n * surfaces, no second substitution engine to drift.\n *\n * Every registered token appears, INCLUDING the ones this site has not set —\n * as `''`. Omitting them would leave `{{host.supportEmail}}` for the generic\n * catch-all to blank, which works today and works by accident: the moment a\n * surface without that catch-all uses this map, the literal token ships.\n */\nexport function hostTokenMerge(\n host: HostTokenSource | null | undefined,\n): Record<string, string> {\n const merge: Record<string, string> = {}\n for (const key of Object.keys(HOST_TOKENS)) {\n merge[`host.${key}`] = resolveHostToken(key, host) ?? ''\n }\n return merge\n}\n\n/**\n * Resolves `host.*` tokens across every string prop of a normalized node map.\n *\n * Mirrors `resolveNodesBindings` deliberately — same shallow-copy discipline,\n * same string-props-only rule — so a screen and an email resolve the same token\n * to the same value. The issue's warning is the reason: two resolvers drift,\n * and then the same token means different things depending on where an author\n * typed it.\n *\n * Returns the input untouched when the site has nothing to substitute, so a\n * binding-free tree costs one `Object.entries`.\n */\nexport function resolveNodesHostTokens<T extends Record<string, any>>(\n nodes: T,\n host: HostTokenSource | null | undefined,\n): T {\n if (!nodes) return nodes\n let changed = false\n const next: Record<string, any> = {}\n for (const [id, node] of Object.entries(nodes)) {\n const props = node?.props\n if (!props) {\n next[id] = node\n continue\n }\n let nodeChanged = false\n const nextProps: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(props)) {\n if (typeof value === 'string' && value.includes('{{')) {\n const resolved = resolveHostTokens(value, host)\n nextProps[key] = resolved\n if (resolved !== value) nodeChanged = true\n } else {\n nextProps[key] = value\n }\n }\n next[id] = nodeChanged ? { ...node, props: nextProps } : node\n changed = changed || nodeChanged\n }\n return (changed ? next : nodes) as T\n}\n"],"names":["hostPublicOrigin","trimmed","value","text","trim","undefined","siteUrl","host","custom","cname","replace","subdomain","businessName","seo","entity","name","displayName","HOST_TOKENS","key","type","label","description","resolve","whenEmpty","logo","logoUrl","url","supportEmail","business","address","socialLinks","links","labels","map","link","filter","Boolean","length","join","hostTokenDefinition","Object","prototype","hasOwnProperty","call","HOST_TOKEN_PATTERN","hostTokensIn","includes","keys","match","matchAll","push","resolveHostToken","definition","fallback","resolveHostTokens","_token","String","validateHostTokens","options","allow","issues","seen","Set","has","add","kind","message","shouldOmitBlock","describeHostTokens","values","token","set","hostTokenMerge","merge","resolveNodesHostTokens","nodes","changed","next","id","node","entries","props","nodeChanged","nextProps","resolved"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/host-tokens.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\nimport { hostPublicOrigin } from './host-naming'\n\n/**\n * Host variables — publisher templates that fill themselves in (AGL-1022).\n *\n * A publisher writing an email template cannot reference the installing site,\n * so every install begins with the same chore: swap the placeholder logo, fix\n * the business name, correct the links. Miss one and the site sends an email\n * carrying someone else's branding.\n *\n * `host.*` gives artifacts a way to NAME the host instead of hard-coding it.\n * The author writes `{{host.businessName}}`; the installing site fills it in,\n * and it stays filled in when that site rebrands.\n *\n * ## The namespace IS the security boundary\n *\n * This is publisher-authored content resolving against tenant data, so the\n * registry is CLOSED and each token reads through a hand-written function\n * rather than a path into the document. That is the whole design: a token\n * cannot address `stripeAccountId`, or `memberRoles`, or anything else, because\n * there is no path syntax to express it — `host.` is followed by a key that\n * either appears in {@link HOST_TOKENS} or resolves to nothing at all. Adding a\n * token is a deliberate edit here, reviewable as a diff.\n *\n * A path-based registry (\"allow `host.seo.title`\") would have been shorter and\n * is the version to refuse: allowlisting prefixes is how a namespace grows a\n * hole, and the first `host.orgId` would be a data leak with a plausible commit\n * message.\n *\n * ## Resolve LATE\n *\n * At render and at send, never at install. Freezing values into the artifact\n * would make a rebrand stop propagating silently — the site would keep sending\n * last year's business name from a template nobody remembers is a template.\n *\n * ## Every token defines what \"empty\" does\n *\n * A site with no logo must produce something deliberate. Never an empty image,\n * and never the literal `{{host.logo}}` reaching a visitor — a leaked token is\n * the most embarrassing possible failure for a feature whose entire purpose is\n * to stop templates looking unfinished.\n */\n\n/**\n * What a token yields, so a renderer knows how to place it and the authoring\n * surface can refuse it in the wrong slot.\n *\n * `text` is the only type safe to drop into arbitrary copy. The others carry a\n * shape a slot has to understand — an `image` in a paragraph is a URL sitting\n * in the middle of a sentence, which is exactly the \"fails in a sent email\"\n * outcome this typing exists to move to authoring time.\n */\nexport type HostTokenType = 'text' | 'url' | 'email' | 'image' | 'address'\n\n/** What a renderer should do when a token resolves to nothing. */\nexport type HostTokenEmptyBehaviour =\n /** Render nothing in its place; the surrounding copy still makes sense. */\n | 'blank'\n /**\n * The block holding it should be dropped entirely. An `<img src=\"\">` is a\n * broken-image icon in every mail client, which is worse than no logo.\n */\n | 'omit-block'\n\n/** The site fields a token may read. A closed, explicit surface. */\nexport interface HostTokenSource {\n displayName?: string\n logoUrl?: string\n subdomain?: string\n cname?: string\n /** Contact details a publisher template can reference (AGL-1022). */\n business?: {\n supportEmail?: string\n address?: string\n socialLinks?: Array<{ label?: string; url?: string }>\n }\n seo?: { entity?: { name?: string; logo?: string } }\n}\n\nexport interface HostTokenDefinition {\n /** The key after `host.` */\n key: string\n type: HostTokenType\n label: string\n description: string\n /** Reads the value. Hand-written per token — never a path into the document. */\n resolve: (host: HostTokenSource) => string | undefined\n /**\n * A deliberate second choice when `resolve` finds nothing.\n *\n * Not a nicety: \"fall back to the business name\" is what turns a missing logo\n * from a hole in the page into a wordmark, which is what a careful designer\n * would have done by hand.\n */\n fallback?: (host: HostTokenSource) => string | undefined\n whenEmpty: HostTokenEmptyBehaviour\n}\n\nconst trimmed = (value: unknown): string | undefined => {\n const text = typeof value === 'string' ? value.trim() : ''\n return text || undefined\n}\n\n/**\n * The site's public URL.\n *\n * A custom domain wins when connected, because that is the address the site is\n * actually known by — a template linking to the aglyn.app subdomain of a site\n * with its own domain is technically correct and reads as a mistake.\n */\nfunction siteUrl(host: HostTokenSource): string | undefined {\n const custom = trimmed(host.cname)\n if (custom) return `https://${custom.replace(/^https?:\\/\\//, '')}`\n // `hostPublicOrigin`, not a fourth hand-rolled apex (AGL-2195). The cname\n // branch stays here because this one strips a scheme the token source may\n // carry; the subdomain branch has no such difference and had none — it was\n // the same two lines with `aglyn.app` written in, so an operator's own apex\n // was honoured everywhere except in the tokens a template renders.\n return hostPublicOrigin({ subdomain: trimmed(host.subdomain) })\n}\n\nfunction businessName(host: HostTokenSource): string | undefined {\n return trimmed(host.seo?.entity?.name) ?? trimmed(host.displayName)\n}\n\n/**\n * The closed registry.\n *\n * Ordered as an author would look for them. Every entry is reachable from a\n * field a site can actually set — a token that can never resolve is worse than\n * an absent one, because it teaches people the feature does not work.\n */\nexport const HOST_TOKENS: Record<string, HostTokenDefinition> = {\n businessName: {\n key: 'businessName',\n type: 'text',\n label: 'Business name',\n description: 'The site’s business or brand name.',\n resolve: businessName,\n whenEmpty: 'blank',\n },\n logo: {\n key: 'logo',\n type: 'image',\n label: 'Logo',\n description: 'The site’s logo image.',\n resolve: (host) =>\n trimmed(host.logoUrl) ?? trimmed(host.seo?.entity?.logo),\n // No image: the block is dropped and the business name carries the brand\n // instead, which is what the block was for.\n whenEmpty: 'omit-block',\n },\n url: {\n key: 'url',\n type: 'url',\n label: 'Site address',\n description: 'The site’s public URL — its custom domain when connected.',\n resolve: siteUrl,\n whenEmpty: 'blank',\n },\n supportEmail: {\n key: 'supportEmail',\n type: 'email',\n label: 'Support email',\n description: 'Where visitors should write for help.',\n resolve: (host) => trimmed(host.business?.supportEmail),\n whenEmpty: 'omit-block',\n },\n address: {\n key: 'address',\n type: 'address',\n label: 'Postal address',\n description: 'The business’s postal address, as one block of text.',\n resolve: (host) => trimmed(host.business?.address),\n whenEmpty: 'omit-block',\n },\n socialLinks: {\n key: 'socialLinks',\n type: 'text',\n label: 'Social links',\n description: 'The site’s social profiles, as a list of labeled links.',\n // Rendered as text here so a token in copy degrades to something readable;\n // a renderer wanting real links reads `host.business.socialLinks` itself.\n resolve: (host) => {\n const links = host.business?.socialLinks ?? []\n const labels = links\n .map((link) => trimmed(link?.label) ?? trimmed(link?.url))\n .filter((label): label is string => Boolean(label))\n return labels.length ? labels.join(' · ') : undefined\n },\n whenEmpty: 'omit-block',\n },\n}\n\n/**\n * The registry's entry for `key`, or undefined when it declares none.\n *\n * An OWN entry only. `HOST_TOKENS` is an object literal, so a bare index also\n * finds the `Object.prototype` members — `constructor`, `toString`, `valueOf` —\n * and every one of those names matches {@link HOST_TOKEN_PATTERN}. Read that\n * way, `{{host.constructor}}` answers the `Object` function where a definition\n * belongs, and calling its `resolve` throws in the middle of a render. A name\n * the registry does not declare resolves to nothing, whatever the name is.\n */\nexport function hostTokenDefinition(\n key: string,\n): HostTokenDefinition | undefined {\n return Object.prototype.hasOwnProperty.call(HOST_TOKENS, key)\n ? HOST_TOKENS[key]\n : undefined\n}\n\n/**\n * `{{host.key}}` — whitespace-tolerant, and deliberately NOT dotted beyond one\n * segment. `host.a.b` does not match, so there is no path syntax to abuse.\n */\nexport const HOST_TOKEN_PATTERN = /\\{\\{\\s*host\\.([a-zA-Z][a-zA-Z0-9_]*)\\s*\\}\\}/g\n\n/** Every `host.*` token in a string, in order, including unknown ones. */\nexport function hostTokensIn(text: string): string[] {\n if (typeof text !== 'string' || !text.includes('{{')) return []\n const keys: string[] = []\n for (const match of text.matchAll(HOST_TOKEN_PATTERN)) keys.push(match[1])\n return keys\n}\n\n/** The resolved value of one token, or undefined when the site has none. */\nexport function resolveHostToken(\n key: string,\n host: HostTokenSource | null | undefined,\n): string | undefined {\n const definition = hostTokenDefinition(key)\n if (!definition || !host) return undefined\n return definition.resolve(host) ?? definition.fallback?.(host)\n}\n\n/**\n * Replaces every `host.*` token with the installing site's value.\n *\n * An unresolved token — unknown key, or a site that has not set the field —\n * renders EMPTY, never the literal. Leaking `{{host.logo}}` into a sent email\n * is the failure this feature exists to prevent, so it cannot be the fallback\n * for the feature's own gaps. The authoring-time check\n * ({@link validateHostTokens}) is where a typo is meant to be caught, while the\n * writer can still fix it.\n */\nexport function resolveHostTokens(\n text: string,\n host: HostTokenSource | null | undefined,\n): string {\n if (typeof text !== 'string' || !text.includes('{{')) return text\n return text.replace(HOST_TOKEN_PATTERN, (_token, key) =>\n resolveHostToken(String(key), host) ?? '',\n )\n}\n\nexport interface HostTokenIssue {\n key: string\n /**\n * `unknown` — no such token; it will render empty.\n * `wrong-slot` — the token's type cannot be placed here.\n */\n kind: 'unknown' | 'wrong-slot'\n message: string\n}\n\n/**\n * Authoring-time check: catch a token before it reaches a visitor.\n *\n * The two things worth failing on are a typo (which would silently render\n * nothing) and a type mismatch (an image URL dropped into a sentence). Both are\n * invisible at authoring time without this and obvious afterwards, which is the\n * wrong way round for something that ends up in a sent email.\n *\n * `allow` names the types this slot can take. Omit it for free text, where only\n * `text` and the string-ish types make sense.\n */\nexport function validateHostTokens(\n text: string,\n options?: { allow?: readonly HostTokenType[] },\n): HostTokenIssue[] {\n const allow = options?.allow ?? ['text', 'url', 'email', 'address']\n const issues: HostTokenIssue[] = []\n const seen = new Set<string>()\n for (const key of hostTokensIn(text)) {\n if (seen.has(key)) continue\n seen.add(key)\n const definition = hostTokenDefinition(key)\n if (!definition) {\n issues.push({\n key,\n kind: 'unknown',\n message:\n `There is no host variable called \"${key}\". It will render as ` +\n 'nothing. Available: ' +\n Object.keys(HOST_TOKENS).join(', ') +\n '.',\n })\n continue\n }\n if (!allow.includes(definition.type)) {\n issues.push({\n key,\n kind: 'wrong-slot',\n message:\n `\"${definition.label}\" is ${\n definition.type === 'image' ? 'an image' : `a ${definition.type}`\n }, so it cannot go here. Put it in a slot that takes ` +\n `${definition.type === 'image' ? 'an image' : `a ${definition.type}`}.`,\n })\n }\n }\n return issues\n}\n\n/**\n * Whether a block containing these tokens should be dropped for this site.\n *\n * The other half of \"every token needs a defined empty behaviour\": a logo block\n * on a site with no logo should not render an empty image, and the renderer\n * cannot know that from the resolved string alone — `''` is both \"no value\" and\n * \"a value that happens to be empty\". Asking here keeps the rule with the\n * token definition rather than spread across the surfaces that render it.\n */\nexport function shouldOmitBlock(\n text: string,\n host: HostTokenSource | null | undefined,\n): boolean {\n for (const key of hostTokensIn(text)) {\n const definition = hostTokenDefinition(key)\n if (!definition || definition.whenEmpty !== 'omit-block') continue\n if (!resolveHostToken(key, host)) return true\n }\n return false\n}\n\n/**\n * Every token with its resolved value for one site, for an authoring picker\n * that shows what each will actually produce here.\n *\n * A picker listing token names alone makes the author guess; showing the value\n * this site would render is what turns \"is it businessName or business_name\"\n * into a question nobody has to ask.\n */\nexport function describeHostTokens(\n host: HostTokenSource | null | undefined,\n): Array<HostTokenDefinition & { token: string; value?: string; set: boolean }> {\n return Object.values(HOST_TOKENS).map((definition) => {\n const value = resolveHostToken(definition.key, host)\n return {\n ...definition,\n token: `{{host.${definition.key}}}`,\n value,\n set: Boolean(value),\n }\n })\n}\n\n/**\n * The `host.*` values as a merge-token map, for surfaces that already have a\n * token substituter of their own.\n *\n * This is how EMAIL gets host variables without the email library depending on\n * this one: `substituteMergeTokens` already matches `{{a.b}}`, so a caller\n * spreads this into the merge map it was passing anyway. One registry, two\n * surfaces, no second substitution engine to drift.\n *\n * Every registered token appears, INCLUDING the ones this site has not set —\n * as `''`. Omitting them would leave `{{host.supportEmail}}` for the generic\n * catch-all to blank, which works today and works by accident: the moment a\n * surface without that catch-all uses this map, the literal token ships.\n */\nexport function hostTokenMerge(\n host: HostTokenSource | null | undefined,\n): Record<string, string> {\n const merge: Record<string, string> = {}\n for (const key of Object.keys(HOST_TOKENS)) {\n merge[`host.${key}`] = resolveHostToken(key, host) ?? ''\n }\n return merge\n}\n\n/**\n * The email address a visitor writes to the business at: the one its\n * business listing shows (`seo.entity.email`), else its support email. Null\n * when the site has neither. One answer for every place that names it: the\n * waiver consent, the quote and invoice PDFs, and the Reply-To of the quote\n * email (AGL-4097). Trimmed, not validated; a caller putting it in a header\n * checks it is an address.\n */\nexport function siteContactEmail(\n host:\n | {\n seo?: { entity?: { email?: unknown } | null } | null\n business?: { supportEmail?: unknown } | null\n }\n | null\n | undefined,\n): string | null {\n const text = (value: unknown) => (typeof value === 'string' ? value.trim().slice(0, 320) : '')\n return text(host?.seo?.entity?.email) || text(host?.business?.supportEmail) || null\n}\n\n/**\n * Resolves `host.*` tokens across every string prop of a normalized node map.\n *\n * Mirrors `resolveNodesBindings` deliberately — same shallow-copy discipline,\n * same string-props-only rule — so a screen and an email resolve the same token\n * to the same value. The issue's warning is the reason: two resolvers drift,\n * and then the same token means different things depending on where an author\n * typed it.\n *\n * Returns the input untouched when the site has nothing to substitute, so a\n * binding-free tree costs one `Object.entries`.\n */\nexport function resolveNodesHostTokens<T extends Record<string, any>>(\n nodes: T,\n host: HostTokenSource | null | undefined,\n): T {\n if (!nodes) return nodes\n let changed = false\n const next: Record<string, any> = {}\n for (const [id, node] of Object.entries(nodes)) {\n const props = node?.props\n if (!props) {\n next[id] = node\n continue\n }\n let nodeChanged = false\n const nextProps: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(props)) {\n if (typeof value === 'string' && value.includes('{{')) {\n const resolved = resolveHostTokens(value, host)\n nextProps[key] = resolved\n if (resolved !== value) nodeChanged = true\n } else {\n nextProps[key] = value\n }\n }\n next[id] = nodeChanged ? { ...node, props: nextProps } : node\n changed = changed || nodeChanged\n }\n return (changed ? next : nodes) as T\n}\n"],"names":["hostPublicOrigin","trimmed","value","text","trim","undefined","siteUrl","host","custom","cname","replace","subdomain","businessName","seo","entity","name","displayName","HOST_TOKENS","key","type","label","description","resolve","whenEmpty","logo","logoUrl","url","supportEmail","business","address","socialLinks","links","labels","map","link","filter","Boolean","length","join","hostTokenDefinition","Object","prototype","hasOwnProperty","call","HOST_TOKEN_PATTERN","hostTokensIn","includes","keys","match","matchAll","push","resolveHostToken","definition","fallback","resolveHostTokens","_token","String","validateHostTokens","options","allow","issues","seen","Set","has","add","kind","message","shouldOmitBlock","describeHostTokens","values","token","set","hostTokenMerge","merge","siteContactEmail","slice","email","resolveNodesHostTokens","nodes","changed","next","id","node","entries","props","nodeChanged","nextProps","resolved"],"mappings":"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"}
|
|
@@ -46,8 +46,23 @@ export interface VisitorUploadType {
|
|
|
46
46
|
aliases?: readonly string[];
|
|
47
47
|
}
|
|
48
48
|
export declare const VISITOR_UPLOAD_TYPES: readonly VisitorUploadType[];
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The kinds of file a business can ask for, as a person picks them: photos and
|
|
51
|
+
* pictures, PDFs, and design files. Each kind is a set of the types above, so
|
|
52
|
+
* a field that takes only PDFs takes only `application/pdf` (AGL-4098).
|
|
53
|
+
*/
|
|
54
|
+
export type VisitorUploadKind = 'images' | 'pdf' | 'design';
|
|
55
|
+
export declare const VISITOR_UPLOAD_KINDS: readonly VisitorUploadKind[];
|
|
56
|
+
/** What a person calls each kind. */
|
|
57
|
+
export declare const VISITOR_UPLOAD_KIND_LABELS: Readonly<Record<VisitorUploadKind, string>>;
|
|
58
|
+
/** The kinds a stored list names, in order and without repeats; empty or unreadable is every kind. */
|
|
59
|
+
export declare function readVisitorUploadKinds(raw: unknown): VisitorUploadKind[];
|
|
60
|
+
/** Whether a stored type is one of the kinds; true for any type when no kinds are named. */
|
|
61
|
+
export declare function visitorUploadTypeInKinds(contentType: string, kinds?: readonly VisitorUploadKind[]): boolean;
|
|
62
|
+
/** `PNG, JPEG, WebP, GIF, PDF, SVG, AI, EPS, PSD or TIFF`, or the labels of the kinds named. */
|
|
63
|
+
export declare function describeVisitorUploadTypes(kinds?: readonly VisitorUploadKind[]): string;
|
|
64
|
+
/** The file picker's `accept` for the kinds named: every extension and type they take. */
|
|
65
|
+
export declare function visitorUploadAccept(kinds?: readonly VisitorUploadKind[]): string;
|
|
51
66
|
/** The file picker's `accept`: every extension and type taken. */
|
|
52
67
|
export declare const VISITOR_UPLOAD_ACCEPT: string;
|
|
53
68
|
/** `25 MB`. */
|
|
@@ -89,6 +104,7 @@ export interface CheckedVisitorUpload {
|
|
|
89
104
|
*/
|
|
90
105
|
export declare function checkVisitorUploadRequest(raw: unknown, options?: {
|
|
91
106
|
maxBytes?: number;
|
|
107
|
+
kinds?: readonly VisitorUploadKind[];
|
|
92
108
|
}): {
|
|
93
109
|
ok: true;
|
|
94
110
|
upload: CheckedVisitorUpload;
|
|
@@ -137,4 +153,4 @@ export declare function checkVisitorUploadSelection(files: ReadonlyArray<{
|
|
|
137
153
|
name: string;
|
|
138
154
|
type: string;
|
|
139
155
|
size: number;
|
|
140
|
-
}>, already?: number, maxFiles?: number): string | null;
|
|
156
|
+
}>, already?: number, maxFiles?: number, kinds?: readonly VisitorUploadKind[]): string | null;
|
|
@@ -165,14 +165,64 @@ export const VISITOR_UPLOAD_TYPES = [
|
|
|
165
165
|
'binary/octet-stream',
|
|
166
166
|
'application/unknown'
|
|
167
167
|
]);
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
export const VISITOR_UPLOAD_KINDS = [
|
|
169
|
+
'images',
|
|
170
|
+
'pdf',
|
|
171
|
+
'design'
|
|
172
|
+
];
|
|
173
|
+
/** What a person calls each kind. */ export const VISITOR_UPLOAD_KIND_LABELS = {
|
|
174
|
+
images: 'Images (PNG, JPEG, WebP, GIF)',
|
|
175
|
+
pdf: 'PDF',
|
|
176
|
+
design: 'Design files (SVG, AI, EPS, PSD, TIFF)'
|
|
177
|
+
};
|
|
178
|
+
const KIND_TYPES = {
|
|
179
|
+
images: [
|
|
180
|
+
'image/png',
|
|
181
|
+
'image/jpeg',
|
|
182
|
+
'image/webp',
|
|
183
|
+
'image/gif'
|
|
184
|
+
],
|
|
185
|
+
pdf: [
|
|
186
|
+
'application/pdf'
|
|
187
|
+
],
|
|
188
|
+
design: [
|
|
189
|
+
'image/svg+xml',
|
|
190
|
+
'application/illustrator',
|
|
191
|
+
'application/postscript',
|
|
192
|
+
'image/vnd.adobe.photoshop',
|
|
193
|
+
'image/tiff'
|
|
194
|
+
]
|
|
195
|
+
};
|
|
196
|
+
/** The kinds a stored list names, in order and without repeats; empty or unreadable is every kind. */ export function readVisitorUploadKinds(raw) {
|
|
197
|
+
const listed = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
|
|
198
|
+
const kinds = VISITOR_UPLOAD_KINDS.filter((kind)=>listed.some((entry)=>String(entry).trim() === kind));
|
|
199
|
+
return kinds.length ? kinds : [
|
|
200
|
+
...VISITOR_UPLOAD_KINDS
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
/** The types the kinds take, or every type when none are named. */ function typesOfKinds(kinds) {
|
|
204
|
+
if (!(kinds == null ? void 0 : kinds.length)) return VISITOR_UPLOAD_TYPES;
|
|
205
|
+
const wanted = new Set(kinds.flatMap((kind)=>{
|
|
206
|
+
var _KIND_TYPES_kind;
|
|
207
|
+
return (_KIND_TYPES_kind = KIND_TYPES[kind]) != null ? _KIND_TYPES_kind : [];
|
|
208
|
+
}));
|
|
209
|
+
return VISITOR_UPLOAD_TYPES.filter((type)=>wanted.has(type.contentType));
|
|
210
|
+
}
|
|
211
|
+
/** Whether a stored type is one of the kinds; true for any type when no kinds are named. */ export function visitorUploadTypeInKinds(contentType, kinds) {
|
|
212
|
+
return typesOfKinds(kinds).some((type)=>type.contentType === contentType);
|
|
213
|
+
}
|
|
214
|
+
/** `PNG, JPEG, WebP, GIF, PDF, SVG, AI, EPS, PSD or TIFF`, or the labels of the kinds named. */ export function describeVisitorUploadTypes(kinds) {
|
|
215
|
+
const labels = typesOfKinds(kinds).map((type)=>type.label);
|
|
216
|
+
if (labels.length === 1) return labels[0];
|
|
170
217
|
return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`;
|
|
171
218
|
}
|
|
172
|
-
/** The file picker's `accept
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
219
|
+
/** The file picker's `accept` for the kinds named: every extension and type they take. */ export function visitorUploadAccept(kinds) {
|
|
220
|
+
return typesOfKinds(kinds).flatMap((type)=>[
|
|
221
|
+
...type.extensions,
|
|
222
|
+
type.contentType
|
|
223
|
+
]).join(',');
|
|
224
|
+
}
|
|
225
|
+
/** The file picker's `accept`: every extension and type taken. */ export const VISITOR_UPLOAD_ACCEPT = visitorUploadAccept();
|
|
176
226
|
/** `25 MB`. */ export function formatVisitorUploadBytes(bytes) {
|
|
177
227
|
if (!(bytes > 0)) return '0 KB';
|
|
178
228
|
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
@@ -235,11 +285,11 @@ export const VISITOR_UPLOAD_TYPES = [
|
|
|
235
285
|
const size = Number(source['size']);
|
|
236
286
|
const shown = ((_name_split_pop = name.split(/[\\/]/).pop()) == null ? void 0 : _name_split_pop.trim()) || 'This file';
|
|
237
287
|
const type = resolveVisitorUploadType(name, typeof source['contentType'] === 'string' ? source['contentType'] : '');
|
|
238
|
-
if (!type) {
|
|
288
|
+
if (!type || !visitorUploadTypeInKinds(type.contentType, options.kinds)) {
|
|
239
289
|
return {
|
|
240
290
|
ok: false,
|
|
241
291
|
code: 'type',
|
|
242
|
-
error: `${shown} can't be sent. Send a ${describeVisitorUploadTypes()} file.`
|
|
292
|
+
error: `${shown} can't be sent. Send a ${describeVisitorUploadTypes(options.kinds)} file.`
|
|
243
293
|
};
|
|
244
294
|
}
|
|
245
295
|
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
@@ -294,15 +344,17 @@ export function isVisitorUploadSegment(value) {
|
|
|
294
344
|
/**
|
|
295
345
|
* The files a browser picked, checked before any is sent: how many, and each
|
|
296
346
|
* one's type and size. `already` is how many the submission holds.
|
|
297
|
-
*/ export function checkVisitorUploadSelection(files, already = 0, maxFiles = VISITOR_UPLOAD_MAX_FILES) {
|
|
347
|
+
*/ export function checkVisitorUploadSelection(files, already = 0, maxFiles = VISITOR_UPLOAD_MAX_FILES, kinds) {
|
|
298
348
|
if (already + files.length > maxFiles) {
|
|
299
|
-
return `Send up to ${maxFiles} files at a time.`;
|
|
349
|
+
return maxFiles === 1 ? 'Send one file.' : `Send up to ${maxFiles} files at a time.`;
|
|
300
350
|
}
|
|
301
351
|
for (const file of files){
|
|
302
352
|
const checked = checkVisitorUploadRequest({
|
|
303
353
|
name: file.name,
|
|
304
354
|
contentType: file.type,
|
|
305
355
|
size: file.size
|
|
356
|
+
}, {
|
|
357
|
+
kinds
|
|
306
358
|
});
|
|
307
359
|
if ('error' in checked) return checked.error;
|
|
308
360
|
}
|
|
@@ -1 +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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"}
|
|
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/**\n * The kinds of file a business can ask for, as a person picks them: photos and\n * pictures, PDFs, and design files. Each kind is a set of the types above, so\n * a field that takes only PDFs takes only `application/pdf` (AGL-4098).\n */\nexport type VisitorUploadKind = 'images' | 'pdf' | 'design'\n\nexport const VISITOR_UPLOAD_KINDS: readonly VisitorUploadKind[] = ['images', 'pdf', 'design']\n\n/** What a person calls each kind. */\nexport const VISITOR_UPLOAD_KIND_LABELS: Readonly<Record<VisitorUploadKind, string>> = {\n images: 'Images (PNG, JPEG, WebP, GIF)',\n pdf: 'PDF',\n design: 'Design files (SVG, AI, EPS, PSD, TIFF)',\n}\n\nconst KIND_TYPES: Readonly<Record<VisitorUploadKind, readonly string[]>> = {\n images: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],\n pdf: ['application/pdf'],\n design: ['image/svg+xml', 'application/illustrator', 'application/postscript', 'image/vnd.adobe.photoshop', 'image/tiff'],\n}\n\n/** The kinds a stored list names, in order and without repeats; empty or unreadable is every kind. */\nexport function readVisitorUploadKinds(raw: unknown): VisitorUploadKind[] {\n const listed = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : []\n const kinds = VISITOR_UPLOAD_KINDS.filter((kind) => listed.some((entry) => String(entry).trim() === kind))\n return kinds.length ? kinds : [...VISITOR_UPLOAD_KINDS]\n}\n\n/** The types the kinds take, or every type when none are named. */\nfunction typesOfKinds(kinds: readonly VisitorUploadKind[] | undefined): readonly VisitorUploadType[] {\n if (!kinds?.length) return VISITOR_UPLOAD_TYPES\n const wanted = new Set(kinds.flatMap((kind) => KIND_TYPES[kind] ?? []))\n return VISITOR_UPLOAD_TYPES.filter((type) => wanted.has(type.contentType))\n}\n\n/** Whether a stored type is one of the kinds; true for any type when no kinds are named. */\nexport function visitorUploadTypeInKinds(contentType: string, kinds?: readonly VisitorUploadKind[]): boolean {\n return typesOfKinds(kinds).some((type) => type.contentType === contentType)\n}\n\n/** `PNG, JPEG, WebP, GIF, PDF, SVG, AI, EPS, PSD or TIFF`, or the labels of the kinds named. */\nexport function describeVisitorUploadTypes(kinds?: readonly VisitorUploadKind[]): string {\n const labels = typesOfKinds(kinds).map((type) => type.label)\n if (labels.length === 1) return labels[0]\n return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`\n}\n\n/** The file picker's `accept` for the kinds named: every extension and type they take. */\nexport function visitorUploadAccept(kinds?: readonly VisitorUploadKind[]): string {\n return typesOfKinds(kinds)\n .flatMap((type) => [...type.extensions, type.contentType])\n .join(',')\n}\n\n/** The file picker's `accept`: every extension and type taken. */\nexport const VISITOR_UPLOAD_ACCEPT = visitorUploadAccept()\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; kinds?: readonly VisitorUploadKind[] } = {},\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 || !visitorUploadTypeInKinds(type.contentType, options.kinds)) {\n return {\n ok: false,\n code: 'type',\n error: `${shown} can't be sent. Send a ${describeVisitorUploadTypes(options.kinds)} 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 kinds?: readonly VisitorUploadKind[],\n): string | null {\n if (already + files.length > maxFiles) {\n return maxFiles === 1 ? 'Send one file.' : `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 }, { kinds })\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","VISITOR_UPLOAD_KINDS","VISITOR_UPLOAD_KIND_LABELS","images","pdf","design","KIND_TYPES","readVisitorUploadKinds","raw","listed","Array","isArray","split","kinds","filter","kind","some","entry","String","trim","length","typesOfKinds","wanted","flatMap","type","has","visitorUploadTypeInKinds","describeVisitorUploadTypes","labels","map","slice","join","visitorUploadAccept","VISITOR_UPLOAD_ACCEPT","formatVisitorUploadBytes","bytes","Math","max","round","mb","extensionOf","name","dot","lastIndexOf","toLowerCase","resolveVisitorUploadType","reportedType","byName","extension","reported","find","includes","visitorUploadTypeOf","visitorUploadIsInline","safeVisitorUploadName","last","pop","base","from","character","code","charCodeAt","replace","stem","kept","checkVisitorUploadRequest","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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"}
|