@narumitw/pi-image-drop 0.18.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/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +51 -0
- package/src/batch.ts +418 -0
- package/src/image-drop.ts +9 -0
- package/src/images.ts +408 -0
- package/src/pi-settings.ts +79 -0
- package/src/runtime.ts +417 -0
- package/src/server.ts +572 -0
- package/src/settings.ts +116 -0
- package/src/vendor.d.ts +30 -0
- package/src/web/app.js +403 -0
- package/src/web/index.html +74 -0
- package/src/web/state.js +61 -0
- package/src/web/styles.css +397 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
+
import type { Socket } from "node:net";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { ProcessedImage } from "./batch.js";
|
|
8
|
+
import { BatchError, type BatchStore, type PublicBatchState } from "./batch.js";
|
|
9
|
+
import type { ProcessImageOptions } from "./images.js";
|
|
10
|
+
import type { ImageDropSettings } from "./settings.js";
|
|
11
|
+
|
|
12
|
+
const JSON_LIMIT = 64 * 1024;
|
|
13
|
+
const CLIENT_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
14
|
+
|
|
15
|
+
export interface ImageDropServerOptions {
|
|
16
|
+
batch: BatchStore;
|
|
17
|
+
settings: ImageDropSettings;
|
|
18
|
+
projectName: string;
|
|
19
|
+
sessionName?: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
process: (source: Uint8Array, options: ProcessImageOptions) => Promise<ProcessedImage>;
|
|
22
|
+
getAutoResize: () => Promise<boolean>;
|
|
23
|
+
onStateChange?: (state: PublicBatchState) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SseClient {
|
|
27
|
+
id: string;
|
|
28
|
+
response: ServerResponse;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class HttpError extends Error {
|
|
32
|
+
constructor(
|
|
33
|
+
readonly status: number,
|
|
34
|
+
message: string,
|
|
35
|
+
readonly closeConnection = false,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ImageDropServer {
|
|
42
|
+
readonly origin: string;
|
|
43
|
+
private bootstrapToken?: string;
|
|
44
|
+
private readonly sessionSecret = token();
|
|
45
|
+
private readonly cookieName = `pi_image_drop_${randomBytes(8).toString("hex")}`;
|
|
46
|
+
private readonly abortController = new AbortController();
|
|
47
|
+
private readonly sockets = new Set<Socket>();
|
|
48
|
+
private readonly sseClients = new Set<SseClient>();
|
|
49
|
+
private activeClientId?: string;
|
|
50
|
+
private closed = false;
|
|
51
|
+
private closePromise?: Promise<void>;
|
|
52
|
+
|
|
53
|
+
private constructor(
|
|
54
|
+
private readonly server: Server,
|
|
55
|
+
private readonly options: ImageDropServerOptions,
|
|
56
|
+
port: number,
|
|
57
|
+
) {
|
|
58
|
+
this.origin = `http://127.0.0.1:${port}`;
|
|
59
|
+
server.on("connection", (socket) => {
|
|
60
|
+
this.sockets.add(socket);
|
|
61
|
+
socket.once("close", () => this.sockets.delete(socket));
|
|
62
|
+
});
|
|
63
|
+
server.unref();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static async start(options: ImageDropServerOptions): Promise<ImageDropServer> {
|
|
67
|
+
let instance: ImageDropServer | undefined;
|
|
68
|
+
const server = createServer((request, response) => {
|
|
69
|
+
if (!instance) {
|
|
70
|
+
response.writeHead(503).end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
void instance.handle(request, response);
|
|
74
|
+
});
|
|
75
|
+
await new Promise<void>((resolve, reject) => {
|
|
76
|
+
server.once("error", reject);
|
|
77
|
+
server.listen(0, "127.0.0.1", () => {
|
|
78
|
+
server.off("error", reject);
|
|
79
|
+
resolve();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
const address = server.address();
|
|
83
|
+
if (!address || typeof address === "string") {
|
|
84
|
+
server.close();
|
|
85
|
+
throw new Error("Image Drop server did not receive a TCP port");
|
|
86
|
+
}
|
|
87
|
+
instance = new ImageDropServer(server, options, address.port);
|
|
88
|
+
return instance;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
issueLink(): string {
|
|
92
|
+
if (this.closed) throw new Error("Image Drop server is closed");
|
|
93
|
+
this.bootstrapToken = token();
|
|
94
|
+
return `${this.origin}/bootstrap?token=${encodeURIComponent(this.bootstrapToken)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
broadcastState(): void {
|
|
98
|
+
if (this.closed) return;
|
|
99
|
+
const state = this.options.batch.publicState();
|
|
100
|
+
try {
|
|
101
|
+
this.options.onStateChange?.(state);
|
|
102
|
+
} catch {
|
|
103
|
+
// A stale Pi UI context must not roll back an accepted browser mutation.
|
|
104
|
+
}
|
|
105
|
+
this.broadcast("state", this.statePayload());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
close(): Promise<void> {
|
|
109
|
+
if (this.closePromise) return this.closePromise;
|
|
110
|
+
this.closePromise = this.closeNow();
|
|
111
|
+
return this.closePromise;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async closeNow(): Promise<void> {
|
|
115
|
+
if (this.closed) return;
|
|
116
|
+
this.closed = true;
|
|
117
|
+
this.bootstrapToken = undefined;
|
|
118
|
+
this.abortController.abort();
|
|
119
|
+
this.broadcast("session-ended", { message: "Pi session ended" });
|
|
120
|
+
const ending = [...this.sseClients].map(
|
|
121
|
+
(client) =>
|
|
122
|
+
new Promise<void>((resolve) => {
|
|
123
|
+
if (client.response.destroyed || client.response.writableEnded) resolve();
|
|
124
|
+
else client.response.end(resolve);
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
this.sseClients.clear();
|
|
128
|
+
await Promise.all(ending);
|
|
129
|
+
this.server.closeAllConnections?.();
|
|
130
|
+
for (const socket of this.sockets) socket.destroy();
|
|
131
|
+
await new Promise<void>((resolve) => this.server.close(() => resolve()));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
135
|
+
this.secureHeaders(response);
|
|
136
|
+
try {
|
|
137
|
+
if (this.closed) throw new HttpError(410, "Pi session ended");
|
|
138
|
+
if (request.headers.host !== new URL(this.origin).host) {
|
|
139
|
+
throw new HttpError(421, "Unexpected Host header");
|
|
140
|
+
}
|
|
141
|
+
const url = new URL(request.url ?? "/", this.origin);
|
|
142
|
+
if (request.method === "GET" && url.pathname === "/bootstrap") {
|
|
143
|
+
this.bootstrap(url, response);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.authenticate(request);
|
|
147
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
148
|
+
await this.asset(response, "index.html", "text/html; charset=utf-8");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
request.method === "GET" &&
|
|
153
|
+
(url.pathname === "/app.js" || url.pathname === "/state.js")
|
|
154
|
+
) {
|
|
155
|
+
await this.asset(response, url.pathname.slice(1), "text/javascript; charset=utf-8");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (request.method === "GET" && url.pathname === "/styles.css") {
|
|
159
|
+
await this.asset(response, "styles.css", "text/css; charset=utf-8");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
163
|
+
this.json(response, 200, this.statePayload());
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (request.method === "GET" && url.pathname === "/api/events") {
|
|
167
|
+
this.events(url, request, response);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const previewMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/preview$/.exec(url.pathname);
|
|
171
|
+
if (request.method === "GET" && previewMatch) {
|
|
172
|
+
const preview = this.options.batch.preview(previewMatch[1] as string);
|
|
173
|
+
response.setHeader("Content-Type", preview.mimeType);
|
|
174
|
+
response.setHeader("Content-Length", preview.bytes.byteLength);
|
|
175
|
+
response.writeHead(200).end(preview.bytes);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (request.method === "POST" && url.pathname === "/api/lease") {
|
|
179
|
+
this.assertMutation(request);
|
|
180
|
+
const body = await readJson(request, JSON_LIMIT);
|
|
181
|
+
const clientId = stringField(body, "clientId");
|
|
182
|
+
if (!CLIENT_ID.test(clientId) || request.headers["x-image-drop-client"] !== clientId) {
|
|
183
|
+
throw new HttpError(400, "Invalid client id");
|
|
184
|
+
}
|
|
185
|
+
this.takeLease(clientId);
|
|
186
|
+
this.json(response, 200, this.statePayload());
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.assertMutation(request);
|
|
190
|
+
this.assertActiveClient(request);
|
|
191
|
+
if (request.method === "POST" && url.pathname === "/api/items") {
|
|
192
|
+
const body = await readJson(request, JSON_LIMIT);
|
|
193
|
+
const revision = integerField(body, "revision");
|
|
194
|
+
const inputs = arrayField(body, "items").map((item) => {
|
|
195
|
+
if (!isRecord(item)) throw new HttpError(400, "Invalid item reservation");
|
|
196
|
+
return {
|
|
197
|
+
id: stringField(item, "id"),
|
|
198
|
+
name: stringField(item, "name"),
|
|
199
|
+
size: integerField(item, "size"),
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
this.options.batch.reserveItems(inputs, revision);
|
|
203
|
+
this.respondWithState(response);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const contentMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/content$/.exec(url.pathname);
|
|
207
|
+
if (request.method === "PUT" && contentMatch) {
|
|
208
|
+
const id = contentMatch[1] as string;
|
|
209
|
+
let source: Buffer;
|
|
210
|
+
try {
|
|
211
|
+
source = await readBody(request, this.options.settings.maxImageBytes);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
try {
|
|
214
|
+
this.options.batch.failUpload(id, `Upload failed: ${formatError(error)}`);
|
|
215
|
+
} catch (failure) {
|
|
216
|
+
if (!isHandledUploadError(failure)) throw failure;
|
|
217
|
+
}
|
|
218
|
+
this.broadcastState();
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
this.options.batch.startProcessing(id, source);
|
|
222
|
+
this.broadcastState();
|
|
223
|
+
const completed = await this.processItem(id, source, request);
|
|
224
|
+
this.broadcastState();
|
|
225
|
+
this.json(response, 200, { ...this.statePayload(), duplicateOf: completed.duplicateOf });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const failureMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/fail$/.exec(url.pathname);
|
|
229
|
+
if (request.method === "POST" && failureMatch) {
|
|
230
|
+
const body = await readJson(request, JSON_LIMIT);
|
|
231
|
+
this.options.batch.failUpload(failureMatch[1] as string, stringField(body, "error"));
|
|
232
|
+
this.respondWithState(response);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const retryMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/retry$/.exec(url.pathname);
|
|
236
|
+
if (request.method === "POST" && retryMatch) {
|
|
237
|
+
const id = retryMatch[1] as string;
|
|
238
|
+
const source = this.options.batch.retrySource(id);
|
|
239
|
+
this.broadcastState();
|
|
240
|
+
const completed = await this.processItem(id, source, request);
|
|
241
|
+
this.broadcastState();
|
|
242
|
+
this.json(response, 200, { ...this.statePayload(), duplicateOf: completed.duplicateOf });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const itemMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})$/.exec(url.pathname);
|
|
246
|
+
if (request.method === "DELETE" && itemMatch) {
|
|
247
|
+
this.options.batch.delete(itemMatch[1] as string, queryInteger(url, "revision"));
|
|
248
|
+
this.respondWithState(response);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (request.method === "PUT" && url.pathname === "/api/order") {
|
|
252
|
+
const body = await readJson(request, JSON_LIMIT);
|
|
253
|
+
const ids = arrayField(body, "ids").map((id) => {
|
|
254
|
+
if (typeof id !== "string") throw new HttpError(400, "Invalid image order");
|
|
255
|
+
return id;
|
|
256
|
+
});
|
|
257
|
+
this.options.batch.reorder(ids, integerField(body, "revision"));
|
|
258
|
+
this.respondWithState(response);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (request.method === "POST" && url.pathname === "/api/clear") {
|
|
262
|
+
const body = await readJson(request, JSON_LIMIT);
|
|
263
|
+
this.options.batch.clear(integerField(body, "revision"));
|
|
264
|
+
this.respondWithState(response);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
throw new HttpError(404, "Not found");
|
|
268
|
+
} catch (error) {
|
|
269
|
+
this.error(response, error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private bootstrap(url: URL, response: ServerResponse): void {
|
|
274
|
+
const supplied = url.searchParams.get("token") ?? "";
|
|
275
|
+
if (!this.bootstrapToken || !secretEquals(supplied, this.bootstrapToken)) {
|
|
276
|
+
throw new HttpError(401, "Invalid or expired bootstrap token");
|
|
277
|
+
}
|
|
278
|
+
this.bootstrapToken = undefined;
|
|
279
|
+
response.setHeader(
|
|
280
|
+
"Set-Cookie",
|
|
281
|
+
`${this.cookieName}=${this.sessionSecret}; HttpOnly; SameSite=Strict; Path=/`,
|
|
282
|
+
);
|
|
283
|
+
response.setHeader("Location", "/");
|
|
284
|
+
response.writeHead(303).end();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private authenticate(request: IncomingMessage): void {
|
|
288
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
289
|
+
if (!secretEquals(cookies.get(this.cookieName) ?? "", this.sessionSecret)) {
|
|
290
|
+
throw new HttpError(401, "Authentication required");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private assertMutation(request: IncomingMessage): void {
|
|
295
|
+
if (request.headers.origin !== this.origin) throw new HttpError(403, "Unexpected Origin");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private assertActiveClient(request: IncomingMessage): void {
|
|
299
|
+
const client = request.headers["x-image-drop-client"];
|
|
300
|
+
if (typeof client !== "string" || client !== this.activeClientId) {
|
|
301
|
+
throw new HttpError(409, "This page no longer owns the editing lease");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private takeLease(clientId: string): void {
|
|
306
|
+
const previous = this.activeClientId;
|
|
307
|
+
this.activeClientId = clientId;
|
|
308
|
+
if (previous && previous !== clientId) {
|
|
309
|
+
for (const client of [...this.sseClients]) {
|
|
310
|
+
if (client.id !== previous) continue;
|
|
311
|
+
writeSse(client.response, "stale", { message: "Image Drop opened in another tab" });
|
|
312
|
+
client.response.end();
|
|
313
|
+
this.sseClients.delete(client);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private events(url: URL, request: IncomingMessage, response: ServerResponse): void {
|
|
319
|
+
const clientId = url.searchParams.get("client") ?? "";
|
|
320
|
+
if (!CLIENT_ID.test(clientId) || clientId !== this.activeClientId) {
|
|
321
|
+
throw new HttpError(409, "This page no longer owns the editing lease");
|
|
322
|
+
}
|
|
323
|
+
response.writeHead(200, {
|
|
324
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
325
|
+
Connection: "keep-alive",
|
|
326
|
+
});
|
|
327
|
+
response.write("retry: 1000\n\n");
|
|
328
|
+
const client = { id: clientId, response };
|
|
329
|
+
this.sseClients.add(client);
|
|
330
|
+
writeSse(response, "state", this.statePayload());
|
|
331
|
+
request.once("close", () => this.sseClients.delete(client));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async processItem(
|
|
335
|
+
id: string,
|
|
336
|
+
source: Buffer,
|
|
337
|
+
request: IncomingMessage,
|
|
338
|
+
): Promise<{ duplicateOf?: string }> {
|
|
339
|
+
const requestAbort = new AbortController();
|
|
340
|
+
request.once("aborted", () => requestAbort.abort());
|
|
341
|
+
const signal = AbortSignal.any([this.abortController.signal, requestAbort.signal]);
|
|
342
|
+
try {
|
|
343
|
+
const autoResize = await this.options.getAutoResize();
|
|
344
|
+
const processed = await this.options.process(source, {
|
|
345
|
+
autoResize,
|
|
346
|
+
maxImagePixels: this.options.settings.maxImagePixels,
|
|
347
|
+
signal,
|
|
348
|
+
});
|
|
349
|
+
const completion = this.options.batch.complete(id, processed, autoResize);
|
|
350
|
+
return completion.kind === "duplicate" ? { duplicateOf: completion.existingId } : {};
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (this.closed || signal.aborted || isDiscardedItemError(error)) return {};
|
|
353
|
+
if (!this.failItem(id, formatError(error))) return {};
|
|
354
|
+
throw new HttpError(422, formatError(error));
|
|
355
|
+
} finally {
|
|
356
|
+
this.broadcastState();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private failItem(id: string, error: string): boolean {
|
|
361
|
+
try {
|
|
362
|
+
this.options.batch.fail(id, error);
|
|
363
|
+
return true;
|
|
364
|
+
} catch (failure) {
|
|
365
|
+
if (isDiscardedItemError(failure)) return false;
|
|
366
|
+
throw failure;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private statePayload() {
|
|
371
|
+
return {
|
|
372
|
+
projectName: this.options.projectName,
|
|
373
|
+
sessionName: this.options.sessionName,
|
|
374
|
+
cwd: this.options.cwd,
|
|
375
|
+
activeClientId: this.activeClientId,
|
|
376
|
+
batch: this.options.batch.publicState(),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private respondWithState(response: ServerResponse): void {
|
|
381
|
+
this.broadcastState();
|
|
382
|
+
this.json(response, 200, this.statePayload());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private broadcast(event: string, data: unknown): void {
|
|
386
|
+
for (const client of [...this.sseClients]) {
|
|
387
|
+
if (client.response.destroyed || client.response.writableEnded) {
|
|
388
|
+
this.sseClients.delete(client);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
writeSse(client.response, event, data);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async asset(response: ServerResponse, name: string, contentType: string): Promise<void> {
|
|
396
|
+
const data = await readAsset(name);
|
|
397
|
+
response.setHeader("Content-Type", contentType);
|
|
398
|
+
response.setHeader("Content-Length", data.byteLength);
|
|
399
|
+
response.writeHead(200).end(data);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private secureHeaders(response: ServerResponse): void {
|
|
403
|
+
response.setHeader("Cache-Control", "no-store");
|
|
404
|
+
response.setHeader(
|
|
405
|
+
"Content-Security-Policy",
|
|
406
|
+
"default-src 'self'; img-src 'self' blob:; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
407
|
+
);
|
|
408
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
409
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
410
|
+
response.setHeader("X-Frame-Options", "DENY");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private json(response: ServerResponse, status: number, value: unknown): void {
|
|
414
|
+
const body = Buffer.from(JSON.stringify(value));
|
|
415
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
416
|
+
response.setHeader("Content-Length", body.byteLength);
|
|
417
|
+
response.writeHead(status).end(body);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private error(response: ServerResponse, error: unknown): void {
|
|
421
|
+
if (response.headersSent || response.writableEnded) {
|
|
422
|
+
response.destroy();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (error instanceof BatchError) {
|
|
426
|
+
const statuses = {
|
|
427
|
+
closed: 410,
|
|
428
|
+
frozen: 423,
|
|
429
|
+
stale: 409,
|
|
430
|
+
limit: 413,
|
|
431
|
+
invalid: 400,
|
|
432
|
+
"not-found": 404,
|
|
433
|
+
"not-ready": 409,
|
|
434
|
+
} as const;
|
|
435
|
+
this.json(response, statuses[error.code], { error: error.message, code: error.code });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (error instanceof HttpError) {
|
|
439
|
+
if (error.closeConnection) response.setHeader("Connection", "close");
|
|
440
|
+
this.json(response, error.status, { error: error.message });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.json(response, 500, { error: "Internal Image Drop error" });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function readAsset(name: string): Promise<Buffer> {
|
|
448
|
+
const runtimePath = fileURLToPath(new URL(`./web/${name}`, import.meta.url));
|
|
449
|
+
try {
|
|
450
|
+
return await readFile(runtimePath);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
453
|
+
return readFile(join(process.cwd(), "extensions", "pi-image-drop", "src", "web", name));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function readJson(request: IncomingMessage, limit: number): Promise<Record<string, unknown>> {
|
|
458
|
+
const body = await readBody(request, limit);
|
|
459
|
+
try {
|
|
460
|
+
const value = JSON.parse(body.toString("utf8")) as unknown;
|
|
461
|
+
if (!isRecord(value)) throw new Error("JSON body must be an object");
|
|
462
|
+
return value;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
throw new HttpError(400, `Invalid JSON body: ${formatError(error)}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function readBody(request: IncomingMessage, limit: number): Promise<Buffer> {
|
|
469
|
+
const declared = Number(request.headers["content-length"]);
|
|
470
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
471
|
+
request.pause();
|
|
472
|
+
return Promise.reject(new HttpError(413, "Request body is too large", true));
|
|
473
|
+
}
|
|
474
|
+
return new Promise((resolve, reject) => {
|
|
475
|
+
const chunks: Buffer[] = [];
|
|
476
|
+
let bytes = 0;
|
|
477
|
+
let settled = false;
|
|
478
|
+
request.on("data", (chunk: Buffer | string) => {
|
|
479
|
+
if (settled) return;
|
|
480
|
+
const buffer = Buffer.from(chunk);
|
|
481
|
+
bytes += buffer.byteLength;
|
|
482
|
+
if (bytes > limit) {
|
|
483
|
+
settled = true;
|
|
484
|
+
request.pause();
|
|
485
|
+
reject(new HttpError(413, "Request body is too large", true));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
chunks.push(buffer);
|
|
489
|
+
});
|
|
490
|
+
request.once("end", () => {
|
|
491
|
+
if (!settled) resolve(Buffer.concat(chunks));
|
|
492
|
+
});
|
|
493
|
+
request.once("aborted", () => reject(new HttpError(400, "Request was aborted")));
|
|
494
|
+
request.once("error", reject);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function parseCookies(header?: string): Map<string, string> {
|
|
499
|
+
const result = new Map<string, string>();
|
|
500
|
+
for (const pair of header?.split(";") ?? []) {
|
|
501
|
+
const separator = pair.indexOf("=");
|
|
502
|
+
if (separator <= 0) continue;
|
|
503
|
+
result.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function writeSse(response: ServerResponse, event: string, data: unknown): void {
|
|
509
|
+
try {
|
|
510
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
511
|
+
} catch {
|
|
512
|
+
response.destroy();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function token(): string {
|
|
517
|
+
return randomBytes(32).toString("base64url");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function secretEquals(left: string, right: string): boolean {
|
|
521
|
+
const a = Buffer.from(left);
|
|
522
|
+
const b = Buffer.from(right);
|
|
523
|
+
return a.byteLength === b.byteLength && timingSafeEqual(a, b);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function stringField(value: Record<string, unknown>, key: string): string {
|
|
527
|
+
const field = value[key];
|
|
528
|
+
if (typeof field !== "string") throw new HttpError(400, `${key} must be a string`);
|
|
529
|
+
return field;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function integerField(value: Record<string, unknown>, key: string): number {
|
|
533
|
+
const field = value[key];
|
|
534
|
+
if (!Number.isSafeInteger(field) || (field as number) < 0) {
|
|
535
|
+
throw new HttpError(400, `${key} must be a non-negative integer`);
|
|
536
|
+
}
|
|
537
|
+
return field as number;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function arrayField(value: Record<string, unknown>, key: string): unknown[] {
|
|
541
|
+
const field = value[key];
|
|
542
|
+
if (!Array.isArray(field)) throw new HttpError(400, `${key} must be an array`);
|
|
543
|
+
return field;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function queryInteger(url: URL, key: string): number {
|
|
547
|
+
const value = url.searchParams.get(key);
|
|
548
|
+
if (!value || !/^\d+$/.test(value)) throw new HttpError(400, `${key} is required`);
|
|
549
|
+
const parsed = Number(value);
|
|
550
|
+
if (!Number.isSafeInteger(parsed)) throw new HttpError(400, `${key} is invalid`);
|
|
551
|
+
return parsed;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
555
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
559
|
+
return error instanceof Error && "code" in error;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function formatError(error: unknown): string {
|
|
563
|
+
return error instanceof Error ? error.message : String(error);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function isDiscardedItemError(error: unknown): boolean {
|
|
567
|
+
return error instanceof BatchError && (error.code === "not-found" || error.code === "closed");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isHandledUploadError(error: unknown): boolean {
|
|
571
|
+
return isDiscardedItemError(error) || (error instanceof BatchError && error.code === "invalid");
|
|
572
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export const SETTINGS_FILE = "pi-image-drop.json";
|
|
6
|
+
const MIB = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export interface ImageDropSettings {
|
|
9
|
+
maxImages: number;
|
|
10
|
+
maxImageBytes: number;
|
|
11
|
+
maxBatchBytes: number;
|
|
12
|
+
maxImagePixels: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_SETTINGS: Readonly<ImageDropSettings> = Object.freeze({
|
|
16
|
+
maxImages: 8,
|
|
17
|
+
maxImageBytes: 10 * MIB,
|
|
18
|
+
maxBatchBytes: 40 * MIB,
|
|
19
|
+
maxImagePixels: 50_000_000,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const HARD_LIMITS: Readonly<ImageDropSettings> = Object.freeze({
|
|
23
|
+
maxImages: 32,
|
|
24
|
+
maxImageBytes: 50 * MIB,
|
|
25
|
+
maxBatchBytes: 200 * MIB,
|
|
26
|
+
maxImagePixels: 100_000_000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const SETTING_KEYS = new Set<keyof ImageDropSettings>([
|
|
30
|
+
"maxImages",
|
|
31
|
+
"maxImageBytes",
|
|
32
|
+
"maxBatchBytes",
|
|
33
|
+
"maxImagePixels",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export type SettingsLoadResult =
|
|
37
|
+
| { kind: "missing"; settings: ImageDropSettings }
|
|
38
|
+
| { kind: "loaded"; settings: ImageDropSettings; warning?: string }
|
|
39
|
+
| { kind: "invalid"; settings: ImageDropSettings; warning: string };
|
|
40
|
+
|
|
41
|
+
export function normalizeSettings(value: unknown): ImageDropSettings | undefined {
|
|
42
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !SETTING_KEYS.has(key as never))) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const settings: ImageDropSettings = { ...DEFAULT_SETTINGS };
|
|
46
|
+
for (const key of SETTING_KEYS) {
|
|
47
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
48
|
+
const candidate = Reflect.get(value, key);
|
|
49
|
+
if (
|
|
50
|
+
typeof candidate !== "number" ||
|
|
51
|
+
!Number.isSafeInteger(candidate) ||
|
|
52
|
+
candidate <= 0 ||
|
|
53
|
+
candidate > HARD_LIMITS[key]
|
|
54
|
+
) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
settings[key] = candidate;
|
|
58
|
+
}
|
|
59
|
+
if (settings.maxImageBytes > settings.maxBatchBytes) return undefined;
|
|
60
|
+
return settings;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadSettings(
|
|
64
|
+
path = join(getAgentDir(), SETTINGS_FILE),
|
|
65
|
+
): Promise<SettingsLoadResult> {
|
|
66
|
+
let text: string;
|
|
67
|
+
try {
|
|
68
|
+
const stats = await lstat(path);
|
|
69
|
+
if (stats.isSymbolicLink()) return invalid(path, "symbolic links are not accepted");
|
|
70
|
+
if (!stats.isFile()) return invalid(path, "settings path is not a regular file");
|
|
71
|
+
text = await readFile(path, "utf8");
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
74
|
+
return { kind: "missing", settings: { ...DEFAULT_SETTINGS } };
|
|
75
|
+
}
|
|
76
|
+
return invalid(path, formatError(error));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const settings = normalizeSettings(JSON.parse(text) as unknown);
|
|
81
|
+
if (!settings) return invalid(path, "invalid settings shape or value");
|
|
82
|
+
const raised = (Object.keys(DEFAULT_SETTINGS) as Array<keyof ImageDropSettings>).filter(
|
|
83
|
+
(key) => settings[key] > DEFAULT_SETTINGS[key],
|
|
84
|
+
);
|
|
85
|
+
return {
|
|
86
|
+
kind: "loaded",
|
|
87
|
+
settings,
|
|
88
|
+
warning:
|
|
89
|
+
raised.length > 0
|
|
90
|
+
? `${SETTINGS_FILE} raises ${raised.join(", ")} above the safe defaults; large provider requests may fail.`
|
|
91
|
+
: undefined,
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return invalid(path, formatError(error));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function invalid(path: string, reason: string): SettingsLoadResult {
|
|
99
|
+
return {
|
|
100
|
+
kind: "invalid",
|
|
101
|
+
settings: { ...DEFAULT_SETTINGS },
|
|
102
|
+
warning: `${SETTINGS_FILE} ignored (${path}: ${reason}); using safe defaults.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
111
|
+
return error instanceof Error && "code" in error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatError(error: unknown) {
|
|
115
|
+
return error instanceof Error ? error.message : String(error);
|
|
116
|
+
}
|