@narumitw/pi-webui 0.20.2 → 0.22.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.
- package/README.md +44 -12
- package/package.json +3 -1
- package/src/attachments.ts +715 -0
- package/src/conversation.ts +23 -6
- package/src/drafts.ts +226 -0
- package/src/image-limits.ts +36 -0
- package/src/image-pipeline.ts +325 -0
- package/src/images.ts +182 -140
- package/src/pi-settings.ts +11 -6
- package/src/runtime.ts +138 -34
- package/src/sent-images.ts +279 -0
- package/src/server.ts +490 -30
- package/src/settings.ts +102 -7
- package/src/vendor.d.ts +30 -0
- package/src/web/app.js +558 -100
- package/src/web/image-drag.js +86 -0
- package/src/web/index.html +25 -2
- package/src/web/state.js +178 -8
- package/src/web/styles.css +176 -11
- package/src/web/transcript.js +66 -9
package/src/runtime.ts
CHANGED
|
@@ -8,11 +8,16 @@ import {
|
|
|
8
8
|
getSettingsListTheme,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { PreparedAttachment } from "./attachments.js";
|
|
11
12
|
import { ConversationProjection, projectBranchMessages } from "./conversation.js";
|
|
13
|
+
import { DEFAULT_IMAGE_LIMITS, type ImageLimits, imageLimits } from "./image-limits.js";
|
|
12
14
|
import {
|
|
13
15
|
type BrowserImageInput,
|
|
14
16
|
type ProcessBrowserImageOptions,
|
|
17
|
+
type ProcessedBrowserImage,
|
|
15
18
|
processBrowserImages,
|
|
19
|
+
processStagedImage,
|
|
20
|
+
validateStagedBrowserImages,
|
|
16
21
|
} from "./images.js";
|
|
17
22
|
import { type EffectivePiImageSettings, readEffectivePiImageSettings } from "./pi-settings.js";
|
|
18
23
|
import {
|
|
@@ -51,6 +56,12 @@ interface PendingBrowserInput {
|
|
|
51
56
|
resolve(): void;
|
|
52
57
|
reject(error: Error): void;
|
|
53
58
|
text: string;
|
|
59
|
+
retainedImageIds: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface AcceptedBrowserInput {
|
|
63
|
+
text: string;
|
|
64
|
+
retainedImageIds: string[];
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
export interface RuntimeDependencies {
|
|
@@ -63,6 +74,10 @@ export interface RuntimeDependencies {
|
|
|
63
74
|
inputs: BrowserImageInput[],
|
|
64
75
|
options?: ProcessBrowserImageOptions,
|
|
65
76
|
): Promise<ImageContent[]>;
|
|
77
|
+
processAttachment(
|
|
78
|
+
source: Uint8Array,
|
|
79
|
+
options?: ProcessBrowserImageOptions,
|
|
80
|
+
): Promise<ProcessedBrowserImage>;
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
const DEFAULT_DEPENDENCIES: RuntimeDependencies = {
|
|
@@ -72,6 +87,7 @@ const DEFAULT_DEPENDENCIES: RuntimeDependencies = {
|
|
|
72
87
|
startServer: (options) => WebUIServer.start(options),
|
|
73
88
|
readPiSettings: readEffectivePiImageSettings,
|
|
74
89
|
processImages: processBrowserImages,
|
|
90
|
+
processAttachment: processStagedImage,
|
|
75
91
|
};
|
|
76
92
|
|
|
77
93
|
export class WebUIRuntime {
|
|
@@ -88,8 +104,9 @@ export class WebUIRuntime {
|
|
|
88
104
|
private readonly activeMessageIds = new Map<string, string>();
|
|
89
105
|
private readonly finalMessageTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
90
106
|
private readonly pendingBrowserInputs = new Map<string, PendingBrowserInput>();
|
|
91
|
-
private readonly acceptedBrowserInputs = new Map<string,
|
|
107
|
+
private readonly acceptedBrowserInputs = new Map<string, AcceptedBrowserInput>();
|
|
92
108
|
private settings: WebUISettings = { ...DEFAULT_SETTINGS };
|
|
109
|
+
private effectiveImageLimits: Readonly<ImageLimits> = DEFAULT_IMAGE_LIMITS;
|
|
93
110
|
private settingsDocument?: Record<string, unknown> = {};
|
|
94
111
|
private settingsPath = "pi-webui.json";
|
|
95
112
|
private settingsSource: SettingsLoadResult["source"] = "defaults";
|
|
@@ -164,7 +181,10 @@ export class WebUIRuntime {
|
|
|
164
181
|
if (!wrapped) return;
|
|
165
182
|
const pending = this.pendingBrowserInputs.get(wrapped.nonce);
|
|
166
183
|
if (!pending) return;
|
|
167
|
-
this.acceptedBrowserInputs.set(wrapped.nonce,
|
|
184
|
+
this.acceptedBrowserInputs.set(wrapped.nonce, {
|
|
185
|
+
text: pending.text,
|
|
186
|
+
retainedImageIds: [...pending.retainedImageIds],
|
|
187
|
+
});
|
|
168
188
|
this.settleBrowserInput(wrapped.nonce);
|
|
169
189
|
});
|
|
170
190
|
this.pi.on("message_start", async (event, ctx) => {
|
|
@@ -281,8 +301,11 @@ export class WebUIRuntime {
|
|
|
281
301
|
id = `web-live:${++this.nextLiveMessageId}`;
|
|
282
302
|
this.activeMessageIds.set(key, id);
|
|
283
303
|
}
|
|
304
|
+
const retainedImageIds = Array.isArray(event.retainedImageIds)
|
|
305
|
+
? event.retainedImageIds.filter((value): value is string => typeof value === "string")
|
|
306
|
+
: [];
|
|
284
307
|
if (phase !== "end") {
|
|
285
|
-
this.recordProjectedMessage(event.message, false, id);
|
|
308
|
+
this.recordProjectedMessage(event.message, false, id, retainedImageIds);
|
|
286
309
|
return;
|
|
287
310
|
}
|
|
288
311
|
this.activeMessageIds.delete(key);
|
|
@@ -290,14 +313,19 @@ export class WebUIRuntime {
|
|
|
290
313
|
const timer = setTimeout(() => {
|
|
291
314
|
this.finalMessageTimers.delete(timer);
|
|
292
315
|
if (generation !== this.generation || this.closed) return;
|
|
293
|
-
this.recordProjectedMessage(event.message, true, id);
|
|
316
|
+
this.recordProjectedMessage(event.message, true, id, retainedImageIds);
|
|
294
317
|
}, 0);
|
|
295
318
|
this.finalMessageTimers.add(timer);
|
|
296
319
|
}
|
|
297
320
|
|
|
298
|
-
private recordProjectedMessage(
|
|
321
|
+
private recordProjectedMessage(
|
|
322
|
+
message: unknown,
|
|
323
|
+
final: boolean,
|
|
324
|
+
id: string,
|
|
325
|
+
retainedImageIds: readonly string[] = [],
|
|
326
|
+
): void {
|
|
299
327
|
try {
|
|
300
|
-
this.conversation?.recordMessage(message, final, id);
|
|
328
|
+
this.conversation?.recordMessage(message, final, id, retainedImageIds);
|
|
301
329
|
} catch {
|
|
302
330
|
// Unknown custom message shapes do not block the supported transcript.
|
|
303
331
|
}
|
|
@@ -369,7 +397,7 @@ export class WebUIRuntime {
|
|
|
369
397
|
if (!this.settingsDocument) {
|
|
370
398
|
throw new Error("the invalid settings file must be repaired manually first");
|
|
371
399
|
}
|
|
372
|
-
const next = { startOnSessionStart: requested };
|
|
400
|
+
const next = { ...this.settings, startOnSessionStart: requested };
|
|
373
401
|
const document = await this.dependencies.saveSettings(
|
|
374
402
|
next,
|
|
375
403
|
this.settingsDocument,
|
|
@@ -409,6 +437,7 @@ export class WebUIRuntime {
|
|
|
409
437
|
[
|
|
410
438
|
"Pi WebUI status",
|
|
411
439
|
`startOnSessionStart: ${this.settings.startOnSessionStart} (${source})`,
|
|
440
|
+
`Image limits (${source}): ${this.effectiveImageLimits.maxImages} images, ${formatMib(this.effectiveImageLimits.maxImageBytes)}/image, ${formatMib(this.effectiveImageLimits.maxBatchBytes)}/batch, ${this.effectiveImageLimits.maxImagePixels.toLocaleString("en-US")} pixels/image`,
|
|
412
441
|
`Settings: ${this.settingsPath}`,
|
|
413
442
|
`Server: ${this.server ? "running" : "stopped"}`,
|
|
414
443
|
].join("\n"),
|
|
@@ -425,9 +454,10 @@ export class WebUIRuntime {
|
|
|
425
454
|
"settings: edit WebUI settings in TUI mode",
|
|
426
455
|
"status: show effective settings, source, path, and current server state",
|
|
427
456
|
"init: create the defaults file without overwriting existing content",
|
|
428
|
-
'Accepted JSON
|
|
457
|
+
'Accepted JSON starts with { "startOnSessionStart": false } and may include advanced maxImages, maxImageBytes, maxBatchBytes, and maxImagePixels fields.',
|
|
429
458
|
`Settings path: ${this.settingsPath}`,
|
|
430
|
-
"
|
|
459
|
+
"Image byte/pixel limits stay in Advanced JSON; Pi provider-ready dimension/Base64 limits are fixed.",
|
|
460
|
+
"Changes apply on the next session initialization or reload.",
|
|
431
461
|
].join("\n"),
|
|
432
462
|
"info",
|
|
433
463
|
);
|
|
@@ -451,6 +481,7 @@ export class WebUIRuntime {
|
|
|
451
481
|
|
|
452
482
|
private applySettingsResult(result: SettingsLoadResult): void {
|
|
453
483
|
this.settings = { ...result.settings };
|
|
484
|
+
this.effectiveImageLimits = imageLimits(result.settings);
|
|
454
485
|
this.settingsDocument = result.kind === "invalid" ? undefined : { ...(result.document ?? {}) };
|
|
455
486
|
this.settingsPath = result.path;
|
|
456
487
|
this.settingsSource = result.source;
|
|
@@ -465,6 +496,14 @@ export class WebUIRuntime {
|
|
|
465
496
|
const starting = this.dependencies.startServer({
|
|
466
497
|
conversation,
|
|
467
498
|
send: (request) => this.sendBrowserMessage(request, generation),
|
|
499
|
+
imageLimits: this.effectiveImageLimits,
|
|
500
|
+
sentImageSettings: {
|
|
501
|
+
enabled: this.settings.retainSentImages,
|
|
502
|
+
maxImages: this.settings.maxRetainedImages,
|
|
503
|
+
maxBytes: this.settings.maxRetainedBytes,
|
|
504
|
+
},
|
|
505
|
+
processAttachment: (source, signal) =>
|
|
506
|
+
this.processStagedAttachment(source, generation, signal),
|
|
468
507
|
});
|
|
469
508
|
this.serverStarting = starting.then(async (server) => {
|
|
470
509
|
if (generation !== this.generation || this.closed || conversation !== this.conversation) {
|
|
@@ -483,6 +522,37 @@ export class WebUIRuntime {
|
|
|
483
522
|
}
|
|
484
523
|
}
|
|
485
524
|
|
|
525
|
+
private async processStagedAttachment(
|
|
526
|
+
source: Uint8Array,
|
|
527
|
+
generation: number,
|
|
528
|
+
signal?: AbortSignal,
|
|
529
|
+
): Promise<PreparedAttachment> {
|
|
530
|
+
const ctx = this.context;
|
|
531
|
+
if (!ctx || this.closed || generation !== this.generation) {
|
|
532
|
+
throw new Error("The Pi session has ended.");
|
|
533
|
+
}
|
|
534
|
+
const combinedSignal = signal
|
|
535
|
+
? AbortSignal.any([this.sessionAbort.signal, signal])
|
|
536
|
+
: this.sessionAbort.signal;
|
|
537
|
+
const settings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
|
|
538
|
+
this.notifySettingsWarnings(ctx, settings.warnings);
|
|
539
|
+
const image = await this.dependencies.processAttachment(source, {
|
|
540
|
+
maxImageBytes: this.effectiveImageLimits.maxImageBytes,
|
|
541
|
+
maxPixels: this.effectiveImageLimits.maxImagePixels,
|
|
542
|
+
autoResize: settings.autoResize,
|
|
543
|
+
blockImages: settings.blockImages,
|
|
544
|
+
supportsImages: ctx.model?.input.includes("image") ?? false,
|
|
545
|
+
signal: combinedSignal,
|
|
546
|
+
});
|
|
547
|
+
if (combinedSignal.aborted || generation !== this.generation || this.closed) {
|
|
548
|
+
throw new Error("Image processing was cancelled.");
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
...image,
|
|
552
|
+
notes: attachmentNotes(image),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
486
556
|
private async sendBrowserMessage(
|
|
487
557
|
request: WebSendRequest,
|
|
488
558
|
generation: number,
|
|
@@ -499,15 +569,16 @@ export class WebUIRuntime {
|
|
|
499
569
|
await this.preflightIdlePrompt(ctx, request, generation, signal);
|
|
500
570
|
let images: ImageContent[] = [];
|
|
501
571
|
if (request.images.length > 0) {
|
|
572
|
+
validateStagedBrowserImages(request.images, this.effectiveImageLimits);
|
|
502
573
|
const settings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
|
|
503
574
|
this.notifySettingsWarnings(ctx, settings.warnings);
|
|
504
|
-
|
|
505
|
-
autoResize: settings.autoResize,
|
|
506
|
-
blockImages: settings.blockImages,
|
|
507
|
-
supportsImages: ctx.model?.input.includes("image") ?? false,
|
|
508
|
-
signal,
|
|
509
|
-
});
|
|
575
|
+
if (settings.blockImages) throw new Error("Pi image sending is disabled.");
|
|
510
576
|
await this.validateCurrentModel(ctx, generation, signal, true);
|
|
577
|
+
images = request.images.map((image) => ({
|
|
578
|
+
type: "image" as const,
|
|
579
|
+
data: image.data,
|
|
580
|
+
mimeType: image.mimeType ?? "image/png",
|
|
581
|
+
}));
|
|
511
582
|
}
|
|
512
583
|
if (signal.aborted) throw new Error("The browser message was cancelled.");
|
|
513
584
|
if (!this.context || this.closed || generation !== this.generation) {
|
|
@@ -519,7 +590,7 @@ export class WebUIRuntime {
|
|
|
519
590
|
images.length === 0
|
|
520
591
|
? text
|
|
521
592
|
: [...(text.trim() ? ([{ type: "text", text }] satisfies TextContent[]) : []), ...images];
|
|
522
|
-
const wrapped = this.createBrowserInput(content);
|
|
593
|
+
const wrapped = this.createBrowserInput(content, request.retainedImageIds ?? []);
|
|
523
594
|
const delivery =
|
|
524
595
|
request.delivery === "steer"
|
|
525
596
|
? "steer"
|
|
@@ -574,7 +645,10 @@ export class WebUIRuntime {
|
|
|
574
645
|
if (ctx.model !== model) throw new Error("The Pi model changed; retry the browser message.");
|
|
575
646
|
}
|
|
576
647
|
|
|
577
|
-
private createBrowserInput(
|
|
648
|
+
private createBrowserInput(
|
|
649
|
+
content: string | Array<TextContent | ImageContent>,
|
|
650
|
+
retainedImageIds: readonly string[],
|
|
651
|
+
): {
|
|
578
652
|
nonce: string;
|
|
579
653
|
content: string | Array<TextContent | ImageContent>;
|
|
580
654
|
accepted: Promise<void>;
|
|
@@ -590,7 +664,12 @@ export class WebUIRuntime {
|
|
|
590
664
|
...content.filter((part): part is ImageContent => part.type === "image"),
|
|
591
665
|
];
|
|
592
666
|
const accepted = new Promise<void>((resolve, reject) => {
|
|
593
|
-
this.pendingBrowserInputs.set(nonce, {
|
|
667
|
+
this.pendingBrowserInputs.set(nonce, {
|
|
668
|
+
resolve,
|
|
669
|
+
reject,
|
|
670
|
+
text,
|
|
671
|
+
retainedImageIds: [...retainedImageIds],
|
|
672
|
+
});
|
|
594
673
|
});
|
|
595
674
|
return { nonce, content: wrappedContent, accepted };
|
|
596
675
|
}
|
|
@@ -653,35 +732,56 @@ function contentText(content: Array<TextContent | ImageContent>): string {
|
|
|
653
732
|
|
|
654
733
|
function sanitizeBrowserMessageEvent(
|
|
655
734
|
event: unknown,
|
|
656
|
-
acceptedNonces: Map<string,
|
|
735
|
+
acceptedNonces: Map<string, AcceptedBrowserInput>,
|
|
657
736
|
consume: boolean,
|
|
658
737
|
): unknown {
|
|
659
738
|
if (!isRecord(event) || !isRecord(event.message) || event.message.role !== "user") return event;
|
|
660
739
|
const content = event.message.content;
|
|
661
740
|
if (typeof content === "string") {
|
|
662
741
|
const wrapped = parseBrowserInput(content);
|
|
663
|
-
|
|
664
|
-
|
|
742
|
+
const accepted = wrapped ? acceptedNonces.get(wrapped.nonce) : undefined;
|
|
743
|
+
if (!wrapped || !accepted) return event;
|
|
665
744
|
if (consume) acceptedNonces.delete(wrapped.nonce);
|
|
666
|
-
return {
|
|
745
|
+
return {
|
|
746
|
+
...event,
|
|
747
|
+
retainedImageIds: [...accepted.retainedImageIds],
|
|
748
|
+
message: { ...event.message, content: accepted.text },
|
|
749
|
+
};
|
|
667
750
|
}
|
|
668
751
|
if (!Array.isArray(content)) return event;
|
|
669
|
-
let
|
|
670
|
-
|
|
671
|
-
const
|
|
752
|
+
let accepted: AcceptedBrowserInput | undefined;
|
|
753
|
+
let acceptedNonce: string | undefined;
|
|
754
|
+
const sanitizedText = content.flatMap((part) => {
|
|
672
755
|
if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return [part];
|
|
673
756
|
const wrapped = parseBrowserInput(part.text);
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
return text ? [{ ...part, text }] : [];
|
|
757
|
+
const candidate = wrapped ? acceptedNonces.get(wrapped.nonce) : undefined;
|
|
758
|
+
if (!wrapped || !candidate) return [part];
|
|
759
|
+
accepted = candidate;
|
|
760
|
+
acceptedNonce = wrapped.nonce;
|
|
761
|
+
return candidate.text ? [{ ...part, text: candidate.text }] : [];
|
|
679
762
|
});
|
|
680
|
-
if (!
|
|
681
|
-
if (consume)
|
|
682
|
-
|
|
763
|
+
if (!accepted) return event;
|
|
764
|
+
if (consume && acceptedNonce) acceptedNonces.delete(acceptedNonce);
|
|
765
|
+
return {
|
|
766
|
+
...event,
|
|
767
|
+
retainedImageIds: [...accepted.retainedImageIds],
|
|
768
|
+
message: { ...event.message, content: sanitizedText },
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function attachmentNotes(image: ProcessedBrowserImage): string[] {
|
|
773
|
+
const notes: string[] = [];
|
|
774
|
+
if (image.sourceFormat !== image.outputFormat) {
|
|
775
|
+
notes.push(
|
|
776
|
+
`Converted ${image.sourceFormat.toUpperCase()} to ${image.outputFormat.toUpperCase()}`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
if (image.resized) {
|
|
780
|
+
notes.push(
|
|
781
|
+
`Resized ${image.originalWidth}×${image.originalHeight} to ${image.width}×${image.height}`,
|
|
782
|
+
);
|
|
683
783
|
}
|
|
684
|
-
return
|
|
784
|
+
return notes;
|
|
685
785
|
}
|
|
686
786
|
|
|
687
787
|
function messageLifecycleKey(message: Record<string, unknown>): string {
|
|
@@ -692,6 +792,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
692
792
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
693
793
|
}
|
|
694
794
|
|
|
795
|
+
function formatMib(bytes: number): string {
|
|
796
|
+
return `${bytes / (1024 * 1024)} MiB`;
|
|
797
|
+
}
|
|
798
|
+
|
|
695
799
|
function formatError(error: unknown): string {
|
|
696
800
|
return error instanceof Error ? error.message : String(error);
|
|
697
801
|
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface SentImageSettings {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
maxImages: number;
|
|
6
|
+
maxBytes: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RetainedImageInput {
|
|
10
|
+
name?: string;
|
|
11
|
+
mimeType?: string;
|
|
12
|
+
data: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RetainedImageClone {
|
|
16
|
+
retainedId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
bytes: Buffer;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PublicSentImage {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
size: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PublicSentImageState {
|
|
30
|
+
revision: number;
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
items: PublicSentImage[];
|
|
33
|
+
totalBytes: number;
|
|
34
|
+
maxImages: number;
|
|
35
|
+
maxBytes: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SentImageItem {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
mimeType: string;
|
|
42
|
+
bytes: Buffer;
|
|
43
|
+
associations: Set<string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SentImageError extends Error {
|
|
47
|
+
constructor(
|
|
48
|
+
message: string,
|
|
49
|
+
readonly status = 409,
|
|
50
|
+
) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "SentImageError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class SentImageStore {
|
|
57
|
+
private readonly key = randomBytes(32);
|
|
58
|
+
private readonly items: SentImageItem[] = [];
|
|
59
|
+
private revision = 0;
|
|
60
|
+
private closed = false;
|
|
61
|
+
|
|
62
|
+
constructor(private readonly settings: SentImageSettings) {
|
|
63
|
+
for (const value of [settings.maxImages, settings.maxBytes]) {
|
|
64
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
65
|
+
throw new Error("Sent-image limits must be positive integers.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
publicState(): PublicSentImageState {
|
|
71
|
+
return {
|
|
72
|
+
revision: this.revision,
|
|
73
|
+
enabled: this.settings.enabled,
|
|
74
|
+
items: this.items.map((item) => ({
|
|
75
|
+
id: item.id,
|
|
76
|
+
name: item.name,
|
|
77
|
+
mimeType: item.mimeType,
|
|
78
|
+
size: item.bytes.byteLength,
|
|
79
|
+
})),
|
|
80
|
+
totalBytes: this.residentBytes(),
|
|
81
|
+
maxImages: this.settings.maxImages,
|
|
82
|
+
maxBytes: this.settings.maxBytes,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
residentBytes(): number {
|
|
87
|
+
return this.items.reduce((total, item) => total + item.bytes.byteLength, 0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
referencesFor(messageId: string, images: readonly RetainedImageInput[]): string[] {
|
|
91
|
+
this.assertOpen();
|
|
92
|
+
if (!this.settings.enabled) return [];
|
|
93
|
+
normalizeMessageId(messageId);
|
|
94
|
+
return images.map((image, index) => {
|
|
95
|
+
const normalized = normalizeImage(image, index);
|
|
96
|
+
return this.idFor(normalized.bytes);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
commit(
|
|
101
|
+
messageId: string,
|
|
102
|
+
images: readonly RetainedImageInput[],
|
|
103
|
+
expectedReferences: readonly string[],
|
|
104
|
+
): PublicSentImageState {
|
|
105
|
+
this.assertOpen();
|
|
106
|
+
if (!this.settings.enabled) {
|
|
107
|
+
if (expectedReferences.length !== 0) {
|
|
108
|
+
throw new SentImageError("Sent-image retention is disabled.");
|
|
109
|
+
}
|
|
110
|
+
return this.publicState();
|
|
111
|
+
}
|
|
112
|
+
normalizeMessageId(messageId);
|
|
113
|
+
const normalized = images.map(normalizeImage);
|
|
114
|
+
const references = normalized.map((image) => this.idFor(image.bytes));
|
|
115
|
+
if (!sameIds(references, expectedReferences)) {
|
|
116
|
+
throw new SentImageError("Sent-image references are stale.");
|
|
117
|
+
}
|
|
118
|
+
let changed = false;
|
|
119
|
+
for (const [index, image] of normalized.entries()) {
|
|
120
|
+
const id = references[index];
|
|
121
|
+
if (!id) throw new SentImageError("Sent-image reference is missing.");
|
|
122
|
+
const existing = this.items.find((item) => item.id === id);
|
|
123
|
+
if (existing) {
|
|
124
|
+
existing.associations.add(messageId);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
this.items.push({
|
|
128
|
+
id,
|
|
129
|
+
name: image.name,
|
|
130
|
+
mimeType: image.mimeType,
|
|
131
|
+
bytes: Buffer.from(image.bytes),
|
|
132
|
+
associations: new Set([messageId]),
|
|
133
|
+
});
|
|
134
|
+
changed = true;
|
|
135
|
+
}
|
|
136
|
+
if (this.evict()) changed = true;
|
|
137
|
+
if (changed) this.revision += 1;
|
|
138
|
+
return this.publicState();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
preview(id: string): { bytes: Buffer; mimeType: string } {
|
|
142
|
+
const item = this.item(id);
|
|
143
|
+
return { bytes: Buffer.from(item.bytes), mimeType: item.mimeType };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
clone(ids: readonly string[]): RetainedImageClone[] {
|
|
147
|
+
this.assertOpen();
|
|
148
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
149
|
+
throw new SentImageError("No retained images were selected.", 400);
|
|
150
|
+
}
|
|
151
|
+
if (new Set(ids).size !== ids.length) {
|
|
152
|
+
throw new SentImageError("Retained image selection contains a duplicate.", 400);
|
|
153
|
+
}
|
|
154
|
+
return ids.map((id) => {
|
|
155
|
+
const item = this.item(id);
|
|
156
|
+
return {
|
|
157
|
+
retainedId: item.id,
|
|
158
|
+
name: item.name,
|
|
159
|
+
bytes: Buffer.from(item.bytes),
|
|
160
|
+
mimeType: item.mimeType,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
reconcile(externalBytes: number, totalBudget = this.settings.maxBytes): PublicSentImageState {
|
|
166
|
+
this.assertOpen();
|
|
167
|
+
if (
|
|
168
|
+
!Number.isSafeInteger(externalBytes) ||
|
|
169
|
+
externalBytes < 0 ||
|
|
170
|
+
!Number.isSafeInteger(totalBudget) ||
|
|
171
|
+
totalBudget <= 0
|
|
172
|
+
) {
|
|
173
|
+
throw new SentImageError("Image resident-byte accounting is invalid.", 500);
|
|
174
|
+
}
|
|
175
|
+
if (!this.evict(externalBytes, totalBudget)) return this.publicState();
|
|
176
|
+
this.revision += 1;
|
|
177
|
+
return this.publicState();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
remove(id: string, expectedRevision: number): PublicSentImageState {
|
|
181
|
+
this.assertRevision(expectedRevision);
|
|
182
|
+
const index = this.items.findIndex((item) => item.id === id);
|
|
183
|
+
if (index === -1) throw new SentImageError("Retained image was not found.", 404);
|
|
184
|
+
this.items.splice(index, 1);
|
|
185
|
+
this.revision += 1;
|
|
186
|
+
return this.publicState();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
clear(expectedRevision: number): PublicSentImageState {
|
|
190
|
+
this.assertRevision(expectedRevision);
|
|
191
|
+
if (this.items.length === 0) return this.publicState();
|
|
192
|
+
this.items.length = 0;
|
|
193
|
+
this.revision += 1;
|
|
194
|
+
return this.publicState();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
close(): void {
|
|
198
|
+
if (this.closed) return;
|
|
199
|
+
this.closed = true;
|
|
200
|
+
this.items.length = 0;
|
|
201
|
+
this.revision += 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private item(id: string): SentImageItem {
|
|
205
|
+
this.assertOpen();
|
|
206
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) {
|
|
207
|
+
throw new SentImageError("Retained image id is invalid.", 400);
|
|
208
|
+
}
|
|
209
|
+
const item = this.items.find((candidate) => candidate.id === id);
|
|
210
|
+
if (!item) throw new SentImageError("Retained image has expired.", 404);
|
|
211
|
+
return item;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private idFor(bytes: Buffer): string {
|
|
215
|
+
return `sent_${createHmac("sha256", this.key).update(bytes).digest("base64url").slice(0, 32)}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private evict(externalBytes = 0, totalBudget = this.settings.maxBytes): boolean {
|
|
219
|
+
let changed = false;
|
|
220
|
+
while (
|
|
221
|
+
this.items.length > 0 &&
|
|
222
|
+
(this.items.length > this.settings.maxImages ||
|
|
223
|
+
this.residentBytes() > this.settings.maxBytes ||
|
|
224
|
+
this.residentBytes() + externalBytes > totalBudget)
|
|
225
|
+
) {
|
|
226
|
+
this.items.shift();
|
|
227
|
+
changed = true;
|
|
228
|
+
}
|
|
229
|
+
return changed;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private assertRevision(expectedRevision: number): void {
|
|
233
|
+
this.assertOpen();
|
|
234
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== this.revision) {
|
|
235
|
+
throw new SentImageError(
|
|
236
|
+
`Sent-image revision mismatch; current revision is ${this.revision}.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private assertOpen(): void {
|
|
242
|
+
if (this.closed) throw new SentImageError("Sent-image store is closed.", 410);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeImage(
|
|
247
|
+
input: RetainedImageInput,
|
|
248
|
+
index = 0,
|
|
249
|
+
): {
|
|
250
|
+
name: string;
|
|
251
|
+
mimeType: string;
|
|
252
|
+
bytes: Buffer;
|
|
253
|
+
} {
|
|
254
|
+
if (!input || typeof input.data !== "string" || !validBase64(input.data)) {
|
|
255
|
+
throw new SentImageError(`Sent image ${index + 1} has invalid data.`, 400);
|
|
256
|
+
}
|
|
257
|
+
const bytes = Buffer.from(input.data, "base64");
|
|
258
|
+
if (bytes.length === 0) throw new SentImageError(`Sent image ${index + 1} is empty.`, 400);
|
|
259
|
+
const mimeType = input.mimeType;
|
|
260
|
+
if (typeof mimeType !== "string" || !/^image\/[a-z0-9.+-]{1,64}$/i.test(mimeType)) {
|
|
261
|
+
throw new SentImageError(`Sent image ${index + 1} has an invalid MIME type.`, 400);
|
|
262
|
+
}
|
|
263
|
+
const name = typeof input.name === "string" && input.name ? input.name.slice(0, 255) : "Image";
|
|
264
|
+
return { name, mimeType, bytes };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeMessageId(value: string): void {
|
|
268
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,120}$/.test(value)) {
|
|
269
|
+
throw new SentImageError("Message association is invalid.", 400);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function validBase64(value: string): boolean {
|
|
274
|
+
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sameIds(left: readonly string[], right: readonly string[]): boolean {
|
|
278
|
+
return left.length === right.length && left.every((id, index) => id === right[index]);
|
|
279
|
+
}
|