fix(bluesky): guard uploadMedia() against non-image HTTP responses

uploadMedia() had no content-type check, so an HTML login-redirect response
from an auth-protected internal endpoint was uploaded to Bluesky as a blob
with encoding "text/html". uploadBlob() accepts it, but record validation
rejects the post with 'Expected "image/*" (got "text/html")'.

The patch mirrors the guard already present in uploadImageFromUrl() and also
wraps per-photo uploads in try/catch so one bad photo doesn't abort the
entire syndication — other photos and the post text are still published.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-03-29 09:53:46 +02:00
parent 4aa1554f3a
commit b4fc7ffb4f
2 changed files with 121 additions and 2 deletions

View File

@@ -0,0 +1,119 @@
/**
* Patch: guard uploadMedia() against non-image HTTP responses.
*
* Root cause:
* uploadMedia() fetches a photo URL and uploads whatever it receives to Bluesky
* without checking the Content-Type. If the internal fetch returns an HTML page
* (e.g. a login redirect from an auth-protected endpoint), the blob is uploaded
* with encoding "text/html". uploadBlob() accepts it, but app.bsky.feed.post
* record validation then rejects the post:
* "Expected 'image/*' (got 'text/html') at $.record.embed.images[0].image.mimeType"
*
* uploadImageFromUrl() has this guard already (it returns null for non-image
* responses) but uploadMedia() does not.
*
* Fix:
* 1. Add a content-type guard to uploadMedia() — throw if response is not image/*.
* 2. Wrap per-photo uploads in post() with try/catch and filter out failures,
* so one bad photo doesn't block the entire syndication.
*/
import { access, readFile, writeFile } from "node:fs/promises";
const TARGET =
"node_modules/@rmdes/indiekit-syndicator-bluesky/lib/bluesky.js";
const MARKER = "// bsky-media-type-guard patch";
// ---------------------------------------------------------------------------
// 1. Guard in uploadMedia(): reject non-image content types
// ---------------------------------------------------------------------------
const OLD_ENCODING = ` let blob = await mediaResponse.blob();
let encoding = mediaResponse.headers.get("Content-Type");
if (encoding?.startsWith("image/")) {`;
const NEW_ENCODING = ` let blob = await mediaResponse.blob();
let encoding = mediaResponse.headers.get("Content-Type");
// Reject non-image responses (e.g. HTML login redirects) ${MARKER}
if (!encoding || !encoding.startsWith("image/")) {
throw new Error(\`uploadMedia: non-image content-type "\${encoding}" for \${mediaUrl}\`); ${MARKER}
}
if (encoding?.startsWith("image/")) {`;
// ---------------------------------------------------------------------------
// 2. Per-photo error handling in post(): skip failed uploads instead of
// propagating a broken blob into the images array.
// ---------------------------------------------------------------------------
const OLD_UPLOADS = ` if (properties.photo) {
const photos = properties.photo.slice(0, 4);
const uploads = photos.map(async (photo) => ({
alt: photo.alt || "",
image: await this.uploadMedia(photo, me),
}));
images = await Promise.all(uploads);
}`;
const NEW_UPLOADS = ` if (properties.photo) {
const photos = properties.photo.slice(0, 4);
const uploads = photos.map(async (photo) => { ${MARKER}
try {
const image = await this.uploadMedia(photo, me);
return image ? { alt: photo.alt || "", image } : null; ${MARKER}
} catch (err) {
console.error(\`[Bluesky] uploadMedia failed for \${photo.url}: \${err.message}\`); ${MARKER}
return null; ${MARKER}
}
});
images = (await Promise.all(uploads)).filter(Boolean); ${MARKER}
}`;
// ---------------------------------------------------------------------------
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
if (!(await exists(TARGET))) {
console.log("[postinstall] patch-bluesky-syndicator-media-type-guard: target not found");
process.exit(0);
}
const source = await readFile(TARGET, "utf8");
if (source.includes(MARKER)) {
console.log("[postinstall] patch-bluesky-syndicator-media-type-guard: already applied");
process.exit(0);
}
let updated = source;
let changed = false;
if (updated.includes(OLD_ENCODING)) {
updated = updated.replace(OLD_ENCODING, NEW_ENCODING);
changed = true;
} else {
console.warn("[postinstall] patch-bluesky-syndicator-media-type-guard: encoding block not found — skipping");
}
if (updated.includes(OLD_UPLOADS)) {
updated = updated.replace(OLD_UPLOADS, NEW_UPLOADS);
changed = true;
} else {
console.warn("[postinstall] patch-bluesky-syndicator-media-type-guard: uploads block not found — skipping");
}
if (!changed || updated === source) {
console.log("[postinstall] patch-bluesky-syndicator-media-type-guard: no changes applied");
process.exit(0);
}
await writeFile(TARGET, updated, "utf8");
console.log(`[postinstall] Applied patch-bluesky-syndicator-media-type-guard to ${TARGET}`);