Open History to see the last commit
1import {
2 GUEST_MESSAGE_MAX_ATTACHMENT_BYTES,
3 GUEST_MESSAGE_MAX_ATTACHMENTS,
4 isProviderSendTurnSupportedImageMimeType,
5} from "@t3tools/contracts";
6import { markdownDestinationHostPath } from "@t3tools/shared/markdownLinks";
7import { chatMarkdownImageSources } from "@t3tools/shared/markdownPipeline";
8import { isAbsolutePath, isNetworkPath } from "@t3tools/shared/path";
9
10/** A file from a guest's message request, saved by the multipart parser to a temporary path. */
11export interface GuestUpload {
12 readonly name: string;
13 readonly contentType: string;
14 readonly path: string;
15}
16
17/** What the server learned about an upload before deciding what it may become. */
18export interface InspectedGuestUpload {
19 readonly name: string;
20 readonly contentType: string;
21 readonly sizeBytes: number;
22 /** The first bytes of the file. */
23 readonly header: Uint8Array;
24}
25
26export type GuestUploadRejection = "too_many_files" | "too_large" | "unsupported_file";
27
28/** An attachment before it is stored and gets its id. */
29export interface GuestAttachmentDraft {
30 readonly type: "image" | "file";
31 readonly name: string;
32 readonly mimeType: string;
33 readonly sizeBytes: number;
34}
35
36/** Bytes of the file the signature check reads. */
37export const GUEST_UPLOAD_HEADER_BYTES = 12;
38
39const startsWith = (header: Uint8Array, bytes: ReadonlyArray<number>, offset = 0) =>
40 bytes.every((byte, index) => header[offset + index] === byte);
41const ascii = (text: string) => Array.from(text, (character) => character.charCodeAt(0));
42
43/** An image must be what its type says, so a renamed HTML page never travels as one. */
44const IMAGE_SIGNATURES: Readonly<Record<string, (header: Uint8Array) => boolean>> = {
45 "image/png": (header) => startsWith(header, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
46 "image/jpeg": (header) => startsWith(header, [0xff, 0xd8, 0xff]),
47 "image/gif": (header) => startsWith(header, ascii("GIF8")),
48 "image/webp": (header) =>
49 startsWith(header, ascii("RIFF")) && startsWith(header, ascii("WEBP"), 8),
50};
51
52export const matchesImageSignature = (mimeType: string, header: Uint8Array) =>
53 IMAGE_SIGNATURES[mimeType]?.(header) === true;
54
55const IMAGE_TYPE_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
56 ["png", "image/png"],
57 ["jpg", "image/jpeg"],
58 ["jpeg", "image/jpeg"],
59 ["gif", "image/gif"],
60 ["webp", "image/webp"],
61]);
62
63/**
64 * The type of a host image a guest may ask for by path, or null. A network path never qualifies:
65 * it would make the host reach out to another machine.
66 */
67export function guestHostImageType(path: string): string | null {
68 if (!isAbsolutePath(path) || isNetworkPath(path)) return null;
69 return IMAGE_TYPE_BY_EXTENSION.get(path.split(".").at(-1)!.toLowerCase()) ?? null;
70}
71
72/**
73 * Whether an agent reply shows the image at `path`, so the guest already sees it. `imageSources`
74 * lets the caller reuse a reply's parse across requests.
75 */
76export function repliesShowImage<Reply extends { readonly text: string }>(
77 path: string,
78 replies: ReadonlyArray<Reply>,
79 imageSources: (reply: Reply) => ReadonlyArray<string> = (reply) =>
80 chatMarkdownImageSources(reply.text),
81) {
82 // Parsing is the slow part; a reply with no image syntax shows no image.
83 return replies.some(
84 (reply) =>
85 (reply.text.includes("![") || /<img/i.test(reply.text)) &&
86 imageSources(reply).some((source) => markdownDestinationHostPath(source) === path),
87 );
88}
89
90/**
91 * Control characters and separators would break the name in headers and on disk, and direction
92 * overrides would let `invoice\u202Efdp.exe` show as a PDF.
93 */
94const safeName = (name: string, fallback: string) =>
95 name
96 .replace(/[\p{Cc}\\/\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "_")
97 .trim()
98 .slice(0, 255) || fallback;
99
100/**
101 * The attachments a guest's files become, as the owner's composer takes them: supported images as
102 * images, anything else that is not an image as a file. Other image types (SVG, HEIC) and empty
103 * files are refused, as are too many files or too many bytes in one message.
104 */
105export function classifyGuestUploads(
106 uploads: ReadonlyArray<InspectedGuestUpload>,
107):
108 | { readonly ok: true; readonly attachments: ReadonlyArray<GuestAttachmentDraft> }
109 | { readonly ok: false; readonly reason: GuestUploadRejection } {
110 if (uploads.length > GUEST_MESSAGE_MAX_ATTACHMENTS)
111 return { ok: false, reason: "too_many_files" };
112 const total = uploads.reduce((sum, upload) => sum + upload.sizeBytes, 0);
113 if (total > GUEST_MESSAGE_MAX_ATTACHMENT_BYTES) return { ok: false, reason: "too_large" };
114 const attachments: Array<GuestAttachmentDraft> = [];
115 for (const upload of uploads) {
116 const mimeType =
117 upload.contentType.split(";", 1)[0]!.trim().toLowerCase() || "application/octet-stream";
118 if (upload.sizeBytes === 0) return { ok: false, reason: "unsupported_file" };
119 if (mimeType.startsWith("image/")) {
120 if (
121 !isProviderSendTurnSupportedImageMimeType(mimeType) ||
122 !matchesImageSignature(mimeType, upload.header)
123 ) {
124 return { ok: false, reason: "unsupported_file" };
125 }
126 attachments.push({
127 type: "image",
128 name: safeName(upload.name, "image"),
129 mimeType,
130 sizeBytes: upload.sizeBytes,
131 });
132 continue;
133 }
134 attachments.push({
135 type: "file",
136 name: safeName(upload.name, "file"),
137 mimeType: mimeType.length <= 100 ? mimeType : "application/octet-stream",
138 sizeBytes: upload.sizeBytes,
139 });
140 }
141 return { ok: true, attachments };
142}