@uploadcn/server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ocr.js","names":[],"sources":["../src/ocr.ts"],"sourcesContent":["import {\n type FileDescriptor,\n type OcrField,\n type OcrLine,\n type OcrPage,\n type OcrResult,\n matchesAccept,\n} from \"@uploadcn/core\"\nimport { AwsClient } from \"aws4fetch\"\n\nimport { readStream } from \"./scan\"\nimport type { UploadStorage } from \"./storage\"\n\nexport type { OcrField, OcrLine, OcrPage, OcrResult } from \"@uploadcn/core\"\n\nexport interface OcrInput {\n data: Uint8Array\n /** MIME type, e.g. `image/png` or `application/pdf`. */\n type: string\n name?: string\n signal?: AbortSignal\n}\n\n/** A server-side OCR engine. */\nexport interface OcrProvider {\n readonly name: string\n recognize(input: OcrInput): Promise<OcrResult>\n}\n\nexport class OcrError extends Error {\n override readonly name = \"OcrError\"\n constructor(\n message: string,\n readonly status?: number\n ) {\n super(message)\n }\n}\n\n/** Builds a provider from a function, for engines without a built-in one. */\nexport function createOcrProvider(\n name: string,\n recognize: (input: OcrInput) => Promise<Omit<OcrResult, \"provider\">>\n): OcrProvider {\n return {\n name,\n async recognize(input) {\n return { ...(await recognize(input)), provider: name }\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n\n/** Base64 without Node's Buffer, so providers run on edge runtimes too. */\nexport function toBase64(bytes: Uint8Array) {\n let binary = \"\"\n for (let at = 0; at < bytes.length; at += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000))\n }\n return btoa(binary)\n}\n\nfunction average(values: (number | undefined)[]) {\n const known = values.filter((value): value is number => value != null)\n return known.length\n ? known.reduce((total, value) => total + value, 0) / known.length\n : undefined\n}\n\nfunction joinPages(pages: OcrPage[]) {\n return pages\n .map((page) => page.text.trim())\n .filter(Boolean)\n .join(\"\\n\\n\")\n}\n\nconst FIELD_NAMES: Record<string, string> = {\n // AWS Textract AnalyzeExpense\n VENDOR_NAME: \"merchant\",\n NAME: \"merchant\",\n INVOICE_RECEIPT_DATE: \"date\",\n TOTAL: \"total\",\n AMOUNT_DUE: \"amountDue\",\n SUBTOTAL: \"subtotal\",\n TAX: \"tax\",\n INVOICE_RECEIPT_ID: \"invoiceId\",\n // Azure Document Intelligence receipts and invoices\n MerchantName: \"merchant\",\n VendorName: \"merchant\",\n TransactionDate: \"date\",\n InvoiceDate: \"date\",\n Total: \"total\",\n InvoiceTotal: \"total\",\n AmountDue: \"amountDue\",\n Subtotal: \"subtotal\",\n SubTotal: \"subtotal\",\n TotalTax: \"tax\",\n InvoiceId: \"invoiceId\",\n}\n\nfunction addField(\n fields: Record<string, OcrField>,\n name: string,\n field: OcrField\n) {\n const key = FIELD_NAMES[name] ?? name\n const current = fields[key]\n // Keep the most confident value when a document repeats a field.\n if (!current || (field.confidence ?? 0) > (current.confidence ?? 0)) {\n fields[key] = field\n }\n}\n\nasync function readJson(response: Response, provider: string) {\n const text = await response.text()\n let body: unknown\n try {\n body = JSON.parse(text)\n } catch {\n body = undefined\n }\n if (!response.ok) {\n const message =\n (body as { error?: { message?: string }; message?: string } | undefined)\n ?.error?.message ??\n (body as { message?: string } | undefined)?.message ??\n text.slice(0, 200)\n throw new OcrError(\n `${provider} answered ${response.status}: ${message}`,\n response.status\n )\n }\n return body\n}\n\nconst sleep = (ms: number, signal?: AbortSignal) =>\n new Promise<void>((resolve, reject) => {\n const timer = setTimeout(resolve, ms)\n signal?.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n reject(signal.reason)\n },\n { once: true }\n )\n })\n\n// ---------------------------------------------------------------------------\n// Google Cloud Vision\n\nexport interface GoogleVisionOptions {\n /** API key restricted to the Vision API. Or use `accessToken`. */\n apiKey?: string\n /** OAuth access token (e.g. from a service account), instead of `apiKey`. */\n accessToken?: () => string | Promise<string>\n /** Language hints, e.g. `[\"en\", \"de\"]`. */\n languageHints?: string[]\n fetch?: typeof fetch\n}\n\ninterface VisionAnnotation {\n text?: string\n pages?: { confidence?: number }[]\n}\n\n/**\n * Google Cloud Vision `DOCUMENT_TEXT_DETECTION`. Images go through\n * `images:annotate`; PDFs and TIFFs through `files:annotate` (first 5 pages).\n */\nexport function googleVisionOcr(options: GoogleVisionOptions): OcrProvider {\n if (!options.apiKey && !options.accessToken) {\n throw new Error(\"[uploadcn] googleVisionOcr needs apiKey or accessToken\")\n }\n const fetcher = options.fetch ?? fetch\n const imageContext = options.languageHints\n ? { languageHints: options.languageHints }\n : undefined\n\n const post = async (path: string, body: unknown, signal?: AbortSignal) => {\n const headers: Record<string, string> = {\n \"content-type\": \"application/json\",\n }\n if (options.accessToken) {\n headers.authorization = `Bearer ${await options.accessToken()}`\n } else {\n headers[\"x-goog-api-key\"] = options.apiKey!\n }\n const response = await fetcher(`https://vision.googleapis.com/v1/${path}`, {\n method: \"POST\",\n headers,\n body: JSON.stringify(body),\n signal,\n })\n return readJson(response, \"Google Vision\")\n }\n\n const toPage = (\n annotation: VisionAnnotation | undefined,\n number: number\n ): OcrPage => ({\n number,\n text: annotation?.text?.trim() ?? \"\",\n lines: (annotation?.text ?? \"\")\n .split(\"\\n\")\n .filter((line) => line.trim())\n .map((line) => ({ text: line })),\n })\n\n return {\n name: \"google-vision\",\n async recognize({ data, type, signal }) {\n const features = [{ type: \"DOCUMENT_TEXT_DETECTION\" }]\n const content = toBase64(data)\n type Response = {\n error?: { message?: string }\n fullTextAnnotation?: VisionAnnotation\n }\n\n let annotations: VisionAnnotation[]\n if (\n type === \"application/pdf\" ||\n type === \"image/tiff\" ||\n type === \"image/gif\"\n ) {\n const body = (await post(\n \"files:annotate\",\n {\n requests: [\n {\n inputConfig: { content, mimeType: type },\n features,\n imageContext,\n pages: [1, 2, 3, 4, 5],\n },\n ],\n },\n signal\n )) as {\n responses?: { responses?: Response[]; error?: { message?: string } }[]\n }\n const file = body.responses?.[0]\n if (file?.error?.message)\n throw new OcrError(`Google Vision: ${file.error.message}`)\n annotations = (file?.responses ?? []).map(\n (page) => page.fullTextAnnotation ?? {}\n )\n } else {\n const body = (await post(\n \"images:annotate\",\n { requests: [{ image: { content }, features, imageContext }] },\n signal\n )) as { responses?: Response[] }\n const image = body.responses?.[0]\n if (image?.error?.message)\n throw new OcrError(`Google Vision: ${image.error.message}`)\n annotations = [image?.fullTextAnnotation ?? {}]\n }\n\n const pages = annotations.map((annotation, index) =>\n toPage(annotation, index + 1)\n )\n return {\n text: joinPages(pages),\n pages,\n confidence: average(\n annotations.flatMap(\n (annotation) =>\n annotation.pages?.map((page) => page.confidence) ?? []\n )\n ),\n provider: \"google-vision\",\n }\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// AWS Textract\n\nexport interface TextractOptions {\n region: string\n accessKeyId: string\n secretAccessKey: string\n sessionToken?: string\n /**\n * `\"text\"` (default): DetectDocumentText, lines of text.\n * `\"expense\"`: AnalyzeExpense, which also returns receipt and invoice\n * fields (merchant, date, total, tax…).\n */\n model?: \"text\" | \"expense\"\n fetch?: typeof fetch\n}\n\ninterface TextractBlock {\n BlockType?: string\n Text?: string\n Confidence?: number\n Page?: number\n}\n\n/**\n * AWS Textract, signed with aws4fetch (no AWS SDK). Synchronous APIs accept\n * JPEG, PNG, TIFF and single-page PDFs up to 10 MB.\n */\nexport function awsTextractOcr(options: TextractOptions): OcrProvider {\n const client = new AwsClient({\n accessKeyId: options.accessKeyId,\n secretAccessKey: options.secretAccessKey,\n sessionToken: options.sessionToken,\n region: options.region,\n service: \"textract\",\n })\n const endpoint = `https://textract.${options.region}.amazonaws.com/`\n const model = options.model ?? \"text\"\n\n const call = async (target: string, body: unknown, signal?: AbortSignal) => {\n const request = await client.sign(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/x-amz-json-1.1\",\n \"x-amz-target\": `Textract.${target}`,\n },\n body: JSON.stringify(body),\n })\n return readJson(\n await (options.fetch ?? fetch)(request, { signal }),\n \"AWS Textract\"\n )\n }\n\n const pagesFrom = (blocks: TextractBlock[]): OcrPage[] => {\n const byPage = new Map<number, OcrLine[]>()\n for (const block of blocks) {\n if (block.BlockType !== \"LINE\" || !block.Text) continue\n const page = block.Page ?? 1\n byPage.set(page, [\n ...(byPage.get(page) ?? []),\n {\n text: block.Text,\n confidence:\n block.Confidence != null ? block.Confidence / 100 : undefined,\n },\n ])\n }\n return [...byPage]\n .sort(([a], [b]) => a - b)\n .map(([number, lines]) => ({\n number,\n lines,\n text: lines.map((line) => line.text).join(\"\\n\"),\n }))\n }\n\n return {\n name: \"aws-textract\",\n async recognize({ data, signal }) {\n const Document = { Bytes: toBase64(data) }\n if (model === \"expense\") {\n const body = (await call(\"AnalyzeExpense\", { Document }, signal)) as {\n ExpenseDocuments?: {\n Blocks?: TextractBlock[]\n SummaryFields?: {\n Type?: { Text?: string }\n LabelDetection?: { Text?: string }\n ValueDetection?: { Text?: string; Confidence?: number }\n Currency?: { Code?: string }\n }[]\n }[]\n }\n const documents = body.ExpenseDocuments ?? []\n const fields: Record<string, OcrField> = {}\n for (const document of documents) {\n for (const field of document.SummaryFields ?? []) {\n const name = field.Type?.Text\n const value = field.ValueDetection?.Text\n if (!name || !value || name === \"OTHER\") continue\n addField(fields, name, {\n value,\n confidence:\n field.ValueDetection?.Confidence != null\n ? field.ValueDetection.Confidence / 100\n : undefined,\n })\n if (field.Currency?.Code && !fields.currency) {\n fields.currency = { value: field.Currency.Code }\n }\n }\n }\n const pages = pagesFrom(\n documents.flatMap((document) => document.Blocks ?? [])\n )\n return {\n text: joinPages(pages),\n pages,\n confidence: average(\n pages.flatMap(\n (page) => page.lines?.map((line) => line.confidence) ?? []\n )\n ),\n fields,\n provider: \"aws-textract\",\n }\n }\n const body = (await call(\"DetectDocumentText\", { Document }, signal)) as {\n Blocks?: TextractBlock[]\n }\n const pages = pagesFrom(body.Blocks ?? [])\n return {\n text: joinPages(pages),\n pages,\n confidence: average(\n pages.flatMap(\n (page) => page.lines?.map((line) => line.confidence) ?? []\n )\n ),\n provider: \"aws-textract\",\n }\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Azure AI Document Intelligence\n\nexport interface AzureDocumentIntelligenceOptions {\n /** Resource endpoint, e.g. `https://my-resource.cognitiveservices.azure.com`. */\n endpoint: string\n apiKey: string\n /**\n * `\"prebuilt-read\"` (default) for text, `\"prebuilt-receipt\"`,\n * `\"prebuilt-invoice\"`, `\"prebuilt-idDocument\"`, `\"prebuilt-layout\"` or a\n * custom model id.\n */\n model?: string\n apiVersion?: string\n /** Milliseconds to wait for the analysis. Default 60 000. */\n timeout?: number\n fetch?: typeof fetch\n}\n\ninterface AzureField {\n content?: string\n confidence?: number\n valueString?: string\n valueNumber?: number\n valueDate?: string\n valueCurrency?: { amount?: number; currencyCode?: string }\n}\n\n/** Azure AI Document Intelligence (formerly Form Recognizer). */\nexport function azureDocumentIntelligenceOcr(\n options: AzureDocumentIntelligenceOptions\n): OcrProvider {\n const fetcher = options.fetch ?? fetch\n const base = options.endpoint.replace(/\\/+$/, \"\")\n const origin = new URL(base).origin\n const model = options.model ?? \"prebuilt-read\"\n const version = options.apiVersion ?? \"2024-11-30\"\n const headers = { \"ocp-apim-subscription-key\": options.apiKey }\n\n return {\n name: \"azure-document-intelligence\",\n async recognize({ data, signal }) {\n const response = await fetcher(\n `${base}/documentintelligence/documentModels/${encodeURIComponent(model)}:analyze?api-version=${version}`,\n {\n method: \"POST\",\n headers: { ...headers, \"content-type\": \"application/json\" },\n body: JSON.stringify({ base64Source: toBase64(data) }),\n signal,\n }\n )\n if (response.status !== 202) {\n await readJson(response, \"Azure Document Intelligence\")\n throw new OcrError(\n `Azure Document Intelligence answered ${response.status}`\n )\n }\n const location = response.headers.get(\"operation-location\")\n // Only follow the poll URL on the resource's own origin; the API key\n // is sent with it.\n if (!location || new URL(location).origin !== origin) {\n throw new OcrError(\n \"Azure Document Intelligence returned an unexpected operation URL\"\n )\n }\n\n const deadline = Date.now() + (options.timeout ?? 60_000)\n let delay = 1000\n while (Date.now() < deadline) {\n await sleep(delay, signal)\n const poll = await fetcher(location, { headers, signal })\n const body = (await readJson(poll, \"Azure Document Intelligence\")) as {\n status?: string\n error?: { message?: string }\n analyzeResult?: {\n content?: string\n pages?: {\n pageNumber: number\n lines?: { content: string }[]\n words?: { confidence?: number }[]\n }[]\n documents?: { fields?: Record<string, AzureField> }[]\n }\n }\n if (body.status === \"failed\") {\n throw new OcrError(\n `Azure Document Intelligence: ${body.error?.message ?? \"analysis failed\"}`\n )\n }\n if (body.status !== \"succeeded\") {\n const retry = Number(poll.headers.get(\"retry-after\"))\n delay =\n Number.isFinite(retry) && retry > 0\n ? retry * 1000\n : Math.min(delay * 1.5, 5000)\n continue\n }\n const result = body.analyzeResult ?? {}\n const pages: OcrPage[] = (result.pages ?? []).map((page) => {\n const lines = (page.lines ?? []).map((line) => ({\n text: line.content,\n }))\n return {\n number: page.pageNumber,\n lines,\n text: lines.map((line) => line.text).join(\"\\n\"),\n }\n })\n const fields: Record<string, OcrField> = {}\n for (const document of result.documents ?? []) {\n for (const [name, field] of Object.entries(document.fields ?? {})) {\n const value =\n field.valueCurrency?.amount != null\n ? String(field.valueCurrency.amount)\n : (field.valueDate ??\n field.valueString ??\n field.content ??\n (field.valueNumber != null\n ? String(field.valueNumber)\n : undefined))\n if (value == null) continue\n addField(fields, name, { value, confidence: field.confidence })\n if (field.valueCurrency?.currencyCode && !fields.currency) {\n fields.currency = { value: field.valueCurrency.currencyCode }\n }\n }\n }\n return {\n text: result.content?.trim() ?? joinPages(pages),\n pages,\n confidence: average(\n (result.pages ?? []).flatMap(\n (page) => page.words?.map((word) => word.confidence) ?? []\n )\n ),\n ...(Object.keys(fields).length ? { fields } : {}),\n provider: \"azure-document-intelligence\",\n }\n }\n throw new OcrError(\"Azure Document Intelligence timed out\")\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Mistral OCR\n\nexport interface MistralOcrOptions {\n apiKey: string\n /** Default `\"mistral-ocr-latest\"`. */\n model?: string\n fetch?: typeof fetch\n}\n\n/** Strips Markdown syntax for the plain `text` field. */\nfunction markdownToText(markdown: string) {\n return markdown\n .replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, \"\")\n .replace(/\\[([^\\]]+)\\]\\([^)]*\\)/g, \"$1\")\n .replace(/^#{1,6}\\s+/gm, \"\")\n .replace(/(\\*\\*|__|`)/g, \"\")\n .replace(/^\\|?\\s*:?-{3,}.*$/gm, \"\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim()\n}\n\n/**\n * Mistral OCR. Reads PDFs and images and returns Markdown that keeps\n * headings, lists and tables, which is ideal input for an LLM.\n */\nexport function mistralOcr(options: MistralOcrOptions): OcrProvider {\n return {\n name: \"mistral\",\n async recognize({ data, type, signal }) {\n const url = `data:${type};base64,${toBase64(data)}`\n const document =\n type === \"application/pdf\"\n ? { type: \"document_url\", document_url: url }\n : { type: \"image_url\", image_url: url }\n const response = await (options.fetch ?? fetch)(\n \"https://api.mistral.ai/v1/ocr\",\n {\n method: \"POST\",\n headers: {\n authorization: `Bearer ${options.apiKey}`,\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({\n model: options.model ?? \"mistral-ocr-latest\",\n document,\n include_image_base64: false,\n }),\n signal,\n }\n )\n const body = (await readJson(response, \"Mistral OCR\")) as {\n pages?: { index: number; markdown?: string }[]\n }\n const markdownPages = (body.pages ?? []).map(\n (page) => page.markdown ?? \"\"\n )\n const pages = markdownPages.map((markdown, index) => {\n const text = markdownToText(markdown)\n return {\n number: (body.pages?.[index]?.index ?? index) + 1,\n text,\n lines: text\n .split(\"\\n\")\n .filter((line) => line.trim())\n .map((line) => ({ text: line })),\n }\n })\n return {\n text: joinPages(pages),\n pages,\n markdown: markdownPages.join(\"\\n\\n\"),\n provider: \"mistral\",\n }\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Your own OCR service\n\nexport interface HttpOcrOptions {\n /** Endpoint that receives `multipart/form-data` with a `file` field. */\n url: string\n headers?: Record<string, string>\n /** Maps the response to an {@link OcrResult}. Default: it already is one. */\n parse?: (body: unknown) => Omit<OcrResult, \"provider\">\n name?: string\n fetch?: typeof fetch\n}\n\n/**\n * Forwards files to your own OCR backend: a Python service running\n * PaddleOCR, docTR, EasyOCR or Tesseract, an internal model, or any other\n * HTTP API.\n */\nexport function httpOcr(options: HttpOcrOptions): OcrProvider {\n const name = options.name ?? \"http\"\n return {\n name,\n async recognize({ data, type, name: fileName, signal }) {\n const form = new FormData()\n form.append(\n \"file\",\n new Blob([data as BlobPart], { type }),\n fileName ?? \"file\"\n )\n const response = await (options.fetch ?? fetch)(options.url, {\n method: \"POST\",\n headers: options.headers,\n body: form,\n signal,\n })\n const body = await readJson(response, name)\n const parsed = options.parse\n ? options.parse(body)\n : (body as Omit<OcrResult, \"provider\">)\n if (typeof parsed?.text !== \"string\") {\n throw new OcrError(`${name} returned an unexpected response`)\n }\n const pages = Array.isArray(parsed.pages)\n ? parsed.pages\n : [{ number: 1, text: parsed.text }]\n return { ...parsed, pages, provider: name }\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// The route\n\n/** Raster images and PDFs. SVG is deliberately excluded (it can carry script). */\nexport const OCR_TYPES = [\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n \"image/tiff\",\n \"image/gif\",\n \"image/bmp\",\n \"application/pdf\",\n] as const\n\nexport interface OcrRouteOptions<TAuth = undefined> {\n provider: OcrProvider\n /**\n * Runs before every request: check the session, rate-limit, and for\n * `{ key }` requests make sure the caller owns the file. Throw\n * `OcrRouteError` to deny.\n */\n authorize?: (context: {\n request: Request\n key?: string\n }) => TAuth | Promise<TAuth>\n /** Largest file in bytes. Default 20 MB. */\n maxFileSize?: number\n /** Allowed types, in `<input accept>` syntax. Default {@link OCR_TYPES}. */\n allowedTypes?: string | readonly string[]\n /**\n * Lets clients OCR files that are already uploaded, by posting\n * `{ \"key\": \"…\" }` as JSON. The storage must be able to read objects.\n */\n storage?: UploadStorage\n /** Called with every result, e.g. to save it. Return a value to replace it. */\n onResult?: (context: {\n request: Request\n auth: TAuth\n file: FileDescriptor\n key?: string\n result: OcrResult\n }) => OcrResult | void | Promise<OcrResult | void>\n}\n\nexport class OcrRouteError extends Error {\n override readonly name = \"OcrRouteError\"\n constructor(\n message: string,\n readonly status = 400\n ) {\n super(message)\n }\n}\n\nconst EXTENSION_TYPES: Record<string, string> = {\n png: \"image/png\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n webp: \"image/webp\",\n tif: \"image/tiff\",\n tiff: \"image/tiff\",\n gif: \"image/gif\",\n bmp: \"image/bmp\",\n pdf: \"application/pdf\",\n}\n\nfunction json(data: unknown, status = 200) {\n return new Response(JSON.stringify(data), {\n status,\n headers: {\n \"content-type\": \"application/json\",\n \"cache-control\": \"no-store\",\n },\n })\n}\n\n/**\n * A POST endpoint that reads text from a file with any provider. Send\n * `multipart/form-data` with a `file` field (what `ocrEndpoint` in\n * `@uploadcn/core` does), or JSON `{ \"key\": \"…\" }` for a stored upload.\n *\n * @example\n * // app/api/ocr/route.ts\n * export const { POST } = createOcrRoute({\n * provider: googleVisionOcr({ apiKey: process.env.GOOGLE_VISION_API_KEY! }),\n * authorize: async ({ request }) => requireUser(request),\n * })\n */\nexport function createOcrRoute<TAuth = undefined>(\n options: OcrRouteOptions<TAuth>\n) {\n const maxFileSize = options.maxFileSize ?? 20 * 1024 * 1024\n const allowedTypes = options.allowedTypes ?? OCR_TYPES\n\n const check = (file: FileDescriptor) => {\n if (file.size > maxFileSize)\n throw new OcrRouteError(\"File is too large\", 413)\n if (!matchesAccept(file, allowedTypes)) {\n throw new OcrRouteError(\"This file type can't be read\", 415)\n }\n }\n\n async function handle(request: Request): Promise<Response> {\n try {\n if (request.method !== \"POST\")\n return json({ error: \"Method not allowed\" }, 405)\n const contentType = request.headers.get(\"content-type\") ?? \"\"\n const declared = Number(request.headers.get(\"content-length\") ?? NaN)\n // Multipart adds a little overhead around the file itself.\n if (declared > maxFileSize + 64 * 1024) {\n throw new OcrRouteError(\"File is too large\", 413)\n }\n\n let file: FileDescriptor\n let data: Uint8Array\n let key: string | undefined\n\n if (contentType.startsWith(\"multipart/form-data\")) {\n const auth = await options.authorize?.({ request })\n let form: FormData\n try {\n form = await request.formData()\n } catch {\n throw new OcrRouteError(\"Expected multipart/form-data\")\n }\n const entry = form.get(\"file\")\n if (!(entry instanceof Blob)) throw new OcrRouteError('Missing \"file\"')\n file = {\n name: (entry as File).name?.slice(0, 255) || \"file\",\n type: entry.type || \"application/octet-stream\",\n size: entry.size,\n }\n check(file)\n data = new Uint8Array(await entry.arrayBuffer())\n return json(await run(request, auth as TAuth, file, data))\n }\n\n if (contentType.startsWith(\"application/json\")) {\n if (!options.storage?.getObject) {\n throw new OcrRouteError(\"Reading stored files isn't enabled\", 400)\n }\n let body: unknown\n try {\n body = await request.json()\n } catch {\n throw new OcrRouteError(\"Expected a JSON body\")\n }\n key = (body as { key?: unknown } | null)?.key as string | undefined\n if (typeof key !== \"string\" || !key || key.length > 1024) {\n throw new OcrRouteError('Invalid \"key\"')\n }\n const auth = await options.authorize?.({ request, key })\n const info = await options.storage.headObject(key)\n if (info === null) throw new OcrRouteError(\"File not found\", 404)\n const extension = key.split(\".\").pop()?.toLowerCase() ?? \"\"\n file = {\n name: key.split(\"/\").pop() ?? key,\n type:\n info?.contentType ||\n EXTENSION_TYPES[extension] ||\n \"application/octet-stream\",\n size: info?.size ?? 0,\n }\n check(file)\n const stream = await options.storage.getObject(key)\n if (!stream) throw new OcrRouteError(\"File not found\", 404)\n try {\n data = await readStream(stream, maxFileSize)\n } catch {\n throw new OcrRouteError(\"File is too large\", 413)\n }\n return json(\n await run(\n request,\n auth as TAuth,\n { ...file, size: data.byteLength },\n data,\n key\n )\n )\n }\n\n throw new OcrRouteError(\"Send multipart/form-data or JSON\", 415)\n } catch (error) {\n if (error instanceof OcrRouteError)\n return json({ error: error.message }, error.status)\n console.error(\"[uploadcn] OCR failed\", error)\n // Provider messages can include account details; keep them in logs.\n return json({ error: \"Couldn't read text from this file\" }, 502)\n }\n }\n\n async function run(\n request: Request,\n auth: TAuth,\n file: FileDescriptor,\n data: Uint8Array,\n key?: string\n ) {\n const result = await options.provider.recognize({\n data,\n type: file.type,\n name: file.name,\n signal: request.signal,\n })\n return (\n (await options.onResult?.({ request, auth, file, key, result })) ?? result\n )\n }\n\n return { POST: handle, handler: handle }\n}\n"],"mappings":"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"}
@@ -0,0 +1,114 @@
1
+ import { FileDescriptor } from "@uploadcn/core";
2
+ //#region src/scan.d.ts
3
+ /** The verdict of a virus scan. */
4
+ interface ScanResult {
5
+ /**
6
+ * `clean`: no threats found. `infected`: at least one threat. `unknown`:
7
+ * the scanner couldn't decide (file too large, unknown hash, outage…).
8
+ */
9
+ status: "clean" | "infected" | "unknown";
10
+ /** Threat names, e.g. `["Win.Test.EICAR_HDB-1"]`. */
11
+ threats: string[];
12
+ /** Which scanner produced the verdict. */
13
+ scanner: string;
14
+ /** Scanner-specific data, for logs. Never sent to the browser. */
15
+ details?: unknown;
16
+ }
17
+ interface ScanInput {
18
+ /** The file's bytes. Read it once. */
19
+ stream: ReadableStream<Uint8Array>;
20
+ file: FileDescriptor;
21
+ /** The storage key, when scanning a stored upload. */
22
+ key?: string;
23
+ signal?: AbortSignal;
24
+ }
25
+ /** Anything that can scan a file: ClamAV, VirusTotal, your own service. */
26
+ interface Scanner {
27
+ readonly name: string;
28
+ scan(input: ScanInput): Promise<ScanResult>;
29
+ }
30
+ declare class ScanError extends Error {
31
+ readonly name = "ScanError";
32
+ }
33
+ /** Builds a scanner from a function, for services without a built-in one. */
34
+ declare function createScanner(name: string, scan: (input: ScanInput) => Promise<Omit<ScanResult, "scanner">>): Scanner;
35
+ /** Reads a stream into memory, failing past `limit` bytes. */
36
+ declare function readStream(stream: ReadableStream<Uint8Array>, limit?: number): Promise<Uint8Array<ArrayBuffer>>;
37
+ interface ClamAvOptions {
38
+ /** clamd host. Default `"127.0.0.1"`. */
39
+ host?: string;
40
+ /** clamd TCP port. Default `3310`. */
41
+ port?: number;
42
+ /** Unix socket path, instead of host and port. */
43
+ socketPath?: string;
44
+ /** Milliseconds before giving up. Default 60 000. */
45
+ timeout?: number;
46
+ /** Bytes per INSTREAM chunk. Default 64 KiB. */
47
+ chunkSize?: number;
48
+ }
49
+ /**
50
+ * Scans files with ClamAV, streaming them to `clamd` over the INSTREAM
51
+ * protocol. Files are never written to disk. Runs on Node.js (it opens a
52
+ * TCP or Unix socket), not on edge runtimes.
53
+ *
54
+ * clamd rejects streams larger than its `StreamMaxLength` (25 MB by
55
+ * default); raise it in `clamd.conf` for bigger files.
56
+ */
57
+ declare function clamavScanner(options?: ClamAvOptions): {
58
+ name: string;
59
+ scan({ stream, signal }: ScanInput): Promise<ScanResult>;
60
+ /** Checks that clamd is reachable. Resolves `true` on PONG. */
61
+ ping(): Promise<boolean>;
62
+ };
63
+ /** Parses a clamd reply such as `stream: Eicar-Signature FOUND`. */
64
+ declare function parseClamReply(reply: string): ScanResult;
65
+ interface VirusTotalOptions {
66
+ /** Your VirusTotal API key. Read it from an environment variable. */
67
+ apiKey: string;
68
+ /**
69
+ * Upload files VirusTotal hasn't seen. Default `false`: only the SHA-256
70
+ * is sent and unknown files are `unknown`.
71
+ *
72
+ * Uploaded files are shared with VirusTotal's security partners. Never
73
+ * enable this for private documents.
74
+ */
75
+ upload?: boolean;
76
+ /** Engines that must flag a file to call it infected. Default 2. */
77
+ threshold?: number;
78
+ /** Milliseconds between analysis polls. Default 5 000. */
79
+ pollInterval?: number;
80
+ /** Milliseconds to wait for an analysis. Default 120 000. */
81
+ timeout?: number;
82
+ /** Largest file to read, in bytes. Default 650 MB (VirusTotal's limit). */
83
+ maxFileSize?: number;
84
+ fetch?: typeof fetch;
85
+ }
86
+ /**
87
+ * Checks files against VirusTotal's 70+ engines. By default it looks up the
88
+ * file's hash only, so file contents never leave your server.
89
+ */
90
+ declare function virusTotalScanner(options: VirusTotalOptions): Scanner;
91
+ interface HttpScannerOptions {
92
+ /** Endpoint that receives the file bytes as the request body. */
93
+ url: string;
94
+ /** Extra headers, e.g. an API key from an environment variable. */
95
+ headers?: Record<string, string>;
96
+ /**
97
+ * Maps the service's response to a verdict. By default the response must
98
+ * be JSON like `{ "status": "clean" | "infected", "threats": [] }`, or
99
+ * `{ "clean": true }`.
100
+ */
101
+ parse?: (body: unknown, response: Response) => Omit<ScanResult, "scanner">;
102
+ /** Name used in results and logs. Default `"http"`. */
103
+ name?: string;
104
+ fetch?: typeof fetch;
105
+ }
106
+ /**
107
+ * Sends files to your own scanning service, or a hosted one such as
108
+ * Cloudmersive, MetaDefender or an internal sandbox. The body is the raw
109
+ * file; `x-file-name` and `content-type` describe it.
110
+ */
111
+ declare function httpScanner(options: HttpScannerOptions): Scanner;
112
+ //#endregion
113
+ export { ScanResult as a, clamavScanner as c, parseClamReply as d, readStream as f, ScanInput as i, createScanner as l, HttpScannerOptions as n, Scanner as o, virusTotalScanner as p, ScanError as r, VirusTotalOptions as s, ClamAvOptions as t, httpScanner as u };
114
+ //# sourceMappingURL=scan-B8LojfB0.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scan-B8LojfB0.d.ts","names":[],"sources":["../src/scan.ts"],"mappings":";;;UAKiB;;;;;EAKf;;EAEA;;EAEA;;EAEA;;UAGe;;EAEf,QAAQ,eAAe;EACvB,MAAM;;EAEN;EACA,SAAS;;;UAIM;WACN;EACT,KAAK,OAAO,YAAY,QAAQ;;cAGrB,kBAAkB;WACX;;;iBAIJ,cACd,cACA,OAAO,OAAO,cAAc,QAAQ,KAAK,0BACxC;;iBAUmB,WACpB,QAAQ,eAAe,aACvB,iBAAgC,QAAA,WAAA;UA2BjB;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;iBAWc,cAAc,UAAS;;EAiBJ,OAAA,QAAA,UAAA,YAAY,QAAQ;;;;;iBAwFvC,eAAe,gBAAgB;UAc9B;;EAEf;;;;;;;;EAQA;;EAEA;;EAEA;;EAEA;;EAEA;EACA,eAAe;;;;;;iBAyBD,kBAAkB,SAAS,oBAAoB;UAoI9C;;EAEf;;EAEA,UAAU;;;;;;EAMV,SAAS,eAAe,UAAU,aAAa,KAAK;;EAEpD;EACA,eAAe;;;;;;;iBA2BD,YAAY,SAAS,qBAAqB"}
package/dist/scan.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { a as ScanResult, c as clamavScanner, d as parseClamReply, f as readStream, i as ScanInput, l as createScanner, n as HttpScannerOptions, o as Scanner, p as virusTotalScanner, r as ScanError, s as VirusTotalOptions, t as ClamAvOptions, u as httpScanner } from "./scan-B8LojfB0.js";
2
+ export { ClamAvOptions, HttpScannerOptions, ScanError, ScanInput, ScanResult, Scanner, VirusTotalOptions, clamavScanner, createScanner, httpScanner, parseClamReply, readStream, virusTotalScanner };
package/dist/scan.js ADDED
@@ -0,0 +1,290 @@
1
+ //#region src/scan.ts
2
+ var ScanError = class extends Error {
3
+ name = "ScanError";
4
+ };
5
+ /** Builds a scanner from a function, for services without a built-in one. */
6
+ function createScanner(name, scan) {
7
+ return {
8
+ name,
9
+ async scan(input) {
10
+ return {
11
+ ...await scan(input),
12
+ scanner: name
13
+ };
14
+ }
15
+ };
16
+ }
17
+ /** Reads a stream into memory, failing past `limit` bytes. */
18
+ async function readStream(stream, limit = Number.POSITIVE_INFINITY) {
19
+ const chunks = [];
20
+ let size = 0;
21
+ const reader = stream.getReader();
22
+ for (;;) {
23
+ const { done, value } = await reader.read();
24
+ if (done) break;
25
+ size += value.byteLength;
26
+ if (size > limit) {
27
+ await reader.cancel();
28
+ throw new ScanError(`File is larger than ${limit} bytes`);
29
+ }
30
+ chunks.push(value);
31
+ }
32
+ const bytes = new Uint8Array(size);
33
+ let offset = 0;
34
+ for (const chunk of chunks) {
35
+ bytes.set(chunk, offset);
36
+ offset += chunk.byteLength;
37
+ }
38
+ return bytes;
39
+ }
40
+ /**
41
+ * Scans files with ClamAV, streaming them to `clamd` over the INSTREAM
42
+ * protocol. Files are never written to disk. Runs on Node.js (it opens a
43
+ * TCP or Unix socket), not on edge runtimes.
44
+ *
45
+ * clamd rejects streams larger than its `StreamMaxLength` (25 MB by
46
+ * default); raise it in `clamd.conf` for bigger files.
47
+ */
48
+ function clamavScanner(options = {}) {
49
+ const timeout = options.timeout ?? 6e4;
50
+ const chunkSize = options.chunkSize ?? 65536;
51
+ async function connect(signal) {
52
+ const net = await import("node:net");
53
+ const socket = options.socketPath ? net.createConnection(options.socketPath) : net.createConnection({
54
+ host: options.host ?? "127.0.0.1",
55
+ port: options.port ?? 3310
56
+ });
57
+ return {
58
+ socket,
59
+ reply: collectReply(socket, timeout, signal)
60
+ };
61
+ }
62
+ return {
63
+ name: "clamav",
64
+ async scan({ stream, signal }) {
65
+ const { socket, reply } = await connect(signal);
66
+ const write = async () => {
67
+ await new Promise((resolve, reject) => {
68
+ socket.once("connect", resolve);
69
+ socket.once("error", reject);
70
+ });
71
+ socket.write("zINSTREAM\0");
72
+ const reader = stream.getReader();
73
+ try {
74
+ for (;;) {
75
+ const { done, value } = await reader.read();
76
+ if (done) break;
77
+ for (let at = 0; at < value.byteLength; at += chunkSize) {
78
+ if (socket.destroyed) return;
79
+ const slice = value.subarray(at, at + chunkSize);
80
+ const length = /* @__PURE__ */ new Uint8Array(4);
81
+ new DataView(length.buffer).setUint32(0, slice.byteLength);
82
+ socket.write(length);
83
+ if (!socket.write(slice)) await new Promise((resolve) => socket.once("drain", resolve));
84
+ }
85
+ }
86
+ socket.write(/* @__PURE__ */ new Uint8Array(4));
87
+ } finally {
88
+ reader.releaseLock();
89
+ }
90
+ };
91
+ write().catch(() => {});
92
+ return parseClamReply(await reply);
93
+ },
94
+ /** Checks that clamd is reachable. Resolves `true` on PONG. */
95
+ async ping() {
96
+ const { socket, reply } = await connect();
97
+ socket.write("zPING\0");
98
+ return (await reply).trim() === "PONG";
99
+ }
100
+ };
101
+ }
102
+ function collectReply(socket, timeout, signal) {
103
+ return new Promise((resolve, reject) => {
104
+ const chunks = [];
105
+ let settled = false;
106
+ const decode = () => new TextDecoder().decode(chunks.length === 1 ? chunks[0] : Uint8Array.from(chunks.flatMap((chunk) => [...chunk]))).replace(/\0/g, "").trim();
107
+ const finish = (error) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ clearTimeout(timer);
111
+ signal?.removeEventListener("abort", onAbort);
112
+ socket.destroy();
113
+ if (chunks.length) resolve(decode());
114
+ else reject(error ?? new ScanError("ClamAV closed the connection"));
115
+ };
116
+ const timer = setTimeout(() => finish(new ScanError("ClamAV timed out")), timeout);
117
+ const onAbort = () => finish(signal?.reason);
118
+ signal?.addEventListener("abort", onAbort, { once: true });
119
+ socket.on("data", (chunk) => {
120
+ chunks.push(chunk);
121
+ if (chunk.includes(0)) finish();
122
+ });
123
+ socket.on("end", () => finish());
124
+ socket.on("close", () => finish());
125
+ socket.on("error", (error) => finish(new ScanError(`ClamAV: ${error.message}`)));
126
+ });
127
+ }
128
+ /** Parses a clamd reply such as `stream: Eicar-Signature FOUND`. */
129
+ function parseClamReply(reply) {
130
+ if (/^stream: OK$/.test(reply)) return {
131
+ status: "clean",
132
+ threats: [],
133
+ scanner: "clamav"
134
+ };
135
+ const found = reply.match(/^stream: (.+) FOUND$/);
136
+ if (found) return {
137
+ status: "infected",
138
+ threats: [found[1]],
139
+ scanner: "clamav"
140
+ };
141
+ throw new ScanError(`ClamAV: ${reply || "empty reply"}`);
142
+ }
143
+ const VT = "https://www.virustotal.com/api/v3";
144
+ async function sha256(bytes) {
145
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
146
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
147
+ }
148
+ /**
149
+ * Checks files against VirusTotal's 70+ engines. By default it looks up the
150
+ * file's hash only, so file contents never leave your server.
151
+ */
152
+ function virusTotalScanner(options) {
153
+ const fetcher = options.fetch ?? fetch;
154
+ const threshold = options.threshold ?? 2;
155
+ const headers = {
156
+ "x-apikey": options.apiKey,
157
+ accept: "application/json"
158
+ };
159
+ const verdict = (stats, results = {}, details) => {
160
+ const flagged = (stats.malicious ?? 0) + (stats.suspicious ?? 0);
161
+ const analysed = flagged + (stats.undetected ?? 0) + (stats.harmless ?? 0);
162
+ const threats = Object.entries(results).filter(([, value]) => value.category === "malicious").map(([engine, value]) => `${engine}: ${value.result ?? "malicious"}`);
163
+ return {
164
+ status: analysed === 0 ? "unknown" : flagged >= threshold ? "infected" : "clean",
165
+ threats,
166
+ scanner: "virustotal",
167
+ details
168
+ };
169
+ };
170
+ const request = async (url, init = {}) => {
171
+ const response = await fetcher(url, {
172
+ ...init,
173
+ headers: {
174
+ ...headers,
175
+ ...init.headers
176
+ }
177
+ });
178
+ if (response.status === 404) return null;
179
+ if (!response.ok) throw new ScanError(`VirusTotal answered ${response.status}`);
180
+ return await response.json();
181
+ };
182
+ return {
183
+ name: "virustotal",
184
+ async scan({ stream, file, signal }) {
185
+ const bytes = await readStream(stream, options.maxFileSize ?? 650 * 1024 ** 2);
186
+ const hash = await sha256(bytes);
187
+ const known = await request(`${VT}/files/${hash}`, { signal });
188
+ if (known?.data?.attributes) {
189
+ const attributes = known.data.attributes;
190
+ return verdict(attributes.last_analysis_stats, attributes.last_analysis_results, { sha256: hash });
191
+ }
192
+ if (!options.upload) return {
193
+ status: "unknown",
194
+ threats: [],
195
+ scanner: "virustotal",
196
+ details: { sha256: hash }
197
+ };
198
+ let uploadUrl = `${VT}/files`;
199
+ if (bytes.byteLength > 32 * 1024 ** 2) {
200
+ const response = await fetcher(`${VT}/files/upload_url`, {
201
+ headers,
202
+ signal
203
+ });
204
+ if (!response.ok) throw new ScanError(`VirusTotal answered ${response.status}`);
205
+ uploadUrl = (await response.json()).data;
206
+ if (!uploadUrl.startsWith("https://www.virustotal.com/")) throw new ScanError("Unexpected VirusTotal upload URL");
207
+ }
208
+ const form = new FormData();
209
+ form.append("file", new Blob([bytes], { type: file.type }), file.name);
210
+ const analysis = (await request(uploadUrl, {
211
+ method: "POST",
212
+ body: form,
213
+ signal
214
+ }))?.data?.id;
215
+ if (!analysis) throw new ScanError("VirusTotal didn't return an analysis id");
216
+ const deadline = Date.now() + (options.timeout ?? 12e4);
217
+ while (Date.now() < deadline) {
218
+ await new Promise((resolve) => setTimeout(resolve, options.pollInterval ?? 5e3));
219
+ signal?.throwIfAborted();
220
+ const attributes = (await request(`${VT}/analyses/${encodeURIComponent(analysis)}`, { signal }))?.data?.attributes;
221
+ if (attributes?.status === "completed") return verdict(attributes.stats, attributes.results, {
222
+ sha256: hash,
223
+ analysis
224
+ });
225
+ }
226
+ return {
227
+ status: "unknown",
228
+ threats: [],
229
+ scanner: "virustotal",
230
+ details: {
231
+ sha256: hash,
232
+ analysis,
233
+ timedOut: true
234
+ }
235
+ };
236
+ }
237
+ };
238
+ }
239
+ function defaultParse(body) {
240
+ if (typeof body !== "object" || body === null) throw new ScanError("Scanner returned an unexpected response");
241
+ const value = body;
242
+ const threats = Array.isArray(value.threats) ? value.threats.map(String) : [];
243
+ if (value.status === "clean" || value.status === "infected" || value.status === "unknown") return {
244
+ status: value.status,
245
+ threats
246
+ };
247
+ if (typeof value.clean === "boolean") return {
248
+ status: value.clean ? "clean" : "infected",
249
+ threats
250
+ };
251
+ throw new ScanError("Scanner returned an unexpected response");
252
+ }
253
+ /**
254
+ * Sends files to your own scanning service, or a hosted one such as
255
+ * Cloudmersive, MetaDefender or an internal sandbox. The body is the raw
256
+ * file; `x-file-name` and `content-type` describe it.
257
+ */
258
+ function httpScanner(options) {
259
+ const name = options.name ?? "http";
260
+ return {
261
+ name,
262
+ async scan({ stream, file, signal }) {
263
+ const bytes = await readStream(stream);
264
+ const response = await (options.fetch ?? fetch)(options.url, {
265
+ method: "POST",
266
+ headers: {
267
+ "content-type": file.type || "application/octet-stream",
268
+ "x-file-name": encodeURIComponent(file.name),
269
+ ...options.headers
270
+ },
271
+ body: bytes,
272
+ signal
273
+ });
274
+ if (!response.ok) throw new ScanError(`${name} answered ${response.status}`);
275
+ const text = await response.text();
276
+ let body = text;
277
+ try {
278
+ body = JSON.parse(text);
279
+ } catch {}
280
+ return {
281
+ ...(options.parse ?? defaultParse)(body, response),
282
+ scanner: name
283
+ };
284
+ }
285
+ };
286
+ }
287
+ //#endregion
288
+ export { ScanError, clamavScanner, createScanner, httpScanner, parseClamReply, readStream, virusTotalScanner };
289
+
290
+ //# sourceMappingURL=scan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scan.js","names":[],"sources":["../src/scan.ts"],"sourcesContent":["import type { Socket } from \"node:net\"\n\nimport type { FileDescriptor } from \"@uploadcn/core\"\n\n/** The verdict of a virus scan. */\nexport interface ScanResult {\n /**\n * `clean`: no threats found. `infected`: at least one threat. `unknown`:\n * the scanner couldn't decide (file too large, unknown hash, outage…).\n */\n status: \"clean\" | \"infected\" | \"unknown\"\n /** Threat names, e.g. `[\"Win.Test.EICAR_HDB-1\"]`. */\n threats: string[]\n /** Which scanner produced the verdict. */\n scanner: string\n /** Scanner-specific data, for logs. Never sent to the browser. */\n details?: unknown\n}\n\nexport interface ScanInput {\n /** The file's bytes. Read it once. */\n stream: ReadableStream<Uint8Array>\n file: FileDescriptor\n /** The storage key, when scanning a stored upload. */\n key?: string\n signal?: AbortSignal\n}\n\n/** Anything that can scan a file: ClamAV, VirusTotal, your own service. */\nexport interface Scanner {\n readonly name: string\n scan(input: ScanInput): Promise<ScanResult>\n}\n\nexport class ScanError extends Error {\n override readonly name = \"ScanError\"\n}\n\n/** Builds a scanner from a function, for services without a built-in one. */\nexport function createScanner(\n name: string,\n scan: (input: ScanInput) => Promise<Omit<ScanResult, \"scanner\">>\n): Scanner {\n return {\n name,\n async scan(input) {\n return { ...(await scan(input)), scanner: name }\n },\n }\n}\n\n/** Reads a stream into memory, failing past `limit` bytes. */\nexport async function readStream(\n stream: ReadableStream<Uint8Array>,\n limit = Number.POSITIVE_INFINITY\n) {\n const chunks: Uint8Array[] = []\n let size = 0\n const reader = stream.getReader()\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n size += value.byteLength\n if (size > limit) {\n await reader.cancel()\n throw new ScanError(`File is larger than ${limit} bytes`)\n }\n chunks.push(value)\n }\n const bytes = new Uint8Array(size)\n let offset = 0\n for (const chunk of chunks) {\n bytes.set(chunk, offset)\n offset += chunk.byteLength\n }\n return bytes\n}\n\n// ---------------------------------------------------------------------------\n// ClamAV\n\nexport interface ClamAvOptions {\n /** clamd host. Default `\"127.0.0.1\"`. */\n host?: string\n /** clamd TCP port. Default `3310`. */\n port?: number\n /** Unix socket path, instead of host and port. */\n socketPath?: string\n /** Milliseconds before giving up. Default 60 000. */\n timeout?: number\n /** Bytes per INSTREAM chunk. Default 64 KiB. */\n chunkSize?: number\n}\n\n/**\n * Scans files with ClamAV, streaming them to `clamd` over the INSTREAM\n * protocol. Files are never written to disk. Runs on Node.js (it opens a\n * TCP or Unix socket), not on edge runtimes.\n *\n * clamd rejects streams larger than its `StreamMaxLength` (25 MB by\n * default); raise it in `clamd.conf` for bigger files.\n */\nexport function clamavScanner(options: ClamAvOptions = {}) {\n const timeout = options.timeout ?? 60_000\n const chunkSize = options.chunkSize ?? 64 * 1024\n\n async function connect(signal?: AbortSignal) {\n const net = await import(\"node:net\")\n const socket = options.socketPath\n ? net.createConnection(options.socketPath)\n : net.createConnection({\n host: options.host ?? \"127.0.0.1\",\n port: options.port ?? 3310,\n })\n return { socket, reply: collectReply(socket, timeout, signal) }\n }\n\n const scanner = {\n name: \"clamav\",\n async scan({ stream, signal }: ScanInput): Promise<ScanResult> {\n const { socket, reply } = await connect(signal)\n const write = async () => {\n await new Promise<void>((resolve, reject) => {\n socket.once(\"connect\", resolve)\n socket.once(\"error\", reject)\n })\n socket.write(\"zINSTREAM\\0\")\n const reader = stream.getReader()\n try {\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n for (let at = 0; at < value.byteLength; at += chunkSize) {\n if (socket.destroyed) return\n const slice = value.subarray(at, at + chunkSize)\n const length = new Uint8Array(4)\n new DataView(length.buffer).setUint32(0, slice.byteLength)\n socket.write(length)\n if (!socket.write(slice)) {\n await new Promise((resolve) => socket.once(\"drain\", resolve))\n }\n }\n }\n // A zero-length chunk ends the stream.\n socket.write(new Uint8Array(4))\n } finally {\n reader.releaseLock()\n }\n }\n write().catch(() => {\n // Write errors surface through `reply` (clamd closes early when the\n // stream is too large, and its reply explains why).\n })\n return parseClamReply(await reply)\n },\n /** Checks that clamd is reachable. Resolves `true` on PONG. */\n async ping() {\n const { socket, reply } = await connect()\n socket.write(\"zPING\\0\")\n return (await reply).trim() === \"PONG\"\n },\n }\n return scanner\n}\n\nfunction collectReply(socket: Socket, timeout: number, signal?: AbortSignal) {\n return new Promise<string>((resolve, reject) => {\n const chunks: Uint8Array[] = []\n let settled = false\n const decode = () =>\n new TextDecoder()\n .decode(\n chunks.length === 1\n ? chunks[0]\n : Uint8Array.from(chunks.flatMap((chunk) => [...chunk]))\n )\n .replace(/\\0/g, \"\")\n .trim()\n const finish = (error?: unknown) => {\n if (settled) return\n settled = true\n clearTimeout(timer)\n signal?.removeEventListener(\"abort\", onAbort)\n socket.destroy()\n // If clamd answered before the socket failed, its answer wins.\n if (chunks.length) resolve(decode())\n else reject(error ?? new ScanError(\"ClamAV closed the connection\"))\n }\n const timer = setTimeout(\n () => finish(new ScanError(\"ClamAV timed out\")),\n timeout\n )\n const onAbort = () => finish(signal?.reason)\n signal?.addEventListener(\"abort\", onAbort, { once: true })\n socket.on(\"data\", (chunk: Uint8Array) => {\n chunks.push(chunk)\n if (chunk.includes(0)) finish()\n })\n socket.on(\"end\", () => finish())\n socket.on(\"close\", () => finish())\n socket.on(\"error\", (error) =>\n finish(new ScanError(`ClamAV: ${error.message}`))\n )\n })\n}\n\n/** Parses a clamd reply such as `stream: Eicar-Signature FOUND`. */\nexport function parseClamReply(reply: string): ScanResult {\n if (/^stream: OK$/.test(reply)) {\n return { status: \"clean\", threats: [], scanner: \"clamav\" }\n }\n const found = reply.match(/^stream: (.+) FOUND$/)\n if (found) {\n return { status: \"infected\", threats: [found[1]!], scanner: \"clamav\" }\n }\n throw new ScanError(`ClamAV: ${reply || \"empty reply\"}`)\n}\n\n// ---------------------------------------------------------------------------\n// VirusTotal\n\nexport interface VirusTotalOptions {\n /** Your VirusTotal API key. Read it from an environment variable. */\n apiKey: string\n /**\n * Upload files VirusTotal hasn't seen. Default `false`: only the SHA-256\n * is sent and unknown files are `unknown`.\n *\n * Uploaded files are shared with VirusTotal's security partners. Never\n * enable this for private documents.\n */\n upload?: boolean\n /** Engines that must flag a file to call it infected. Default 2. */\n threshold?: number\n /** Milliseconds between analysis polls. Default 5 000. */\n pollInterval?: number\n /** Milliseconds to wait for an analysis. Default 120 000. */\n timeout?: number\n /** Largest file to read, in bytes. Default 650 MB (VirusTotal's limit). */\n maxFileSize?: number\n fetch?: typeof fetch\n}\n\nconst VT = \"https://www.virustotal.com/api/v3\"\n\ninterface VtStats {\n malicious?: number\n suspicious?: number\n undetected?: number\n harmless?: number\n}\n\ntype VtResults = Record<string, { category?: string; result?: string | null }>\n\nasync function sha256(bytes: Uint8Array) {\n const digest = await crypto.subtle.digest(\"SHA-256\", bytes as BufferSource)\n return [...new Uint8Array(digest)]\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\")\n}\n\n/**\n * Checks files against VirusTotal's 70+ engines. By default it looks up the\n * file's hash only, so file contents never leave your server.\n */\nexport function virusTotalScanner(options: VirusTotalOptions): Scanner {\n const fetcher = options.fetch ?? fetch\n const threshold = options.threshold ?? 2\n const headers = { \"x-apikey\": options.apiKey, accept: \"application/json\" }\n\n const verdict = (\n stats: VtStats,\n results: VtResults = {},\n details?: unknown\n ): ScanResult => {\n const flagged = (stats.malicious ?? 0) + (stats.suspicious ?? 0)\n const analysed = flagged + (stats.undetected ?? 0) + (stats.harmless ?? 0)\n const threats = Object.entries(results)\n .filter(([, value]) => value.category === \"malicious\")\n .map(([engine, value]) => `${engine}: ${value.result ?? \"malicious\"}`)\n return {\n status:\n analysed === 0\n ? \"unknown\"\n : flagged >= threshold\n ? \"infected\"\n : \"clean\",\n threats,\n scanner: \"virustotal\",\n details,\n }\n }\n\n const request = async (url: string, init: RequestInit = {}) => {\n const response = await fetcher(url, {\n ...init,\n headers: { ...headers, ...init.headers },\n })\n if (response.status === 404) return null\n if (!response.ok) {\n throw new ScanError(`VirusTotal answered ${response.status}`)\n }\n return (await response.json()) as {\n data?: { id?: string; attributes?: Record<string, unknown> }\n }\n }\n\n return {\n name: \"virustotal\",\n async scan({ stream, file, signal }) {\n const bytes = await readStream(\n stream,\n options.maxFileSize ?? 650 * 1024 ** 2\n )\n const hash = await sha256(bytes)\n const known = await request(`${VT}/files/${hash}`, { signal })\n if (known?.data?.attributes) {\n const attributes = known.data.attributes\n return verdict(\n attributes.last_analysis_stats as VtStats,\n attributes.last_analysis_results as VtResults,\n { sha256: hash }\n )\n }\n if (!options.upload) {\n return {\n status: \"unknown\",\n threats: [],\n scanner: \"virustotal\",\n details: { sha256: hash },\n }\n }\n\n // Files over 32 MB need a one-time upload URL.\n let uploadUrl = `${VT}/files`\n if (bytes.byteLength > 32 * 1024 ** 2) {\n const response = await fetcher(`${VT}/files/upload_url`, {\n headers,\n signal,\n })\n if (!response.ok)\n throw new ScanError(`VirusTotal answered ${response.status}`)\n uploadUrl = ((await response.json()) as { data: string }).data\n if (!uploadUrl.startsWith(\"https://www.virustotal.com/\")) {\n throw new ScanError(\"Unexpected VirusTotal upload URL\")\n }\n }\n const form = new FormData()\n form.append(\n \"file\",\n new Blob([bytes as BlobPart], { type: file.type }),\n file.name\n )\n const uploaded = await request(uploadUrl, {\n method: \"POST\",\n body: form,\n signal,\n })\n const analysis = uploaded?.data?.id\n if (!analysis)\n throw new ScanError(\"VirusTotal didn't return an analysis id\")\n\n const deadline = Date.now() + (options.timeout ?? 120_000)\n while (Date.now() < deadline) {\n await new Promise((resolve) =>\n setTimeout(resolve, options.pollInterval ?? 5_000)\n )\n signal?.throwIfAborted()\n const result = await request(\n `${VT}/analyses/${encodeURIComponent(analysis)}`,\n { signal }\n )\n const attributes = result?.data?.attributes\n if (attributes?.status === \"completed\") {\n return verdict(\n attributes.stats as VtStats,\n attributes.results as VtResults,\n {\n sha256: hash,\n analysis,\n }\n )\n }\n }\n return {\n status: \"unknown\",\n threats: [],\n scanner: \"virustotal\",\n details: { sha256: hash, analysis, timedOut: true },\n }\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Any HTTP scanning service\n\nexport interface HttpScannerOptions {\n /** Endpoint that receives the file bytes as the request body. */\n url: string\n /** Extra headers, e.g. an API key from an environment variable. */\n headers?: Record<string, string>\n /**\n * Maps the service's response to a verdict. By default the response must\n * be JSON like `{ \"status\": \"clean\" | \"infected\", \"threats\": [] }`, or\n * `{ \"clean\": true }`.\n */\n parse?: (body: unknown, response: Response) => Omit<ScanResult, \"scanner\">\n /** Name used in results and logs. Default `\"http\"`. */\n name?: string\n fetch?: typeof fetch\n}\n\nfunction defaultParse(body: unknown): Omit<ScanResult, \"scanner\"> {\n if (typeof body !== \"object\" || body === null) {\n throw new ScanError(\"Scanner returned an unexpected response\")\n }\n const value = body as { status?: unknown; clean?: unknown; threats?: unknown }\n const threats = Array.isArray(value.threats) ? value.threats.map(String) : []\n if (\n value.status === \"clean\" ||\n value.status === \"infected\" ||\n value.status === \"unknown\"\n ) {\n return { status: value.status, threats }\n }\n if (typeof value.clean === \"boolean\") {\n return { status: value.clean ? \"clean\" : \"infected\", threats }\n }\n throw new ScanError(\"Scanner returned an unexpected response\")\n}\n\n/**\n * Sends files to your own scanning service, or a hosted one such as\n * Cloudmersive, MetaDefender or an internal sandbox. The body is the raw\n * file; `x-file-name` and `content-type` describe it.\n */\nexport function httpScanner(options: HttpScannerOptions): Scanner {\n const name = options.name ?? \"http\"\n return {\n name,\n async scan({ stream, file, signal }) {\n const bytes = await readStream(stream)\n const response = await (options.fetch ?? fetch)(options.url, {\n method: \"POST\",\n headers: {\n \"content-type\": file.type || \"application/octet-stream\",\n \"x-file-name\": encodeURIComponent(file.name),\n ...options.headers,\n },\n body: bytes as BodyInit,\n signal,\n })\n if (!response.ok)\n throw new ScanError(`${name} answered ${response.status}`)\n const text = await response.text()\n let body: unknown = text\n try {\n body = JSON.parse(text)\n } catch {\n // Some services answer in plain text; `parse` handles those.\n }\n return {\n ...(options.parse ?? defaultParse)(body, response),\n scanner: name,\n }\n },\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ //#region src/signing.ts
2
+ const encoder = new TextEncoder();
3
+ function toBase64Url(bytes) {
4
+ let binary = "";
5
+ for (const byte of bytes) binary += String.fromCharCode(byte);
6
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
7
+ }
8
+ function fromBase64Url(value) {
9
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
10
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
11
+ }
12
+ async function hmac(secret, payload) {
13
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
14
+ name: "HMAC",
15
+ hash: "SHA-256"
16
+ }, false, ["sign"]);
17
+ return toBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload))));
18
+ }
19
+ /** Constant-time comparison for signatures. */
20
+ function safeEqual(a, b) {
21
+ if (a.length !== b.length) return false;
22
+ let diff = 0;
23
+ for (let index = 0; index < a.length; index++) diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
24
+ return diff === 0;
25
+ }
26
+ /** Signs and verifies short-lived URL tokens with an HMAC secret. */
27
+ function createUrlSigner(secret, baseUrl) {
28
+ if (!secret) throw new Error("A signing secret is required");
29
+ const base = baseUrl.replace(/\/+$/, "");
30
+ return {
31
+ base,
32
+ async sign(token) {
33
+ const payload = toBase64Url(encoder.encode(JSON.stringify(token)));
34
+ return `${base}?token=${payload}.${await hmac(secret, payload)}`;
35
+ },
36
+ async verify(raw) {
37
+ if (!raw) return null;
38
+ const [payload, signature] = raw.split(".");
39
+ if (!payload || !signature) return null;
40
+ if (!safeEqual(await hmac(secret, payload), signature)) return null;
41
+ const token = JSON.parse(new TextDecoder().decode(fromBase64Url(payload)));
42
+ return token.expires > Date.now() ? token : null;
43
+ }
44
+ };
45
+ }
46
+ async function sha256Hex(bytes, length = 16) {
47
+ return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)).slice(0, length)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
48
+ }
49
+ //#endregion
50
+ export { sha256Hex as n, createUrlSigner as t };
51
+
52
+ //# sourceMappingURL=signing-DZiuKRaJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signing-DZiuKRaJ.js","names":[],"sources":["../src/signing.ts"],"sourcesContent":["const encoder = new TextEncoder()\n\nexport function toBase64Url(bytes: Uint8Array) {\n let binary = \"\"\n for (const byte of bytes) binary += String.fromCharCode(byte)\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\")\n}\n\nfunction fromBase64Url(value: string) {\n const binary = atob(value.replace(/-/g, \"+\").replace(/_/g, \"/\"))\n return Uint8Array.from(binary, (char) => char.charCodeAt(0))\n}\n\nasync function hmac(secret: string, payload: string) {\n const key = await crypto.subtle.importKey(\n \"raw\",\n encoder.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"]\n )\n return toBase64Url(\n new Uint8Array(\n await crypto.subtle.sign(\"HMAC\", key, encoder.encode(payload))\n )\n )\n}\n\n/** Constant-time comparison for signatures. */\nfunction safeEqual(a: string, b: string) {\n if (a.length !== b.length) return false\n let diff = 0\n for (let index = 0; index < a.length; index++) {\n diff |= a.charCodeAt(index) ^ b.charCodeAt(index)\n }\n return diff === 0\n}\n\nexport interface UrlToken {\n key: string\n method: \"PUT\" | \"GET\"\n /** Expected body size for single PUTs. */\n size?: number\n uploadId?: string\n partNumber?: number\n expires: number\n}\n\n/** Signs and verifies short-lived URL tokens with an HMAC secret. */\nexport function createUrlSigner(secret: string, baseUrl: string) {\n if (!secret) throw new Error(\"A signing secret is required\")\n const base = baseUrl.replace(/\\/+$/, \"\")\n return {\n base,\n async sign(token: UrlToken) {\n const payload = toBase64Url(encoder.encode(JSON.stringify(token)))\n return `${base}?token=${payload}.${await hmac(secret, payload)}`\n },\n async verify(raw: string | null): Promise<UrlToken | null> {\n if (!raw) return null\n const [payload, signature] = raw.split(\".\")\n if (!payload || !signature) return null\n if (!safeEqual(await hmac(secret, payload), signature)) return null\n const token = JSON.parse(\n new TextDecoder().decode(fromBase64Url(payload))\n ) as UrlToken\n return token.expires > Date.now() ? token : null\n },\n }\n}\n\nexport async function sha256Hex(bytes: ArrayBuffer, length = 16) {\n const hash = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", bytes))\n return [...hash.slice(0, length)]\n .map((byte) => byte.toString(16).padStart(2, \"0\"))\n .join(\"\")\n}\n"],"mappings":";AAAA,MAAM,UAAU,IAAI,YAAY;AAEhC,SAAgB,YAAY,OAAmB;CAC7C,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,EAAE;AAC/E;AAEA,SAAS,cAAc,OAAe;CACpC,MAAM,SAAS,KAAK,MAAM,QAAQ,MAAM,GAAG,CAAC,CAAC,QAAQ,MAAM,GAAG,CAAC;CAC/D,OAAO,WAAW,KAAK,SAAS,SAAS,KAAK,WAAW,CAAC,CAAC;AAC7D;AAEA,eAAe,KAAK,QAAgB,SAAiB;CACnD,MAAM,MAAM,MAAM,OAAO,OAAO,UAC9B,OACA,QAAQ,OAAO,MAAM,GACrB;EAAE,MAAM;EAAQ,MAAM;CAAU,GAChC,OACA,CAAC,MAAM,CACT;CACA,OAAO,YACL,IAAI,WACF,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,QAAQ,OAAO,OAAO,CAAC,CAC/D,CACF;AACF;;AAGA,SAAS,UAAU,GAAW,GAAW;CACvC,IAAI,EAAE,WAAW,EAAE,QAAQ,OAAO;CAClC,IAAI,OAAO;CACX,KAAK,IAAI,QAAQ,GAAG,QAAQ,EAAE,QAAQ,SACpC,QAAQ,EAAE,WAAW,KAAK,IAAI,EAAE,WAAW,KAAK;CAElD,OAAO,SAAS;AAClB;;AAaA,SAAgB,gBAAgB,QAAgB,SAAiB;CAC/D,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,8BAA8B;CAC3D,MAAM,OAAO,QAAQ,QAAQ,QAAQ,EAAE;CACvC,OAAO;EACL;EACA,MAAM,KAAK,OAAiB;GAC1B,MAAM,UAAU,YAAY,QAAQ,OAAO,KAAK,UAAU,KAAK,CAAC,CAAC;GACjE,OAAO,GAAG,KAAK,SAAS,QAAQ,GAAG,MAAM,KAAK,QAAQ,OAAO;EAC/D;EACA,MAAM,OAAO,KAA8C;GACzD,IAAI,CAAC,KAAK,OAAO;GACjB,MAAM,CAAC,SAAS,aAAa,IAAI,MAAM,GAAG;GAC1C,IAAI,CAAC,WAAW,CAAC,WAAW,OAAO;GACnC,IAAI,CAAC,UAAU,MAAM,KAAK,QAAQ,OAAO,GAAG,SAAS,GAAG,OAAO;GAC/D,MAAM,QAAQ,KAAK,MACjB,IAAI,YAAY,CAAC,CAAC,OAAO,cAAc,OAAO,CAAC,CACjD;GACA,OAAO,MAAM,UAAU,KAAK,IAAI,IAAI,QAAQ;EAC9C;CACF;AACF;AAEA,eAAsB,UAAU,OAAoB,SAAS,IAAI;CAE/D,OAAO,CAAC,GAAG,IADM,WAAW,MAAM,OAAO,OAAO,OAAO,WAAW,KAAK,CACzD,CAAC,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,CAC9B,KAAK,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CACjD,KAAK,EAAE;AACZ"}