feat(core): image hosts (server, litterbox, catbox, direct) and per-image rehosting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-14 23:38:22 +02:00
1 parent 1e548701ca
commit a6c02cb548
8 files changed
+295

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import type { Transport } from "../transport";
import { HostError, UPLOAD_TIMEOUT_MS, fileFor, viaServer, type ImageHost } from "./host";
export const CATBOX_API = "https://catbox.moe/user/api.php";
/** Used directly by the userscript and by our server (for the page). */
export async function uploadToCatbox(transport: Transport, file: Blob, name: string, userhash?: string): Promise<{ url: string }> {
const form = new FormData();
form.set("reqtype", "fileupload");
if (userhash) form.set("userhash", userhash);
form.set("fileToUpload", file, name);
const res = await transport.fetch(CATBOX_API, { method: "POST", body: form, timeoutMs: UPLOAD_TIMEOUT_MS });
const body = (await res.text()).trim();
if (!res.ok || !/^https:\/\/files\.catbox\.moe\/\S+$/.test(body)) {
throw new HostError(`catbox: ${body.slice(0, 200) || `HTTP ${res.status}`}`, res.status);
}
return { url: body };
}
export const catbox: ImageHost = {
id: "catbox",
lifetime: () => "permanent",
upload(image, ctx) {
const userhash = ctx.options.catboxUserhash?.trim() || undefined;
if (ctx.catboxVia === "server") return viaServer(image, ctx, { host: "catbox", ...(userhash ? { userhash } : {}) });
return uploadToCatbox(ctx.transport, fileFor(image), `${image.sha256}.${image.ext}`, userhash);
},
};
+11
View File
@@ -0,0 +1,11 @@
import { HostError, type ImageHost } from "./host";
/** No rehosting: submit the source URL itself. */
export const direct: ImageHost = {
id: "direct",
lifetime: () => "as long as the source keeps it",
async upload(image) {
if (!image.live) throw new HostError("This image is only on archive.org now; pick a host that rehosts it.");
return { url: image.sourceUrl };
},
};
+66
View File
@@ -0,0 +1,66 @@
import type { Transport } from "../transport";
export type HostId = "server" | "litterbox" | "catbox" | "direct";
export type LitterboxTime = "1h" | "12h" | "24h" | "72h";
export const LITTERBOX_TIMES: readonly LitterboxTime[] = ["1h", "12h", "24h", "72h"];
export interface HostOptions {
litterboxTime?: LitterboxTime;
catboxUserhash?: string;
}
/** One inspected image, ready to rehost. `sourceUrl` is where its bytes came from. */
export interface HostImage {
sourceUrl: string;
live: boolean;
bytes: Uint8Array;
sha256: string;
contentType: string;
ext: string;
}
export interface HostContext {
transport: Transport;
/** Our server's origin, or null when there is none (userscript without a server). */
serverBase: string | null;
/** catbox has no CORS: the page goes through our server, the userscript straight there (spec §4.6). */
catboxVia: "server" | "direct";
options: HostOptions;
}
export interface ImageHost {
readonly id: HostId;
/** Shown next to the host in the UI. */
lifetime(opts: HostOptions): string;
upload(image: HostImage, ctx: HostContext): Promise<{ url: string }>;
}
export class HostError extends Error {
constructor(message: string, readonly status?: number) {
super(message);
this.name = "HostError";
}
}
export const UPLOAD_TIMEOUT_MS = 120_000;
export const fileFor = (image: HostImage): Blob => new Blob([image.bytes as Uint8Array<ArrayBuffer>], { type: image.contentType });
/** `POST /api/upload`: the server fetches `sourceUrl` itself, so it can't be used to host arbitrary bytes. */
export async function viaServer(
image: HostImage,
ctx: HostContext,
body: { host: "store" | "catbox"; userhash?: string },
): Promise<{ url: string }> {
if (ctx.serverBase === null) throw new HostError("No mangabaka-seeder server is configured.");
const res = await ctx.transport.fetch(`${ctx.serverBase}/api/upload`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: image.sourceUrl, ...body }),
timeoutMs: UPLOAD_TIMEOUT_MS,
});
const reply = (await res.json().catch(() => null)) as { url?: string; sha256?: string; error?: string } | null;
if (!res.ok || !reply?.url) throw new HostError(reply?.error ?? `Upload failed (HTTP ${res.status}).`, res.status);
if (reply.sha256 !== image.sha256) throw new HostError("The server fetched different bytes than the ones checked; not using them.");
return { url: reply.url };
}
+22
View File
@@ -0,0 +1,22 @@
import { HostError, UPLOAD_TIMEOUT_MS, fileFor, type ImageHost, type LitterboxTime } from "./host";
export const LITTERBOX_API = "https://litterbox.catbox.moe/resources/internals/api.php";
const LABEL: Readonly<Record<LitterboxTime, string>> = { "1h": "1 hour", "12h": "12 hours", "24h": "24 hours", "72h": "72 hours" };
/** Temporary (≤ 72 h); CORS is open, so the page uploads straight from the browser. */
export const litterbox: ImageHost = {
id: "litterbox",
lifetime: (opts) => LABEL[opts.litterboxTime ?? "72h"],
async upload(image, ctx) {
const form = new FormData();
form.set("reqtype", "fileupload");
form.set("time", ctx.options.litterboxTime ?? "72h");
form.set("fileToUpload", fileFor(image), `${image.sha256}.${image.ext}`);
const res = await ctx.transport.fetch(LITTERBOX_API, { method: "POST", body: form, timeoutMs: UPLOAD_TIMEOUT_MS });
const body = (await res.text()).trim();
if (!res.ok || !/^https:\/\/litter\.catbox\.moe\/\S+$/.test(body)) {
throw new HostError(`litterbox: ${body.slice(0, 200) || `HTTP ${res.status}`}`, res.status);
}
return { url: body };
},
};
+8
View File
@@ -0,0 +1,8 @@
import { catbox } from "./catbox";
import { direct } from "./direct";
import type { HostId, ImageHost } from "./host";
import { litterbox } from "./litterbox";
import { serverHost } from "./server";
/** Adding a host: one file in this folder plus one line here. */
export const HOSTS: Readonly<Record<HostId, ImageHost>> = { server: serverHost, litterbox, catbox, direct };
+46
View File
@@ -0,0 +1,46 @@
import { HostError, type HostContext, type HostId, type HostImage, type HostOptions, type ImageHost } from "./host";
export interface RehostResult {
hosted: Map<string, string>;
failures: { sha256: string; message: string }[];
allFailed: boolean;
}
/** litterbox and catbox uploads are remembered by hash; server storage is re-uploaded on purpose (restarts its 30 days). */
export function cacheKey(host: HostId, options: HostOptions, sha256: string): string | null {
if (host === "litterbox") return `litterbox:${options.litterboxTime ?? "72h"}:${sha256}`;
if (host === "catbox") return `catbox:${sha256}`;
return null;
}
/** Failures are per image (spec §4.6). A full store (507) stops the rest: they would fail the same way. */
export async function rehostAll(
images: readonly HostImage[],
host: ImageHost,
ctx: HostContext,
cache: Map<string, string>,
onProgress?: (done: number, total: number) => void,
): Promise<RehostResult> {
const hosted = new Map<string, string>();
const failures: { sha256: string; message: string }[] = [];
let stopped: string | null = null;
let done = 0;
for (const image of images) {
if (stopped) {
failures.push({ sha256: image.sha256, message: stopped });
} else {
const key = cacheKey(host.id, ctx.options, image.sha256);
try {
const url = (key && cache.get(key)) || (await host.upload(image, ctx)).url;
if (key) cache.set(key, url);
hosted.set(image.sha256, url);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failures.push({ sha256: image.sha256, message });
if (err instanceof HostError && err.status === 507) stopped = message;
}
}
onProgress?.(++done, images.length);
}
return { hosted, failures, allFailed: images.length > 0 && hosted.size === 0 };
}
+8
View File
@@ -0,0 +1,8 @@
import { viaServer, type ImageHost } from "./host";
/** Our server's covers folder; swept after 30 days (spec §4.6). */
export const serverHost: ImageHost = {
id: "server",
lifetime: () => "30 days",
upload: (image, ctx) => viaServer(image, ctx, { host: "store" }),
};
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, test } from "bun:test";
import { catbox } from "../src/core/hosting/catbox";
import { direct } from "../src/core/hosting/direct";
import { HostError, type HostContext, type HostImage } from "../src/core/hosting/host";
import { litterbox } from "../src/core/hosting/litterbox";
import { HOSTS } from "../src/core/hosting/registry";
import { rehostAll } from "../src/core/hosting/rehost";
import { serverHost } from "../src/core/hosting/server";
import { FakeTransport, json, text } from "./helpers/fake-transport";
const image = (n = 1, live = true): HostImage => ({
sourceUrl: `https://swebtoon-phinf.pstatic.net/x/${n}.jpg`, live, bytes: new Uint8Array([n, 2, 3]),
sha256: String(n).repeat(64).slice(0, 64), contentType: "image/jpeg", ext: "jpg",
});
const ctx = (transport: FakeTransport, over: Partial<HostContext> = {}): HostContext =>
({ transport, serverBase: "https://mbseed.example.org", catboxVia: "direct", options: {}, ...over });
const LITTERBOX = "https://litterbox.catbox.moe/resources/internals/api.php";
const CATBOX = "https://catbox.moe/user/api.php";
const UPLOAD = "https://mbseed.example.org/api/upload";
test("lifetimes shown next to each host", () => {
expect(HOSTS.server.lifetime({})).toBe("30 days");
expect(HOSTS.litterbox.lifetime({})).toBe("72 hours");
expect(HOSTS.litterbox.lifetime({ litterboxTime: "12h" })).toBe("12 hours");
expect(HOSTS.catbox.lifetime({})).toBe("permanent");
expect(HOSTS.direct.lifetime({})).toBe("as long as the source keeps it");
});
test("direct: the live source URL; refuses archive-only images", async () => {
const fake = new FakeTransport();
expect(await direct.upload(image(), ctx(fake))).toEqual({ url: image().sourceUrl });
await expect(direct.upload(image(1, false), ctx(fake))).rejects.toBeInstanceOf(HostError);
expect(fake.calls).toHaveLength(0);
});
describe("litterbox", () => {
test("multipart upload of the checked bytes, 72h by default", async () => {
const fake = new FakeTransport().on(LITTERBOX, () => text("https://litter.catbox.moe/abc123.jpg\n"));
expect(await litterbox.upload(image(), ctx(fake))).toEqual({ url: "https://litter.catbox.moe/abc123.jpg" });
const form = fake.calls[0]!.init.body as FormData;
expect(fake.calls[0]!.init.method).toBe("POST");
expect(form.get("reqtype")).toBe("fileupload");
expect(form.get("time")).toBe("72h");
expect((form.get("fileToUpload") as File).size).toBe(3);
});
test("an error body is a HostError", async () => {
const fake = new FakeTransport().on(LITTERBOX, () => text("File too large", 413));
await expect(litterbox.upload(image(), ctx(fake))).rejects.toThrow(/File too large/);
});
});
describe("catbox", () => {
test("direct (userscript): multipart with the userhash", async () => {
const fake = new FakeTransport().on(CATBOX, () => text("https://files.catbox.moe/xyz.jpg"));
const url = await catbox.upload(image(), ctx(fake, { options: { catboxUserhash: "abc123" } }));
expect(url).toEqual({ url: "https://files.catbox.moe/xyz.jpg" });
expect((fake.calls[0]!.init.body as FormData).get("userhash")).toBe("abc123");
});
test("via our server (page): sends the source URL, never bytes; checks the sha256", async () => {
const fake = new FakeTransport().on(UPLOAD, () => json({ url: "https://files.catbox.moe/xyz.jpg", sha256: image().sha256 }));
await catbox.upload(image(), ctx(fake, { catboxVia: "server", options: { catboxUserhash: "abc123" } }));
expect(JSON.parse(fake.calls[0]!.init.body as string)).toEqual({ url: image().sourceUrl, host: "catbox", userhash: "abc123" });
});
});
describe("server storage", () => {
test("posts the source URL with host store", async () => {
const fake = new FakeTransport().on(UPLOAD, () =>
json({ url: `https://mbseed.example.org/covers/${image().sha256}.jpg`, sha256: image().sha256 }));
expect((await serverHost.upload(image(), ctx(fake))).url).toContain("/covers/");
expect(JSON.parse(fake.calls[0]!.init.body as string)).toEqual({ url: image().sourceUrl, host: "store" });
});
test("rejects different bytes", async () => {
const fake = new FakeTransport().on(UPLOAD, () => json({ url: "https://x/covers/y.jpg", sha256: "0".repeat(64) }));
await expect(serverHost.upload(image(), ctx(fake))).rejects.toThrow(/different bytes/);
});
test("storage full is HostError 507", async () => {
const fake = new FakeTransport().on(UPLOAD, () => json({ error: "server storage is full" }, 507));
await expect(serverHost.upload(image(), ctx(fake))).rejects.toMatchObject({ status: 507 });
});
test("no server configured", async () => {
await expect(serverHost.upload(image(), ctx(new FakeTransport(), { serverBase: null }))).rejects.toBeInstanceOf(HostError);
});
});
describe("rehostAll", () => {
test("failures are per image; the cache avoids uploading twice", async () => {
let n = 0;
const fake = new FakeTransport().on(LITTERBOX, () =>
++n === 2 ? text("error", 500) : text(`https://litter.catbox.moe/${n}.jpg`));
const cache = new Map<string, string>();
const first = await rehostAll([image(1), image(2), image(3)], litterbox, ctx(fake), cache);
expect([...first.hosted.keys()]).toEqual([image(1).sha256, image(3).sha256]);
expect(first.failures).toEqual([{ sha256: image(2).sha256, message: expect.stringContaining("litterbox") }]);
expect(first.allFailed).toBe(false);
await rehostAll([image(1)], litterbox, ctx(fake), cache);
expect(fake.calls).toHaveLength(3);
});
test("a full store stops the remaining uploads", async () => {
const fake = new FakeTransport().on(UPLOAD, () => json({ error: "server storage is full" }, 507));
const res = await rehostAll([image(1), image(2)], serverHost, ctx(fake), new Map());
expect(fake.calls).toHaveLength(1);
expect(res.failures).toHaveLength(2);
expect(res.allFailed).toBe(true);
});
});