@bacnh85/pi-web 0.10.2 → 0.10.4
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/CHANGELOG.md +18 -0
- package/extensions/lib/imageapi.ts +43 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.4 (2026-09-13)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Truthful image extensions**: saved files are typed by magic bytes, not
|
|
8
|
+
URL/file extension — Z.ai GLM-Image serves JPEG behind a `.png` URL, which
|
|
9
|
+
previously produced mislabeled files and inline blocks.
|
|
10
|
+
|
|
11
|
+
## 0.10.3 (2026-09-13)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Redirect-aware SSRF guard**: image downloads now use `redirect: "manual"`
|
|
16
|
+
and re-validate every hop with the private/loopback check — a gateway URL
|
|
17
|
+
that 302s to an internal host (e.g. cloud metadata) can no longer bypass
|
|
18
|
+
the guard via fetch's default redirect following. Public redirects still
|
|
19
|
+
work (relative locations resolved per hop, max 3 hops).
|
|
20
|
+
|
|
3
21
|
## 0.10.2 (2026-09-13)
|
|
4
22
|
|
|
5
23
|
### Fixed
|
|
@@ -159,8 +159,15 @@ export function imageRateSnapshot(): Record<string, { count: number; day: string
|
|
|
159
159
|
export interface FetchLike {
|
|
160
160
|
(
|
|
161
161
|
url: string,
|
|
162
|
-
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
|
|
163
|
-
): Promise<{
|
|
162
|
+
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal; redirect?: string },
|
|
163
|
+
): Promise<{
|
|
164
|
+
ok: boolean;
|
|
165
|
+
status: number;
|
|
166
|
+
statusText?: string;
|
|
167
|
+
headers?: { get(name: string): string | null };
|
|
168
|
+
json(): Promise<unknown>;
|
|
169
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
170
|
+
}>;
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
export interface ApiImageResult {
|
|
@@ -231,7 +238,7 @@ export async function apiGenerateImage(opts: {
|
|
|
231
238
|
for (let i = 0; i < items.length; i++) {
|
|
232
239
|
const item = items[i];
|
|
233
240
|
if (typeof item?.b64_json === "string" && item.b64_json) {
|
|
234
|
-
paths.push(writeB64(opts.outDir, item.b64_json, i));
|
|
241
|
+
paths.push(writeB64(opts.outDir, Buffer.from(item.b64_json, "base64"), i));
|
|
235
242
|
} else if (typeof item?.url === "string" && item.url) {
|
|
236
243
|
// A failed download must not waste the generation: surface the URL.
|
|
237
244
|
try {
|
|
@@ -249,9 +256,19 @@ export async function apiGenerateImage(opts: {
|
|
|
249
256
|
return { paths, urls, model: typeof payload?.model === "string" ? payload.model : opts.model };
|
|
250
257
|
}
|
|
251
258
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
259
|
+
// Some gateways serve JPEG/WebP bytes behind a .png URL (Z.ai GLM-Image does) —
|
|
260
|
+
// trust the magic bytes, not the URL/file extension.
|
|
261
|
+
function extFromBytes(buf: Buffer, fallback: string): string {
|
|
262
|
+
if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return ".png";
|
|
263
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return ".jpg";
|
|
264
|
+
if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF") return ".gif";
|
|
265
|
+
if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return ".webp";
|
|
266
|
+
return fallback;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function writeB64(outDir: string, buf: Buffer, i: number): string {
|
|
270
|
+
const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFromBytes(buf, ".png")}`);
|
|
271
|
+
fs.writeFileSync(file, buf);
|
|
255
272
|
return file;
|
|
256
273
|
}
|
|
257
274
|
|
|
@@ -263,17 +280,30 @@ async function downloadImage(
|
|
|
263
280
|
signal?: AbortSignal,
|
|
264
281
|
timeoutMs?: number,
|
|
265
282
|
): Promise<string> {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
// fetch follows redirects by default — follow manually and re-validate each
|
|
284
|
+
// hop, or a gateway URL that 302s to an internal host bypasses the guard.
|
|
285
|
+
let current = url;
|
|
286
|
+
for (let hop = 0; ; hop++) {
|
|
287
|
+
if (hop > 3) throw new Error("too many image redirects");
|
|
288
|
+
if (isLocalUrl(current)) throw new Error(`image host is private/loopback (SSRF-guarded): ${current}`);
|
|
289
|
+
const res = await raceGuard(fetchImpl(current, { method: "GET", redirect: "manual", signal }), {
|
|
290
|
+
signal,
|
|
291
|
+
timeoutMs: timeoutMs ?? 120_000,
|
|
292
|
+
label: "web_image download",
|
|
293
|
+
});
|
|
294
|
+
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
295
|
+
const loc = res.headers?.get?.("location") ?? null;
|
|
296
|
+
if (!loc) throw new ImageApiError(res.status, `image redirect ${res.status} without a location header`);
|
|
297
|
+
current = new URL(loc, current).toString();
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!res.ok) throw new ImageApiError(res.status, `image download failed (HTTP ${res.status})`);
|
|
272
301
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
273
302
|
if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`image exceeds the ${MAX_DOWNLOAD_BYTES}-byte download cap`);
|
|
274
|
-
const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFor(url)}`);
|
|
303
|
+
const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFromBytes(buf, extFor(url))}`);
|
|
275
304
|
fs.writeFileSync(file, buf);
|
|
276
305
|
return file;
|
|
306
|
+
}
|
|
277
307
|
}
|
|
278
308
|
|
|
279
309
|
function extFor(url: string): string {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-web",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, Crawl4AI headless browser crawling, Gemini web-tier research, free upstream image generation, and one-off gateway chat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|