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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,140 @@
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
+ /** `PNG, JPEG, WebP, GIF, PDF, SVG, AI, EPS, PSD or TIFF`. */
50
+ export declare function describeVisitorUploadTypes(): string;
51
+ /** The file picker's `accept`: every extension and type taken. */
52
+ export declare const VISITOR_UPLOAD_ACCEPT: string;
53
+ /** `25 MB`. */
54
+ export declare function formatVisitorUploadBytes(bytes: number): string;
55
+ /**
56
+ * The type a file is taken as, or null when it is not one this takes. The
57
+ * NAME decides, because a browser reports most design files as nothing or as
58
+ * something generic; the reported type only has to agree with it (be this
59
+ * type, one it is known by, or unknown). The bytes are checked against the
60
+ * answer after they arrive.
61
+ */
62
+ export declare function resolveVisitorUploadType(name: string, reportedType: string): VisitorUploadType | null;
63
+ /** A stored type, as the list of types knows it; null for anything else. */
64
+ export declare function visitorUploadTypeOf(contentType: string): VisitorUploadType | null;
65
+ /** Whether a stored file may be shown as a picture rather than downloaded. */
66
+ export declare function visitorUploadIsInline(contentType: string): boolean;
67
+ /**
68
+ * A file name safe to keep and to put in a download's header: no folders, no
69
+ * control or quoting characters, at most 120 characters, and ending in the
70
+ * extension of the type it was taken as.
71
+ */
72
+ export declare function safeVisitorUploadName(name: string, type: VisitorUploadType): string;
73
+ /** What the browser asks to upload: a file's name, its reported type and its size. */
74
+ export interface VisitorUploadRequest {
75
+ name: string;
76
+ contentType: string;
77
+ size: number;
78
+ }
79
+ /** A request, checked: the type it is taken as and a safe name. */
80
+ export interface CheckedVisitorUpload {
81
+ name: string;
82
+ type: VisitorUploadType;
83
+ size: number;
84
+ }
85
+ /**
86
+ * Checks what the browser asks to send, before anything is signed: one of
87
+ * the types taken, not empty, not over the size. The answer's `error` names
88
+ * the file and what to do.
89
+ */
90
+ export declare function checkVisitorUploadRequest(raw: unknown, options?: {
91
+ maxBytes?: number;
92
+ }): {
93
+ ok: true;
94
+ upload: CheckedVisitorUpload;
95
+ } | {
96
+ ok: false;
97
+ error: string;
98
+ code: 'type' | 'size' | 'empty';
99
+ };
100
+ /** A file a visitor or a member sent, once it is checked and kept. */
101
+ export interface VisitorUploadFile {
102
+ /** Unique within its purpose and record. */
103
+ id: string;
104
+ /** The name it was sent with, made safe. */
105
+ name: string;
106
+ contentType: string;
107
+ /** Bytes, as stored (an SVG may shrink when its script is stripped). */
108
+ size: number;
109
+ /** The private Storage object. Never sent to a visitor. */
110
+ path: string;
111
+ uploadedAtMs: number;
112
+ }
113
+ /** A file as a visitor's page may see it: no storage path. */
114
+ export type VisitorUploadFileView = Omit<VisitorUploadFile, 'path'>;
115
+ /** The record without its storage path. */
116
+ export declare function visitorUploadFileView(file: VisitorUploadFile): VisitorUploadFileView;
117
+ export declare function isVisitorUploadSegment(value: unknown): value is string;
118
+ /** What an upload is for: the site, what kind of thing (`quote-artwork`), and which one. */
119
+ export interface VisitorUploadScope {
120
+ hostId: string;
121
+ purpose: string;
122
+ purposeId: string;
123
+ }
124
+ /** Whether every part of a scope can be a path segment. */
125
+ export declare function isVisitorUploadScope(scope: Partial<VisitorUploadScope>): scope is VisitorUploadScope;
126
+ /** Where a file waits between the browser's upload and its check. */
127
+ export declare function visitorUploadPendingPath(scope: VisitorUploadScope, uploadId: string): string;
128
+ /** The folder every kept file of one purpose and record is in. */
129
+ export declare function visitorUploadFolder(scope: VisitorUploadScope): string;
130
+ /** Where a checked file is kept: private to the site, with no download token. */
131
+ export declare function visitorUploadFinalPath(scope: VisitorUploadScope, uploadId: string): string;
132
+ /**
133
+ * The files a browser picked, checked before any is sent: how many, and each
134
+ * one's type and size. `already` is how many the submission holds.
135
+ */
136
+ export declare function checkVisitorUploadSelection(files: ReadonlyArray<{
137
+ name: string;
138
+ type: string;
139
+ size: number;
140
+ }>, already?: number, maxFiles?: number): string | null;