@aglyn/aglyn 1.0.0-beta.289 → 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 +22 -2
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +97 -6
- 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/upload-inspection.js +36 -0
- package/src/lib/app-utils/upload-inspection.js.map +1 -1
- package/src/lib/app-utils/visitor-upload-client.d.ts +47 -0
- package/src/lib/app-utils/visitor-upload-client.js +63 -0
- package/src/lib/app-utils/visitor-upload-client.js.map +1 -0
- package/src/lib/app-utils/visitor-upload.d.ts +156 -0
- package/src/lib/app-utils/visitor-upload.js +364 -0
- package/src/lib/app-utils/visitor-upload.js.map +1 -0
|
@@ -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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"}
|
|
@@ -512,6 +512,42 @@ const ascii = (text)=>Array.from(text, (character)=>character.charCodeAt(0));
|
|
|
512
512
|
magic: ascii('wOF2'),
|
|
513
513
|
label: 'WOFF2'
|
|
514
514
|
}
|
|
515
|
+
],
|
|
516
|
+
// Design files a business and its customer trade as proofs and artwork
|
|
517
|
+
// (AGL-4096, `visitor-upload.ts`). Illustrator saves a PDF-compatible file
|
|
518
|
+
// by default and plain PostScript when that option is off; an EPS is
|
|
519
|
+
// PostScript, or PostScript inside the DOS EPS binary header; a Photoshop
|
|
520
|
+
// document opens `8BPS`.
|
|
521
|
+
'application/illustrator': [
|
|
522
|
+
{
|
|
523
|
+
magic: ascii('%PDF-'),
|
|
524
|
+
label: 'Illustrator'
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
magic: ascii('%!PS'),
|
|
528
|
+
label: 'Illustrator'
|
|
529
|
+
}
|
|
530
|
+
],
|
|
531
|
+
'application/postscript': [
|
|
532
|
+
{
|
|
533
|
+
magic: ascii('%!PS'),
|
|
534
|
+
label: 'PostScript'
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
magic: [
|
|
538
|
+
0xc5,
|
|
539
|
+
0xd0,
|
|
540
|
+
0xd3,
|
|
541
|
+
0xc6
|
|
542
|
+
],
|
|
543
|
+
label: 'PostScript'
|
|
544
|
+
}
|
|
545
|
+
],
|
|
546
|
+
'image/vnd.adobe.photoshop': [
|
|
547
|
+
{
|
|
548
|
+
magic: ascii('8BPS'),
|
|
549
|
+
label: 'Photoshop'
|
|
550
|
+
}
|
|
515
551
|
]
|
|
516
552
|
};
|
|
517
553
|
/** The OOXML document types whose archive is scanned for a macro project. */ const OOXML_DOCUMENT_TYPES = new Set([
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/upload-inspection.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 * STRUCTURAL upload inspection (AGL-1475).\n *\n * ## What this is, and what it is emphatically not\n *\n * This is **not** an antivirus scanner and no code in this module should ever\n * be described as one. It does not have malware signatures, it does not\n * emulate, unpack or detonate anything, and a novel trojan with a correct PDF\n * header passes it without a murmur. It is *structural validation*: it reads\n * the first few kilobytes of an upload and answers three questions the\n * platform previously could not ask at all.\n *\n * 1. **Are these bytes an executable?** A Windows PE, an ELF binary, a Mach-O\n * binary or an installer package is refused whatever it claims to be. This\n * is the single highest-value check here: the Sept-1 risk is a customer's\n * domain re-serving a trojan to a visitor's browser, and the overwhelming\n * majority of that population is a plain executable with a lying name.\n * 2. **Do the bytes match the declared type?** Media ingress trusted the\n * caller's content type completely — AGL-2463 wrote that gap down. The\n * allowlist bounded what a file *claimed* to be and nothing bounded what it\n * *was*, so `Content-Type: application/pdf` over a `.exe` was stored,\n * hashed, given a CDN path and served. It now has to be a PDF.\n * 3. **Does an Office document carry a macro project?** AGL-1465 kept\n * `.docm`/`.xlsm`/`.pptm` out **by content type**, which is a gate on a\n * string the uploader chooses. Renaming a macro-enabled document to\n * `.docx` walked straight past it. The archive is now checked for a\n * `vbaProject.bin` entry, which is the thing that made those extensions\n * worth excluding in the first place.\n *\n * Plus one signature, EICAR — see {@link EICAR_TEST_SIGNATURE}.\n *\n * ## What it therefore does not catch\n *\n * A malicious PDF that is a real PDF. A macro-free document that exploits a\n * reader. Anything inside a plain `application/zip`, which is a brand kit and\n * may legitimately contain a build tool. An obfuscated dropper in a JSON\n * file. Real malware detection needs signatures and an engine, which needs a\n * ClamAV service, which does not fit the budget this platform runs on — the\n * cost work is in the issue. Nothing here should be allowed to read as though\n * that decision went the other way.\n *\n * ## Why it is pure, and takes a window rather than a file\n *\n * Three of the four media chokepoints hold the whole file in memory and can\n * pass it straight in. The fourth — signed direct-to-storage upload — never\n * sees the bytes, because that is the entire reason it exists: a 200 MB video\n * goes browser → bucket and downloading it back into the function to look at\n * it would cost more than the feature. So that caller does two RANGED reads\n * instead, a few KB from each end, and passes them as {@link\n * InspectUploadInput.bytes} and {@link InspectUploadInput.tail}. Every\n * signature this module knows lives in one of those two windows, so the\n * signed route gets the same verdict for the price of a rounding error.\n */\n\n/**\n * The EICAR standard antivirus test string.\n *\n * A 68-byte printable string, defined by the European Institute for Computer\n * Antivirus Research, that every antivirus product agrees to flag and that is\n * completely inert. It exists so that a pipeline can be tested end to end\n * without anyone handling a live sample.\n *\n * It is matched here for exactly two reasons, and neither is malware\n * detection. First, it makes this module's own tests honest without checking\n * a real sample into the repository. Second, it is the probe a security\n * reviewer or a customer will actually try, and answering it with silence\n * would be a worse lie than answering it with a refusal — a refusal that says\n * \"structural check\" is true, where a silent accept invites the reader to\n * conclude nothing is checked at all.\n *\n * **This is a signature list of one. It is not an engine.** Do not add a\n * second entry here and start calling the result malware scanning; if real\n * signatures are wanted, they belong in a scanner service, not in a string\n * constant.\n */\nexport const EICAR_TEST_SIGNATURE =\n 'X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'\n\n/**\n * How many leading bytes an inspection needs.\n *\n * Every magic number checked here sits in the first 16 bytes; the window is\n * far larger so that the EICAR match and the small-file macro scan have room,\n * and because the signed route's ranged GET costs the same for 4 KB as for\n * 64 bytes.\n */\nexport const UPLOAD_INSPECTION_HEAD_BYTES = 4096\n\n/**\n * How many trailing bytes an inspection wants for an Office document.\n *\n * A ZIP's central directory — the authoritative list of what is inside it —\n * lives at the end of the file. 64 KB covers the directory of any document\n * archive this platform accepts (the largest ceiling is a 50 MB `.pptx`, and\n * its directory is a few KB even with hundreds of embedded images). A\n * document whose directory is somehow larger than this window degrades to\n * \"macro not found\", which is the pre-existing behaviour, not a regression.\n */\nexport const UPLOAD_INSPECTION_TAIL_BYTES = 65536\n\nexport type UploadInspectionCode =\n | 'signature_match'\n | 'executable_bytes'\n | 'type_mismatch'\n | 'macro_payload'\n | 'empty_file'\n\nexport interface UploadInspectionRefusal {\n /** Stable machine code — for the API error body, logs and tests. */\n code: UploadInspectionCode\n /** What the bytes turned out to be, in words rather than hex. */\n detected: string\n /**\n * The sentence the uploader sees. Names the file and what was found, so\n * that a merchant who did nothing wrong can tell which of fifty dropped\n * files was the problem and why.\n */\n message: string\n}\n\nexport interface InspectUploadInput {\n /**\n * The whole file, or its leading {@link UPLOAD_INSPECTION_HEAD_BYTES}.\n * Callers holding the whole file pass it and omit `tail`.\n */\n bytes: Uint8Array\n /**\n * The file's trailing bytes, supplied ONLY by a caller that passed a head\n * window rather than the whole file. When absent, `bytes` is searched for\n * the trailing structures too — which is correct precisely because a caller\n * that omits it is one that handed over the entire file.\n */\n tail?: Uint8Array | null\n /** The type the upload claims to be, already normalized by the caller. */\n contentType: string\n /** For the message only. Never used to decide anything. */\n fileName?: string | null\n}\n\ninterface Signature {\n /** Bytes to match. */\n magic: readonly number[]\n /** Where they start. */\n offset?: number\n /** Human name for the refusal message. */\n label: string\n}\n\nconst at = (bytes: Uint8Array, signature: Signature): boolean => {\n const offset = signature.offset ?? 0\n if (bytes.length < offset + signature.magic.length) return false\n for (let i = 0; i < signature.magic.length; i++) {\n if (bytes[offset + i] !== signature.magic[i]) return false\n }\n return true\n}\n\nconst ascii = (text: string): number[] =>\n Array.from(text, (character) => character.charCodeAt(0))\n\n/**\n * Executable and installer containers, refused whatever the upload claims to\n * be. This list is deliberately about CONTAINERS, not content: each entry is\n * a format whose entire purpose is to be run, and none of them has any\n * business in a media library under any content type.\n *\n * `#!` is NOT here on purpose. A shebang is two characters that a legitimate\n * `text/plain` upload can genuinely open with, it is not executable in a\n * browser, and the type-mismatch check below already refuses it under every\n * binary type. Refusing it outright would buy nothing and cost real uploads.\n */\nconst EXECUTABLE_SIGNATURES: readonly Signature[] = [\n { magic: ascii('MZ'), label: 'a Windows executable' },\n { magic: [0x7f, 0x45, 0x4c, 0x46], label: 'a Linux executable (ELF)' },\n { magic: [0xfe, 0xed, 0xfa, 0xce], label: 'a macOS executable (Mach-O)' },\n { magic: [0xfe, 0xed, 0xfa, 0xcf], label: 'a macOS executable (Mach-O)' },\n { magic: [0xce, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n { magic: [0xcf, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n // 0xCAFEBABE is both a Java class file and a multi-architecture Mach-O\n // binary. Both are code; the label names the likelier one for a DAM.\n { magic: [0xca, 0xfe, 0xba, 0xbe], label: 'a compiled program' },\n { magic: ascii('!<arch>'), label: 'an installer package' },\n { magic: [0xed, 0xab, 0xee, 0xdb], label: 'an installer package (RPM)' },\n // Windows shortcut — a .lnk is a launcher, and a classic phishing payload.\n { magic: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], label: 'a Windows shortcut' },\n]\n\n/** ZIP, in its three legal opening forms. Every OOXML document is one. */\nconst ZIP_SIGNATURES: readonly Signature[] = [\n { magic: [0x50, 0x4b, 0x03, 0x04], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x05, 0x06], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x07, 0x08], label: 'ZIP' },\n]\n\n/** Microsoft's pre-2007 container: legacy `.doc`, `.xls`, `.ppt` and `.msi`. */\nconst OLE_SIGNATURE: Signature = {\n magic: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],\n label: 'a legacy Office document',\n}\n\n/** ISO base media (`....ftyp`) — mp4, QuickTime, and the modern image codecs. */\nconst ISO_BMFF: Signature = { magic: ascii('ftyp'), offset: 4, label: 'ISO media' }\n\n/**\n * Accepted content type → the signatures its bytes may legally start with.\n *\n * A type ABSENT from this table is not checked for a match, and that is a\n * deliberate, load-bearing property rather than a gap to be filled with\n * guesses. `text/plain`, `text/csv`, `text/markdown`, `application/json` and\n * `image/svg+xml` are text: they have no magic number, any leading bytes are\n * legal, and inventing a heuristic for them would refuse real files. They are\n * still covered by the executable check above, which is the check that\n * matters for them — an `.exe` renamed `notes.txt` is refused; a CSV that is\n * merely unusual is not.\n */\nconst TYPE_SIGNATURES: Readonly<Record<string, readonly Signature[]>> = {\n 'application/pdf': [{ magic: ascii('%PDF-'), label: 'PDF' }],\n 'application/zip': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_SIGNATURES,\n // Word writes RTF under a `.doc` name often enough that refusing it would\n // be a real false positive, so both containers are legal for this type.\n 'application/msword': [OLE_SIGNATURE, { magic: ascii('{\\\\rtf'), label: 'RTF' }],\n 'application/vnd.ms-excel': [OLE_SIGNATURE],\n 'application/vnd.ms-powerpoint': [OLE_SIGNATURE],\n 'application/rtf': [{ magic: ascii('{\\\\rtf'), label: 'RTF' }],\n\n 'image/png': [{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], label: 'PNG' }],\n 'image/jpeg': [{ magic: [0xff, 0xd8, 0xff], label: 'JPEG' }],\n 'image/gif': [\n { magic: ascii('GIF87a'), label: 'GIF' },\n { magic: ascii('GIF89a'), label: 'GIF' },\n ],\n 'image/webp': [{ magic: ascii('RIFF'), label: 'WebP' }],\n 'image/bmp': [{ magic: ascii('BM'), label: 'BMP' }],\n 'image/tiff': [\n { magic: [0x49, 0x49, 0x2a, 0x00], label: 'TIFF' },\n { magic: [0x4d, 0x4d, 0x00, 0x2a], label: 'TIFF' },\n ],\n 'image/x-icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/vnd.microsoft.icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/avif': [ISO_BMFF],\n 'image/heic': [ISO_BMFF],\n 'image/heif': [ISO_BMFF],\n\n 'video/mp4': [ISO_BMFF],\n 'video/quicktime': [ISO_BMFF],\n 'video/webm': [{ magic: [0x1a, 0x45, 0xdf, 0xa3], label: 'Matroska/WebM' }],\n\n // Audio for the Music player (AGL-3716). An MP3 opens with an ID3 tag or\n // straight on an MPEG audio frame (sync bits, then the layer III and\n // MPEG-2/2.5 headers encoders actually write); an AAC file is ADTS frames\n // or the same ID3 tag; M4A is ISO media like MP4; OGG and WAV name their\n // containers.\n 'audio/mpeg': [\n { magic: ascii('ID3'), label: 'MP3' },\n { magic: [0xff, 0xfb], label: 'MP3' },\n { magic: [0xff, 0xfa], label: 'MP3' },\n { magic: [0xff, 0xf3], label: 'MP3' },\n { magic: [0xff, 0xf2], label: 'MP3' },\n { magic: [0xff, 0xe3], label: 'MP3' },\n { magic: [0xff, 0xe2], label: 'MP3' },\n ],\n 'audio/aac': [\n { magic: [0xff, 0xf1], label: 'AAC' },\n { magic: [0xff, 0xf9], label: 'AAC' },\n { magic: ascii('ID3'), label: 'AAC' },\n ],\n 'audio/mp4': [ISO_BMFF],\n 'audio/ogg': [{ magic: ascii('OggS'), label: 'OGG' }],\n 'audio/wav': [{ magic: ascii('RIFF'), label: 'WAV' }],\n\n // A web font (AGL-3656): the theme's font installer stores WOFF2 only.\n 'font/woff2': [{ magic: ascii('wOF2'), label: 'WOFF2' }],\n}\n\n/** The OOXML document types whose archive is scanned for a macro project. */\nconst OOXML_DOCUMENT_TYPES = new Set([\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n])\n\n/** The pre-2007 Office types whose OLE directory is scanned for the same. */\nconst LEGACY_OFFICE_TYPES = new Set([\n 'application/msword',\n 'application/vnd.ms-excel',\n 'application/vnd.ms-powerpoint',\n])\n\n/** The photo types {@link sniffPhotoContentType} tells apart, in the order it checks them. */\nconst SNIFFED_PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/gif'] as const\n\n/** WebP's own mark after its RIFF header: a RIFF file is a WebP only where it says so. */\nconst WEBP_MARK: Signature = { magic: ascii('WEBP'), offset: 8, label: 'WebP' }\n\n/**\n * The photo type a file's own bytes say it is (AGL-3923): JPEG, PNG, GIF or\n * WebP, by the signatures {@link inspectUploadBytes} holds a declared type to,\n * so a file labeled with what this returns passes the type check. `null` for\n * any other bytes. A library's label is not proof: Pixabay served photo\n * 10157445 as image/png, the upload check refused it as not PNG, and the\n * beta.267 Brightvale start lost every stock photo it had chosen.\n */\nexport function sniffPhotoContentType(bytes: Uint8Array): string | null {\n for (const type of SNIFFED_PHOTO_TYPES) {\n if (TYPE_SIGNATURES[type]?.some((signature) => at(bytes, signature))) return type\n }\n const riff = TYPE_SIGNATURES['image/webp']?.[0]\n if (riff && at(bytes, riff) && at(bytes, WEBP_MARK)) return 'image/webp'\n return null\n}\n\n/**\n * Does a caller holding only a head window need to fetch the tail as well?\n *\n * Only the macro scan reads the end of a file, and only for document\n * archives, so this is what lets the signed-upload route skip a second ranged\n * GET for the 200 MB videos that route exists to carry — which is every\n * object on it bar a handful of documents.\n */\nexport function uploadInspectionNeedsTail(contentType: string): boolean {\n return (\n OOXML_DOCUMENT_TYPES.has(contentType) || LEGACY_OFFICE_TYPES.has(contentType)\n )\n}\n\nconst indexOfBytes = (haystack: Uint8Array, needle: Uint8Array): number => {\n if (!needle.length || haystack.length < needle.length) return -1\n const last = haystack.length - needle.length\n outer: for (let i = 0; i <= last; i++) {\n for (let j = 0; j < needle.length; j++) {\n if (haystack[i + j] !== needle[j]) continue outer\n }\n return i\n }\n return -1\n}\n\nconst contains = (haystack: Uint8Array, needle: Uint8Array): boolean =>\n indexOfBytes(haystack, needle) >= 0\n\nconst EICAR_BYTES = new Uint8Array(ascii(EICAR_TEST_SIGNATURE))\n\n/** `vbaProject.bin` as an OOXML archive entry name. */\nconst VBA_ENTRY = new Uint8Array(ascii('vbaProject.bin'))\n\n/**\n * `_VBA_PROJECT` as it appears in an OLE directory, which stores stream names\n * as UTF-16LE. Matching the encoded form rather than the ASCII one is what\n * keeps this from firing on a document that merely contains the words.\n */\nconst OLE_VBA_STREAM = new Uint8Array(\n ascii('_VBA_PROJECT').flatMap((code) => [code, 0x00]),\n)\n\nconst describeFile = (fileName?: string | null): string => {\n const name = String(fileName ?? '').trim()\n return name ? `\"${name}\"` : 'This file'\n}\n\n/**\n * Inspect an upload's bytes against the type it claims to be.\n *\n * Returns `null` when nothing structural is wrong — which, to be explicit, is\n * NOT a statement that the file is safe. See the module header.\n */\nexport function inspectUploadBytes(\n input: InspectUploadInput,\n): UploadInspectionRefusal | null {\n const { bytes, contentType, fileName } = input\n const head = bytes ?? new Uint8Array(0)\n // A caller that passed the whole file gets the whole file searched for the\n // trailing structures too; only a ranged caller supplies a separate tail.\n const tail = input.tail ?? head\n const subject = describeFile(fileName)\n\n if (!head.length) {\n return {\n code: 'empty_file',\n detected: 'an empty file',\n message: `${subject} is empty, so there is nothing to store.`,\n }\n }\n\n // 1. The signature list of one. Checked first so that a probe gets the\n // answer it is actually asking for, whatever else is also wrong.\n if (contains(head, EICAR_BYTES) || contains(tail, EICAR_BYTES)) {\n return {\n code: 'signature_match',\n detected: 'the EICAR antivirus test file',\n message:\n `${subject} matches the EICAR test signature and was refused by the ` +\n `upload structure check.`,\n }\n }\n\n // 2. Executable containers, refused under every declared type.\n for (const signature of EXECUTABLE_SIGNATURES) {\n if (at(head, signature)) {\n return {\n code: 'executable_bytes',\n detected: signature.label,\n message:\n `${subject} contains ${signature.label}, which cannot be uploaded ` +\n `whatever it is named or labeled.`,\n }\n }\n }\n\n // 3. The declared type against what the bytes actually are.\n const expected = TYPE_SIGNATURES[contentType]\n if (expected && !expected.some((signature) => at(head, signature))) {\n return {\n code: 'type_mismatch',\n detected: `something other than ${expected[0].label}`,\n message:\n `${subject} is labeled ${contentType} but its contents are not ` +\n `${expected[0].label}. Re-save it in the format it claims to be, or ` +\n `upload it under its real type.`,\n }\n }\n\n // 4. A macro project inside a document whose type is not supposed to have\n // one. This is the check the content-type allowlist could never make.\n if (OOXML_DOCUMENT_TYPES.has(contentType)) {\n if (contains(head, VBA_ENTRY) || contains(tail, VBA_ENTRY)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n } else if (LEGACY_OFFICE_TYPES.has(contentType)) {\n if (contains(head, OLE_VBA_STREAM) || contains(tail, OLE_VBA_STREAM)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n }\n\n return null\n}\n\nexport default inspectUploadBytes\n"],"names":["EICAR_TEST_SIGNATURE","UPLOAD_INSPECTION_HEAD_BYTES","UPLOAD_INSPECTION_TAIL_BYTES","at","bytes","signature","offset","length","magic","i","ascii","text","Array","from","character","charCodeAt","EXECUTABLE_SIGNATURES","label","ZIP_SIGNATURES","OLE_SIGNATURE","ISO_BMFF","TYPE_SIGNATURES","OOXML_DOCUMENT_TYPES","Set","LEGACY_OFFICE_TYPES","SNIFFED_PHOTO_TYPES","WEBP_MARK","sniffPhotoContentType","type","some","riff","uploadInspectionNeedsTail","contentType","has","indexOfBytes","haystack","needle","last","outer","j","contains","EICAR_BYTES","Uint8Array","VBA_ENTRY","OLE_VBA_STREAM","flatMap","code","describeFile","fileName","name","String","trim","inspectUploadBytes","input","head","tail","subject","detected","message","expected"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/upload-inspection.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 * STRUCTURAL upload inspection (AGL-1475).\n *\n * ## What this is, and what it is emphatically not\n *\n * This is **not** an antivirus scanner and no code in this module should ever\n * be described as one. It does not have malware signatures, it does not\n * emulate, unpack or detonate anything, and a novel trojan with a correct PDF\n * header passes it without a murmur. It is *structural validation*: it reads\n * the first few kilobytes of an upload and answers three questions the\n * platform previously could not ask at all.\n *\n * 1. **Are these bytes an executable?** A Windows PE, an ELF binary, a Mach-O\n * binary or an installer package is refused whatever it claims to be. This\n * is the single highest-value check here: the Sept-1 risk is a customer's\n * domain re-serving a trojan to a visitor's browser, and the overwhelming\n * majority of that population is a plain executable with a lying name.\n * 2. **Do the bytes match the declared type?** Media ingress trusted the\n * caller's content type completely — AGL-2463 wrote that gap down. The\n * allowlist bounded what a file *claimed* to be and nothing bounded what it\n * *was*, so `Content-Type: application/pdf` over a `.exe` was stored,\n * hashed, given a CDN path and served. It now has to be a PDF.\n * 3. **Does an Office document carry a macro project?** AGL-1465 kept\n * `.docm`/`.xlsm`/`.pptm` out **by content type**, which is a gate on a\n * string the uploader chooses. Renaming a macro-enabled document to\n * `.docx` walked straight past it. The archive is now checked for a\n * `vbaProject.bin` entry, which is the thing that made those extensions\n * worth excluding in the first place.\n *\n * Plus one signature, EICAR — see {@link EICAR_TEST_SIGNATURE}.\n *\n * ## What it therefore does not catch\n *\n * A malicious PDF that is a real PDF. A macro-free document that exploits a\n * reader. Anything inside a plain `application/zip`, which is a brand kit and\n * may legitimately contain a build tool. An obfuscated dropper in a JSON\n * file. Real malware detection needs signatures and an engine, which needs a\n * ClamAV service, which does not fit the budget this platform runs on — the\n * cost work is in the issue. Nothing here should be allowed to read as though\n * that decision went the other way.\n *\n * ## Why it is pure, and takes a window rather than a file\n *\n * Three of the four media chokepoints hold the whole file in memory and can\n * pass it straight in. The fourth — signed direct-to-storage upload — never\n * sees the bytes, because that is the entire reason it exists: a 200 MB video\n * goes browser → bucket and downloading it back into the function to look at\n * it would cost more than the feature. So that caller does two RANGED reads\n * instead, a few KB from each end, and passes them as {@link\n * InspectUploadInput.bytes} and {@link InspectUploadInput.tail}. Every\n * signature this module knows lives in one of those two windows, so the\n * signed route gets the same verdict for the price of a rounding error.\n */\n\n/**\n * The EICAR standard antivirus test string.\n *\n * A 68-byte printable string, defined by the European Institute for Computer\n * Antivirus Research, that every antivirus product agrees to flag and that is\n * completely inert. It exists so that a pipeline can be tested end to end\n * without anyone handling a live sample.\n *\n * It is matched here for exactly two reasons, and neither is malware\n * detection. First, it makes this module's own tests honest without checking\n * a real sample into the repository. Second, it is the probe a security\n * reviewer or a customer will actually try, and answering it with silence\n * would be a worse lie than answering it with a refusal — a refusal that says\n * \"structural check\" is true, where a silent accept invites the reader to\n * conclude nothing is checked at all.\n *\n * **This is a signature list of one. It is not an engine.** Do not add a\n * second entry here and start calling the result malware scanning; if real\n * signatures are wanted, they belong in a scanner service, not in a string\n * constant.\n */\nexport const EICAR_TEST_SIGNATURE =\n 'X5O!P%@AP[4\\\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'\n\n/**\n * How many leading bytes an inspection needs.\n *\n * Every magic number checked here sits in the first 16 bytes; the window is\n * far larger so that the EICAR match and the small-file macro scan have room,\n * and because the signed route's ranged GET costs the same for 4 KB as for\n * 64 bytes.\n */\nexport const UPLOAD_INSPECTION_HEAD_BYTES = 4096\n\n/**\n * How many trailing bytes an inspection wants for an Office document.\n *\n * A ZIP's central directory — the authoritative list of what is inside it —\n * lives at the end of the file. 64 KB covers the directory of any document\n * archive this platform accepts (the largest ceiling is a 50 MB `.pptx`, and\n * its directory is a few KB even with hundreds of embedded images). A\n * document whose directory is somehow larger than this window degrades to\n * \"macro not found\", which is the pre-existing behaviour, not a regression.\n */\nexport const UPLOAD_INSPECTION_TAIL_BYTES = 65536\n\nexport type UploadInspectionCode =\n | 'signature_match'\n | 'executable_bytes'\n | 'type_mismatch'\n | 'macro_payload'\n | 'empty_file'\n\nexport interface UploadInspectionRefusal {\n /** Stable machine code — for the API error body, logs and tests. */\n code: UploadInspectionCode\n /** What the bytes turned out to be, in words rather than hex. */\n detected: string\n /**\n * The sentence the uploader sees. Names the file and what was found, so\n * that a merchant who did nothing wrong can tell which of fifty dropped\n * files was the problem and why.\n */\n message: string\n}\n\nexport interface InspectUploadInput {\n /**\n * The whole file, or its leading {@link UPLOAD_INSPECTION_HEAD_BYTES}.\n * Callers holding the whole file pass it and omit `tail`.\n */\n bytes: Uint8Array\n /**\n * The file's trailing bytes, supplied ONLY by a caller that passed a head\n * window rather than the whole file. When absent, `bytes` is searched for\n * the trailing structures too — which is correct precisely because a caller\n * that omits it is one that handed over the entire file.\n */\n tail?: Uint8Array | null\n /** The type the upload claims to be, already normalized by the caller. */\n contentType: string\n /** For the message only. Never used to decide anything. */\n fileName?: string | null\n}\n\ninterface Signature {\n /** Bytes to match. */\n magic: readonly number[]\n /** Where they start. */\n offset?: number\n /** Human name for the refusal message. */\n label: string\n}\n\nconst at = (bytes: Uint8Array, signature: Signature): boolean => {\n const offset = signature.offset ?? 0\n if (bytes.length < offset + signature.magic.length) return false\n for (let i = 0; i < signature.magic.length; i++) {\n if (bytes[offset + i] !== signature.magic[i]) return false\n }\n return true\n}\n\nconst ascii = (text: string): number[] =>\n Array.from(text, (character) => character.charCodeAt(0))\n\n/**\n * Executable and installer containers, refused whatever the upload claims to\n * be. This list is deliberately about CONTAINERS, not content: each entry is\n * a format whose entire purpose is to be run, and none of them has any\n * business in a media library under any content type.\n *\n * `#!` is NOT here on purpose. A shebang is two characters that a legitimate\n * `text/plain` upload can genuinely open with, it is not executable in a\n * browser, and the type-mismatch check below already refuses it under every\n * binary type. Refusing it outright would buy nothing and cost real uploads.\n */\nconst EXECUTABLE_SIGNATURES: readonly Signature[] = [\n { magic: ascii('MZ'), label: 'a Windows executable' },\n { magic: [0x7f, 0x45, 0x4c, 0x46], label: 'a Linux executable (ELF)' },\n { magic: [0xfe, 0xed, 0xfa, 0xce], label: 'a macOS executable (Mach-O)' },\n { magic: [0xfe, 0xed, 0xfa, 0xcf], label: 'a macOS executable (Mach-O)' },\n { magic: [0xce, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n { magic: [0xcf, 0xfa, 0xed, 0xfe], label: 'a macOS executable (Mach-O)' },\n // 0xCAFEBABE is both a Java class file and a multi-architecture Mach-O\n // binary. Both are code; the label names the likelier one for a DAM.\n { magic: [0xca, 0xfe, 0xba, 0xbe], label: 'a compiled program' },\n { magic: ascii('!<arch>'), label: 'an installer package' },\n { magic: [0xed, 0xab, 0xee, 0xdb], label: 'an installer package (RPM)' },\n // Windows shortcut — a .lnk is a launcher, and a classic phishing payload.\n { magic: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], label: 'a Windows shortcut' },\n]\n\n/** ZIP, in its three legal opening forms. Every OOXML document is one. */\nconst ZIP_SIGNATURES: readonly Signature[] = [\n { magic: [0x50, 0x4b, 0x03, 0x04], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x05, 0x06], label: 'ZIP' },\n { magic: [0x50, 0x4b, 0x07, 0x08], label: 'ZIP' },\n]\n\n/** Microsoft's pre-2007 container: legacy `.doc`, `.xls`, `.ppt` and `.msi`. */\nconst OLE_SIGNATURE: Signature = {\n magic: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],\n label: 'a legacy Office document',\n}\n\n/** ISO base media (`....ftyp`) — mp4, QuickTime, and the modern image codecs. */\nconst ISO_BMFF: Signature = { magic: ascii('ftyp'), offset: 4, label: 'ISO media' }\n\n/**\n * Accepted content type → the signatures its bytes may legally start with.\n *\n * A type ABSENT from this table is not checked for a match, and that is a\n * deliberate, load-bearing property rather than a gap to be filled with\n * guesses. `text/plain`, `text/csv`, `text/markdown`, `application/json` and\n * `image/svg+xml` are text: they have no magic number, any leading bytes are\n * legal, and inventing a heuristic for them would refuse real files. They are\n * still covered by the executable check above, which is the check that\n * matters for them — an `.exe` renamed `notes.txt` is refused; a CSV that is\n * merely unusual is not.\n */\nconst TYPE_SIGNATURES: Readonly<Record<string, readonly Signature[]>> = {\n 'application/pdf': [{ magic: ascii('%PDF-'), label: 'PDF' }],\n 'application/zip': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ZIP_SIGNATURES,\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ZIP_SIGNATURES,\n // Word writes RTF under a `.doc` name often enough that refusing it would\n // be a real false positive, so both containers are legal for this type.\n 'application/msword': [OLE_SIGNATURE, { magic: ascii('{\\\\rtf'), label: 'RTF' }],\n 'application/vnd.ms-excel': [OLE_SIGNATURE],\n 'application/vnd.ms-powerpoint': [OLE_SIGNATURE],\n 'application/rtf': [{ magic: ascii('{\\\\rtf'), label: 'RTF' }],\n\n 'image/png': [{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], label: 'PNG' }],\n 'image/jpeg': [{ magic: [0xff, 0xd8, 0xff], label: 'JPEG' }],\n 'image/gif': [\n { magic: ascii('GIF87a'), label: 'GIF' },\n { magic: ascii('GIF89a'), label: 'GIF' },\n ],\n 'image/webp': [{ magic: ascii('RIFF'), label: 'WebP' }],\n 'image/bmp': [{ magic: ascii('BM'), label: 'BMP' }],\n 'image/tiff': [\n { magic: [0x49, 0x49, 0x2a, 0x00], label: 'TIFF' },\n { magic: [0x4d, 0x4d, 0x00, 0x2a], label: 'TIFF' },\n ],\n 'image/x-icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/vnd.microsoft.icon': [{ magic: [0x00, 0x00, 0x01, 0x00], label: 'icon' }],\n 'image/avif': [ISO_BMFF],\n 'image/heic': [ISO_BMFF],\n 'image/heif': [ISO_BMFF],\n\n 'video/mp4': [ISO_BMFF],\n 'video/quicktime': [ISO_BMFF],\n 'video/webm': [{ magic: [0x1a, 0x45, 0xdf, 0xa3], label: 'Matroska/WebM' }],\n\n // Audio for the Music player (AGL-3716). An MP3 opens with an ID3 tag or\n // straight on an MPEG audio frame (sync bits, then the layer III and\n // MPEG-2/2.5 headers encoders actually write); an AAC file is ADTS frames\n // or the same ID3 tag; M4A is ISO media like MP4; OGG and WAV name their\n // containers.\n 'audio/mpeg': [\n { magic: ascii('ID3'), label: 'MP3' },\n { magic: [0xff, 0xfb], label: 'MP3' },\n { magic: [0xff, 0xfa], label: 'MP3' },\n { magic: [0xff, 0xf3], label: 'MP3' },\n { magic: [0xff, 0xf2], label: 'MP3' },\n { magic: [0xff, 0xe3], label: 'MP3' },\n { magic: [0xff, 0xe2], label: 'MP3' },\n ],\n 'audio/aac': [\n { magic: [0xff, 0xf1], label: 'AAC' },\n { magic: [0xff, 0xf9], label: 'AAC' },\n { magic: ascii('ID3'), label: 'AAC' },\n ],\n 'audio/mp4': [ISO_BMFF],\n 'audio/ogg': [{ magic: ascii('OggS'), label: 'OGG' }],\n 'audio/wav': [{ magic: ascii('RIFF'), label: 'WAV' }],\n\n // A web font (AGL-3656): the theme's font installer stores WOFF2 only.\n 'font/woff2': [{ magic: ascii('wOF2'), label: 'WOFF2' }],\n\n // Design files a business and its customer trade as proofs and artwork\n // (AGL-4096, `visitor-upload.ts`). Illustrator saves a PDF-compatible file\n // by default and plain PostScript when that option is off; an EPS is\n // PostScript, or PostScript inside the DOS EPS binary header; a Photoshop\n // document opens `8BPS`.\n 'application/illustrator': [\n { magic: ascii('%PDF-'), label: 'Illustrator' },\n { magic: ascii('%!PS'), label: 'Illustrator' },\n ],\n 'application/postscript': [\n { magic: ascii('%!PS'), label: 'PostScript' },\n { magic: [0xc5, 0xd0, 0xd3, 0xc6], label: 'PostScript' },\n ],\n 'image/vnd.adobe.photoshop': [{ magic: ascii('8BPS'), label: 'Photoshop' }],\n}\n\n/** The OOXML document types whose archive is scanned for a macro project. */\nconst OOXML_DOCUMENT_TYPES = new Set([\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n])\n\n/** The pre-2007 Office types whose OLE directory is scanned for the same. */\nconst LEGACY_OFFICE_TYPES = new Set([\n 'application/msword',\n 'application/vnd.ms-excel',\n 'application/vnd.ms-powerpoint',\n])\n\n/** The photo types {@link sniffPhotoContentType} tells apart, in the order it checks them. */\nconst SNIFFED_PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/gif'] as const\n\n/** WebP's own mark after its RIFF header: a RIFF file is a WebP only where it says so. */\nconst WEBP_MARK: Signature = { magic: ascii('WEBP'), offset: 8, label: 'WebP' }\n\n/**\n * The photo type a file's own bytes say it is (AGL-3923): JPEG, PNG, GIF or\n * WebP, by the signatures {@link inspectUploadBytes} holds a declared type to,\n * so a file labeled with what this returns passes the type check. `null` for\n * any other bytes. A library's label is not proof: Pixabay served photo\n * 10157445 as image/png, the upload check refused it as not PNG, and the\n * beta.267 Brightvale start lost every stock photo it had chosen.\n */\nexport function sniffPhotoContentType(bytes: Uint8Array): string | null {\n for (const type of SNIFFED_PHOTO_TYPES) {\n if (TYPE_SIGNATURES[type]?.some((signature) => at(bytes, signature))) return type\n }\n const riff = TYPE_SIGNATURES['image/webp']?.[0]\n if (riff && at(bytes, riff) && at(bytes, WEBP_MARK)) return 'image/webp'\n return null\n}\n\n/**\n * Does a caller holding only a head window need to fetch the tail as well?\n *\n * Only the macro scan reads the end of a file, and only for document\n * archives, so this is what lets the signed-upload route skip a second ranged\n * GET for the 200 MB videos that route exists to carry — which is every\n * object on it bar a handful of documents.\n */\nexport function uploadInspectionNeedsTail(contentType: string): boolean {\n return (\n OOXML_DOCUMENT_TYPES.has(contentType) || LEGACY_OFFICE_TYPES.has(contentType)\n )\n}\n\nconst indexOfBytes = (haystack: Uint8Array, needle: Uint8Array): number => {\n if (!needle.length || haystack.length < needle.length) return -1\n const last = haystack.length - needle.length\n outer: for (let i = 0; i <= last; i++) {\n for (let j = 0; j < needle.length; j++) {\n if (haystack[i + j] !== needle[j]) continue outer\n }\n return i\n }\n return -1\n}\n\nconst contains = (haystack: Uint8Array, needle: Uint8Array): boolean =>\n indexOfBytes(haystack, needle) >= 0\n\nconst EICAR_BYTES = new Uint8Array(ascii(EICAR_TEST_SIGNATURE))\n\n/** `vbaProject.bin` as an OOXML archive entry name. */\nconst VBA_ENTRY = new Uint8Array(ascii('vbaProject.bin'))\n\n/**\n * `_VBA_PROJECT` as it appears in an OLE directory, which stores stream names\n * as UTF-16LE. Matching the encoded form rather than the ASCII one is what\n * keeps this from firing on a document that merely contains the words.\n */\nconst OLE_VBA_STREAM = new Uint8Array(\n ascii('_VBA_PROJECT').flatMap((code) => [code, 0x00]),\n)\n\nconst describeFile = (fileName?: string | null): string => {\n const name = String(fileName ?? '').trim()\n return name ? `\"${name}\"` : 'This file'\n}\n\n/**\n * Inspect an upload's bytes against the type it claims to be.\n *\n * Returns `null` when nothing structural is wrong — which, to be explicit, is\n * NOT a statement that the file is safe. See the module header.\n */\nexport function inspectUploadBytes(\n input: InspectUploadInput,\n): UploadInspectionRefusal | null {\n const { bytes, contentType, fileName } = input\n const head = bytes ?? new Uint8Array(0)\n // A caller that passed the whole file gets the whole file searched for the\n // trailing structures too; only a ranged caller supplies a separate tail.\n const tail = input.tail ?? head\n const subject = describeFile(fileName)\n\n if (!head.length) {\n return {\n code: 'empty_file',\n detected: 'an empty file',\n message: `${subject} is empty, so there is nothing to store.`,\n }\n }\n\n // 1. The signature list of one. Checked first so that a probe gets the\n // answer it is actually asking for, whatever else is also wrong.\n if (contains(head, EICAR_BYTES) || contains(tail, EICAR_BYTES)) {\n return {\n code: 'signature_match',\n detected: 'the EICAR antivirus test file',\n message:\n `${subject} matches the EICAR test signature and was refused by the ` +\n `upload structure check.`,\n }\n }\n\n // 2. Executable containers, refused under every declared type.\n for (const signature of EXECUTABLE_SIGNATURES) {\n if (at(head, signature)) {\n return {\n code: 'executable_bytes',\n detected: signature.label,\n message:\n `${subject} contains ${signature.label}, which cannot be uploaded ` +\n `whatever it is named or labeled.`,\n }\n }\n }\n\n // 3. The declared type against what the bytes actually are.\n const expected = TYPE_SIGNATURES[contentType]\n if (expected && !expected.some((signature) => at(head, signature))) {\n return {\n code: 'type_mismatch',\n detected: `something other than ${expected[0].label}`,\n message:\n `${subject} is labeled ${contentType} but its contents are not ` +\n `${expected[0].label}. Re-save it in the format it claims to be, or ` +\n `upload it under its real type.`,\n }\n }\n\n // 4. A macro project inside a document whose type is not supposed to have\n // one. This is the check the content-type allowlist could never make.\n if (OOXML_DOCUMENT_TYPES.has(contentType)) {\n if (contains(head, VBA_ENTRY) || contains(tail, VBA_ENTRY)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n } else if (LEGACY_OFFICE_TYPES.has(contentType)) {\n if (contains(head, OLE_VBA_STREAM) || contains(tail, OLE_VBA_STREAM)) {\n return {\n code: 'macro_payload',\n detected: 'an embedded macro project',\n message:\n `${subject} contains an embedded macro project. Macro-enabled ` +\n `documents are not accepted — save it without macros and upload ` +\n `it again.`,\n }\n }\n }\n\n return null\n}\n\nexport default inspectUploadBytes\n"],"names":["EICAR_TEST_SIGNATURE","UPLOAD_INSPECTION_HEAD_BYTES","UPLOAD_INSPECTION_TAIL_BYTES","at","bytes","signature","offset","length","magic","i","ascii","text","Array","from","character","charCodeAt","EXECUTABLE_SIGNATURES","label","ZIP_SIGNATURES","OLE_SIGNATURE","ISO_BMFF","TYPE_SIGNATURES","OOXML_DOCUMENT_TYPES","Set","LEGACY_OFFICE_TYPES","SNIFFED_PHOTO_TYPES","WEBP_MARK","sniffPhotoContentType","type","some","riff","uploadInspectionNeedsTail","contentType","has","indexOfBytes","haystack","needle","last","outer","j","contains","EICAR_BYTES","Uint8Array","VBA_ENTRY","OLE_VBA_STREAM","flatMap","code","describeFile","fileName","name","String","trim","inspectUploadBytes","input","head","tail","subject","detected","message","expected"],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
import type { VisitorUploadFileView, VisitorUploadRequest } from './visitor-upload';
|
|
18
|
+
/** What a ticket route answers. */
|
|
19
|
+
export interface VisitorUploadTicketAnswer {
|
|
20
|
+
ticket: string;
|
|
21
|
+
upload: {
|
|
22
|
+
url: string;
|
|
23
|
+
fields: Record<string, string>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export type VisitorUploadOutcome = {
|
|
27
|
+
ok: true;
|
|
28
|
+
file: VisitorUploadFileView;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
error: string;
|
|
32
|
+
};
|
|
33
|
+
/** Sends one file through a ticket, the signed upload and finalize. Never throws. */
|
|
34
|
+
export declare function sendVisitorUpload(options: {
|
|
35
|
+
file: Blob & {
|
|
36
|
+
name: string;
|
|
37
|
+
};
|
|
38
|
+
requestTicket(request: VisitorUploadRequest): Promise<VisitorUploadTicketAnswer | {
|
|
39
|
+
error: string;
|
|
40
|
+
}>;
|
|
41
|
+
finalize(ticket: string): Promise<{
|
|
42
|
+
file: VisitorUploadFileView;
|
|
43
|
+
} | {
|
|
44
|
+
error: string;
|
|
45
|
+
}>;
|
|
46
|
+
fetchImpl?: typeof fetch;
|
|
47
|
+
}): Promise<VisitorUploadOutcome>;
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
*/ const NOT_SENT = 'The file could not be sent. Check your connection and try again.';
|
|
17
|
+
/** Sends one file through a ticket, the signed upload and finalize. Never throws. */ export async function sendVisitorUpload(options) {
|
|
18
|
+
var _options_fetchImpl;
|
|
19
|
+
const { file } = options;
|
|
20
|
+
const send = (_options_fetchImpl = options.fetchImpl) != null ? _options_fetchImpl : globalThis.fetch;
|
|
21
|
+
try {
|
|
22
|
+
var _ticket_upload_fields;
|
|
23
|
+
var _ticket_upload;
|
|
24
|
+
const ticket = await options.requestTicket({
|
|
25
|
+
name: file.name,
|
|
26
|
+
contentType: file.type,
|
|
27
|
+
size: file.size
|
|
28
|
+
});
|
|
29
|
+
if ('error' in ticket) return {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: ticket.error
|
|
32
|
+
};
|
|
33
|
+
if (!ticket.ticket || !((_ticket_upload = ticket.upload) == null ? void 0 : _ticket_upload.url)) return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: NOT_SENT
|
|
36
|
+
};
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
for (const [key, value] of Object.entries((_ticket_upload_fields = ticket.upload.fields) != null ? _ticket_upload_fields : {}))form.append(key, value);
|
|
39
|
+
// Storage reads the policy's fields first and the file last.
|
|
40
|
+
form.append('file', file);
|
|
41
|
+
await send(ticket.upload.url, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
mode: 'no-cors',
|
|
44
|
+
body: form
|
|
45
|
+
});
|
|
46
|
+
const finished = await options.finalize(ticket.ticket);
|
|
47
|
+
if ('error' in finished) return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: finished.error
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
file: finished.file
|
|
54
|
+
};
|
|
55
|
+
} catch (unused) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: NOT_SENT
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=visitor-upload-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/visitor-upload-client.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 type { VisitorUploadFileView, VisitorUploadRequest } from './visitor-upload'\n\n/*==========================================\n * SENDING ONE FILE FROM THE BROWSER (AGL-4096).\n *\n * Three steps, the middle one straight to Storage so a 25 MB file never\n * passes through a function (whose request body stops at 4.5 MB):\n *\n * 1. ask the route for a TICKET for this file's name, type and size;\n * 2. POST the bytes to the signed upload the ticket carries, as a form;\n * 3. ask the route to FINALIZE the ticket, which checks the bytes and keeps\n * them, and answers the file's record.\n *\n * The form in step 2 is sent `no-cors`. A site lives on its own domain, any\n * domain, and the bucket answers cross-origin requests only for the console's\n * addresses, which are listed one by one (AGL-1452); a form post needs no\n * such permission. Its answer cannot be read in return, and does not need to\n * be: finalize is what says whether the file arrived, and refuses one that\n * did not. Storage itself holds the post to the size, type and path signed\n * into its policy.\n *\n * The plugin supplies the two calls to its own routes, which decide who may\n * upload for what; this only moves the bytes.\n *=========================================*/\n\n/** What a ticket route answers. */\nexport interface VisitorUploadTicketAnswer {\n ticket: string\n upload: { url: string; fields: Record<string, string> }\n}\n\nexport type VisitorUploadOutcome = { ok: true; file: VisitorUploadFileView } | { ok: false; error: string }\n\nconst NOT_SENT = 'The file could not be sent. Check your connection and try again.'\n\n/** Sends one file through a ticket, the signed upload and finalize. Never throws. */\nexport async function sendVisitorUpload(options: {\n file: Blob & { name: string }\n requestTicket(request: VisitorUploadRequest): Promise<VisitorUploadTicketAnswer | { error: string }>\n finalize(ticket: string): Promise<{ file: VisitorUploadFileView } | { error: string }>\n fetchImpl?: typeof fetch\n}): Promise<VisitorUploadOutcome> {\n const { file } = options\n const send = options.fetchImpl ?? globalThis.fetch\n try {\n const ticket = await options.requestTicket({ name: file.name, contentType: file.type, size: file.size })\n if ('error' in ticket) return { ok: false, error: ticket.error }\n if (!ticket.ticket || !ticket.upload?.url) return { ok: false, error: NOT_SENT }\n const form = new FormData()\n for (const [key, value] of Object.entries(ticket.upload.fields ?? {})) form.append(key, value)\n // Storage reads the policy's fields first and the file last.\n form.append('file', file)\n await send(ticket.upload.url, { method: 'POST', mode: 'no-cors', body: form })\n const finished = await options.finalize(ticket.ticket)\n if ('error' in finished) return { ok: false, error: finished.error }\n return { ok: true, file: finished.file }\n } catch {\n return { ok: false, error: NOT_SENT }\n }\n}\n"],"names":["NOT_SENT","sendVisitorUpload","options","file","send","fetchImpl","globalThis","fetch","ticket","requestTicket","name","contentType","type","size","ok","error","upload","url","form","FormData","key","value","Object","entries","fields","append","method","mode","body","finished","finalize"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAmCD,MAAMA,WAAW;AAEjB,mFAAmF,GACnF,OAAO,eAAeC,kBAAkBC,OAKvC;QAEcA;IADb,MAAM,EAAEC,IAAI,EAAE,GAAGD;IACjB,MAAME,QAAOF,qBAAAA,QAAQG,SAAS,YAAjBH,qBAAqBI,WAAWC,KAAK;IAClD,IAAI;YAKwCC;YAFnBA;QAFvB,MAAMA,SAAS,MAAMN,QAAQO,aAAa,CAAC;YAAEC,MAAMP,KAAKO,IAAI;YAAEC,aAAaR,KAAKS,IAAI;YAAEC,MAAMV,KAAKU,IAAI;QAAC;QACtG,IAAI,WAAWL,QAAQ,OAAO;YAAEM,IAAI;YAAOC,OAAOP,OAAOO,KAAK;QAAC;QAC/D,IAAI,CAACP,OAAOA,MAAM,IAAI,GAACA,iBAAAA,OAAOQ,MAAM,qBAAbR,eAAeS,GAAG,GAAE,OAAO;YAAEH,IAAI;YAAOC,OAAOf;QAAS;QAC/E,MAAMkB,OAAO,IAAIC;QACjB,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,EAACf,wBAAAA,OAAOQ,MAAM,CAACQ,MAAM,YAApBhB,wBAAwB,CAAC,GAAIU,KAAKO,MAAM,CAACL,KAAKC;QACxF,6DAA6D;QAC7DH,KAAKO,MAAM,CAAC,QAAQtB;QACpB,MAAMC,KAAKI,OAAOQ,MAAM,CAACC,GAAG,EAAE;YAAES,QAAQ;YAAQC,MAAM;YAAWC,MAAMV;QAAK;QAC5E,MAAMW,WAAW,MAAM3B,QAAQ4B,QAAQ,CAACtB,OAAOA,MAAM;QACrD,IAAI,WAAWqB,UAAU,OAAO;YAAEf,IAAI;YAAOC,OAAOc,SAASd,KAAK;QAAC;QACnE,OAAO;YAAED,IAAI;YAAMX,MAAM0B,SAAS1B,IAAI;QAAC;IACzC,EAAE,eAAM;QACN,OAAO;YAAEW,IAAI;YAAOC,OAAOf;QAAS;IACtC;AACF"}
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
/** The most one file may weigh. */
|
|
18
|
+
export declare const VISITOR_UPLOAD_MAX_BYTES: number;
|
|
19
|
+
/** The most files one submission carries. */
|
|
20
|
+
export declare const VISITOR_UPLOAD_MAX_FILES = 10;
|
|
21
|
+
/** How long the signed upload a ticket carries can be used, from its minting. */
|
|
22
|
+
export declare const VISITOR_UPLOAD_TICKET_TTL_MS: number;
|
|
23
|
+
/**
|
|
24
|
+
* Where uploads wait until they are checked. Top-level, never under `hosts/`:
|
|
25
|
+
* the bucket's lifecycle deletes whatever is still here a day after it
|
|
26
|
+
* arrived (`cloud/media-bucket-lifecycle.json`), which no rule may do under a
|
|
27
|
+
* prefix that holds live customer files.
|
|
28
|
+
*/
|
|
29
|
+
export declare const VISITOR_UPLOAD_PENDING_PREFIX = "visitor-uploads-pending/";
|
|
30
|
+
/** The honeypot field a form posts beside its files, as the forms plugin names it. */
|
|
31
|
+
export declare const VISITOR_UPLOAD_HONEYPOT_FIELD = "website";
|
|
32
|
+
/** One kind of file a visitor may send. */
|
|
33
|
+
export interface VisitorUploadType {
|
|
34
|
+
/** The type the file is stored and served as. */
|
|
35
|
+
contentType: string;
|
|
36
|
+
/** Lower-case, with the dot. The first is the one a file is renamed to when it has none. */
|
|
37
|
+
extensions: readonly string[];
|
|
38
|
+
/** What a person calls it. */
|
|
39
|
+
label: string;
|
|
40
|
+
/**
|
|
41
|
+
* Shown in the page as a picture. Only raster formats a browser draws and
|
|
42
|
+
* that cannot carry script; everything else downloads.
|
|
43
|
+
*/
|
|
44
|
+
inline: boolean;
|
|
45
|
+
/** Other types a browser reports for this kind of file. */
|
|
46
|
+
aliases?: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
export declare const VISITOR_UPLOAD_TYPES: readonly VisitorUploadType[];
|
|
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;
|
|
66
|
+
/** The file picker's `accept`: every extension and type taken. */
|
|
67
|
+
export declare const VISITOR_UPLOAD_ACCEPT: string;
|
|
68
|
+
/** `25 MB`. */
|
|
69
|
+
export declare function formatVisitorUploadBytes(bytes: number): string;
|
|
70
|
+
/**
|
|
71
|
+
* The type a file is taken as, or null when it is not one this takes. The
|
|
72
|
+
* NAME decides, because a browser reports most design files as nothing or as
|
|
73
|
+
* something generic; the reported type only has to agree with it (be this
|
|
74
|
+
* type, one it is known by, or unknown). The bytes are checked against the
|
|
75
|
+
* answer after they arrive.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveVisitorUploadType(name: string, reportedType: string): VisitorUploadType | null;
|
|
78
|
+
/** A stored type, as the list of types knows it; null for anything else. */
|
|
79
|
+
export declare function visitorUploadTypeOf(contentType: string): VisitorUploadType | null;
|
|
80
|
+
/** Whether a stored file may be shown as a picture rather than downloaded. */
|
|
81
|
+
export declare function visitorUploadIsInline(contentType: string): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* A file name safe to keep and to put in a download's header: no folders, no
|
|
84
|
+
* control or quoting characters, at most 120 characters, and ending in the
|
|
85
|
+
* extension of the type it was taken as.
|
|
86
|
+
*/
|
|
87
|
+
export declare function safeVisitorUploadName(name: string, type: VisitorUploadType): string;
|
|
88
|
+
/** What the browser asks to upload: a file's name, its reported type and its size. */
|
|
89
|
+
export interface VisitorUploadRequest {
|
|
90
|
+
name: string;
|
|
91
|
+
contentType: string;
|
|
92
|
+
size: number;
|
|
93
|
+
}
|
|
94
|
+
/** A request, checked: the type it is taken as and a safe name. */
|
|
95
|
+
export interface CheckedVisitorUpload {
|
|
96
|
+
name: string;
|
|
97
|
+
type: VisitorUploadType;
|
|
98
|
+
size: number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Checks what the browser asks to send, before anything is signed: one of
|
|
102
|
+
* the types taken, not empty, not over the size. The answer's `error` names
|
|
103
|
+
* the file and what to do.
|
|
104
|
+
*/
|
|
105
|
+
export declare function checkVisitorUploadRequest(raw: unknown, options?: {
|
|
106
|
+
maxBytes?: number;
|
|
107
|
+
kinds?: readonly VisitorUploadKind[];
|
|
108
|
+
}): {
|
|
109
|
+
ok: true;
|
|
110
|
+
upload: CheckedVisitorUpload;
|
|
111
|
+
} | {
|
|
112
|
+
ok: false;
|
|
113
|
+
error: string;
|
|
114
|
+
code: 'type' | 'size' | 'empty';
|
|
115
|
+
};
|
|
116
|
+
/** A file a visitor or a member sent, once it is checked and kept. */
|
|
117
|
+
export interface VisitorUploadFile {
|
|
118
|
+
/** Unique within its purpose and record. */
|
|
119
|
+
id: string;
|
|
120
|
+
/** The name it was sent with, made safe. */
|
|
121
|
+
name: string;
|
|
122
|
+
contentType: string;
|
|
123
|
+
/** Bytes, as stored (an SVG may shrink when its script is stripped). */
|
|
124
|
+
size: number;
|
|
125
|
+
/** The private Storage object. Never sent to a visitor. */
|
|
126
|
+
path: string;
|
|
127
|
+
uploadedAtMs: number;
|
|
128
|
+
}
|
|
129
|
+
/** A file as a visitor's page may see it: no storage path. */
|
|
130
|
+
export type VisitorUploadFileView = Omit<VisitorUploadFile, 'path'>;
|
|
131
|
+
/** The record without its storage path. */
|
|
132
|
+
export declare function visitorUploadFileView(file: VisitorUploadFile): VisitorUploadFileView;
|
|
133
|
+
export declare function isVisitorUploadSegment(value: unknown): value is string;
|
|
134
|
+
/** What an upload is for: the site, what kind of thing (`quote-artwork`), and which one. */
|
|
135
|
+
export interface VisitorUploadScope {
|
|
136
|
+
hostId: string;
|
|
137
|
+
purpose: string;
|
|
138
|
+
purposeId: string;
|
|
139
|
+
}
|
|
140
|
+
/** Whether every part of a scope can be a path segment. */
|
|
141
|
+
export declare function isVisitorUploadScope(scope: Partial<VisitorUploadScope>): scope is VisitorUploadScope;
|
|
142
|
+
/** Where a file waits between the browser's upload and its check. */
|
|
143
|
+
export declare function visitorUploadPendingPath(scope: VisitorUploadScope, uploadId: string): string;
|
|
144
|
+
/** The folder every kept file of one purpose and record is in. */
|
|
145
|
+
export declare function visitorUploadFolder(scope: VisitorUploadScope): string;
|
|
146
|
+
/** Where a checked file is kept: private to the site, with no download token. */
|
|
147
|
+
export declare function visitorUploadFinalPath(scope: VisitorUploadScope, uploadId: string): string;
|
|
148
|
+
/**
|
|
149
|
+
* The files a browser picked, checked before any is sent: how many, and each
|
|
150
|
+
* one's type and size. `already` is how many the submission holds.
|
|
151
|
+
*/
|
|
152
|
+
export declare function checkVisitorUploadSelection(files: ReadonlyArray<{
|
|
153
|
+
name: string;
|
|
154
|
+
type: string;
|
|
155
|
+
size: number;
|
|
156
|
+
}>, already?: number, maxFiles?: number, kinds?: readonly VisitorUploadKind[]): string | null;
|