Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 3 additions & 63 deletions apps/studio/src/app/api/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,12 @@
// the loaded-checkpoint hint. The browser never talks to ComfyUI directly.

import { readConfig, type ToonyConfig, writeConfig } from "@toony/project-io";
import { probeComfyui } from "@/lib/comfyui-probe";
import { safeErrorMessage } from "@/lib/errors";
import { workspaceRoot } from "@/lib/workspace";

export const dynamic = "force-dynamic";

/** How long to wait for ComfyUI's /system_stats before calling it unreachable. */
const PING_TIMEOUT_MS = 4_000;

interface SavePayload {
comfyui: {
endpoint: string;
Expand All @@ -37,13 +35,6 @@ interface SavePayload {
};
}

/** Connection probe outcome for the status badge. */
interface ConnectionStatus {
state: "reachable" | "unreachable" | "unconfigured";
/** Present only when reachable and the endpoint reported a system summary. */
detail?: string;
}

function badRequest(message: string): Response {
return Response.json({ ok: false, error: message }, { status: 400 });
}
Expand All @@ -68,57 +59,6 @@ function isSavePayload(value: unknown): value is SavePayload {
);
}

/**
* Probe the configured ComfyUI endpoint's `/system_stats`. Returns a coarse
* reachable/unreachable verdict; any network/HTTP error is "unreachable" (the
* page shows that without treating it as a server error). An unset endpoint is
* "unconfigured" so the badge can prompt the operator to set one.
*/
async function probeConnection(endpoint: string | null): Promise<ConnectionStatus> {
if (endpoint === null) return { state: "unconfigured" };
let base: URL;
try {
base = new URL(endpoint);
} catch {
return { state: "unreachable" };
}
// SSRF posture (#82): this is a server-side fetch of the OPERATOR's own
// ComfyUI endpoint, and Studio is a local-first, single-user, localhost-only
// tool — the operator sets and trusts this address (typically 127.0.0.1:8188),
// so the risk is accepted for the intended deployment. As a basic guard we only
// probe http/https (no file:, etc.) and keep the short timeout below; we do NOT
// attempt to enumerate/block internal addresses, which would break the common
// case of pointing at a LAN ComfyUI host.
if (base.protocol !== "http:" && base.protocol !== "https:") {
return { state: "unreachable" };
}
const url = new URL("/system_stats", base);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) return { state: "unreachable" };
// ComfyUI returns a JSON summary; surface a compact, safe detail if present.
let detail: string | undefined;
try {
const stats = (await response.json()) as {
system?: { comfyui_version?: unknown; os?: unknown };
};
const version = stats.system?.comfyui_version;
if (typeof version === "string" && version.length > 0) {
detail = `ComfyUI ${version}`;
}
} catch {
// A reachable endpoint that returns non-JSON is still reachable.
}
return { state: "reachable", detail };
} catch {
return { state: "unreachable" };
} finally {
clearTimeout(timer);
}
}

/** Read the workspace config and probe the configured endpoint. */
export async function GET(): Promise<Response> {
let config: ToonyConfig;
Expand All @@ -130,7 +70,7 @@ export async function GET(): Promise<Response> {
{ status: 500 },
);
}
const connection = await probeConnection(config.comfyui.endpoint);
const connection = await probeComfyui(config.comfyui.endpoint);
return Response.json({ ok: true, config, connection });
}

Expand Down Expand Up @@ -176,6 +116,6 @@ export async function POST(request: Request): Promise<Response> {
);
}

const connection = await probeConnection(config.comfyui.endpoint);
const connection = await probeComfyui(config.comfyui.endpoint);
return Response.json({ ok: true, config, connection });
}
15 changes: 7 additions & 8 deletions apps/studio/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,15 @@
// the server-only `@/lib/workspace` wrapper; no wallet/account/publish surfaces —
// this is a production tool.

import { join } from "node:path";
import Link from "next/link";
import { NewWorkButton } from "@/components/new-work-button";
import { listWorks } from "@/lib/workspace";
import { assetUrl } from "@/lib/project";
import { listWorks, workspaceRoot } from "@/lib/workspace";

// The workspace is scanned from disk per request, so this page must not be cached.
export const dynamic = "force-dynamic";

/** A project-relative cover path becomes a scoped, path-safe asset URL. */
function coverUrl(workId: string, coverImagePath: string | null): string | null {
if (!coverImagePath) return null;
return `/api/asset?work=${encodeURIComponent(workId)}&path=${encodeURIComponent(coverImagePath)}`;
}

/** Human-readable "last edited" from an ISO timestamp (date only, locale-free). */
function formatUpdated(iso: string): string {
const date = new Date(iso);
Expand All @@ -29,6 +25,7 @@ function formatUpdated(iso: string): string {

export default async function LibraryPage() {
const works = await listWorks();
const wsRoot = workspaceRoot();
const totalEpisodes = works.reduce((sum, work) => sum + work.episodeCount, 0);
const totalCuts = works.reduce((sum, work) => sum + work.cutCount, 0);

Expand Down Expand Up @@ -69,7 +66,9 @@ export default async function LibraryPage() {
) : (
<ul className="work-grid" data-testid="work-grid">
{works.map((work) => {
const cover = coverUrl(work.id, work.coverImagePath);
// Reuse the shared, path-safe `assetUrl` (its `resolveWorkAsset`
// pre-check) instead of re-building the URL here (#154).
const cover = assetUrl(work.id, join(wsRoot, work.id), work.coverImagePath);
return (
<li key={work.id}>
<Link
Expand Down
44 changes: 4 additions & 40 deletions apps/studio/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,53 +12,17 @@

import { readConfig } from "@toony/project-io";
import { SettingsForm } from "@/components/settings-form";
import { probeComfyui } from "@/lib/comfyui-probe";
import { workspaceRoot } from "@/lib/workspace";

// The config is read from disk per request; never cache it.
export const dynamic = "force-dynamic";

const PING_TIMEOUT_MS = 4_000;

/**
* Probe the configured ComfyUI `/system_stats` for the initial badge. Mirrors the
* route's probe; kept here so the first paint already shows reachability without a
* client round-trip. Any failure is "unreachable"; an unset endpoint is
* "unconfigured".
*/
async function probe(
endpoint: string | null,
): Promise<{ state: "reachable" | "unreachable" | "unconfigured"; detail?: string }> {
if (endpoint === null) return { state: "unconfigured" };
let base: URL;
try {
base = new URL(endpoint);
} catch {
return { state: "unreachable" };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS);
try {
const response = await fetch(new URL("/system_stats", base), { signal: controller.signal });
if (!response.ok) return { state: "unreachable" };
let detail: string | undefined;
try {
const stats = (await response.json()) as { system?: { comfyui_version?: unknown } };
const version = stats.system?.comfyui_version;
if (typeof version === "string" && version.length > 0) detail = `ComfyUI ${version}`;
} catch {
// Reachable but non-JSON is still reachable.
}
return { state: "reachable", detail };
} catch {
return { state: "unreachable" };
} finally {
clearTimeout(timer);
}
}

export default async function SettingsPage() {
const config = await readConfig(workspaceRoot());
const connection = await probe(config.comfyui.endpoint);
// Single-sourced probe (#154), so the first-paint badge matches /api/config
// exactly — including the http/https SSRF guard the settings page now adopts.
const connection = await probeComfyui(config.comfyui.endpoint);

return (
<div data-testid="studio-settings">
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/app/w/[workId]/episodes/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { LoadError } from "@/components/load-error";
import { TransitionBlock } from "@/components/transition-block";
import {
type CutArt,
FALLBACK_ART,
findEpisodeBundle,
loadWork,
ProjectIoError,
Expand Down Expand Up @@ -130,7 +131,7 @@ export default async function EpisodePreviewPage({
key={key}
cut={cut}
bubbles={bubblesByCut.get(cut.id) ?? []}
art={artByCut.get(cut.id) ?? { src: null, width: 1000, height: 1414 }}
art={artByCut.get(cut.id) ?? FALLBACK_ART}
workId={work.id}
episodeId={episode.id}
/>
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/app/w/[workId]/episodes/[id]/read/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { LoadError } from "@/components/load-error";
import { TransitionBlock } from "@/components/transition-block";
import {
type CutArt,
FALLBACK_ART,
findEpisodeBundle,
loadWork,
ProjectIoError,
Expand Down Expand Up @@ -103,7 +104,7 @@ export default async function EpisodeReaderPage({
key={key}
cut={cut}
bubbles={bubblesByCut.get(cut.id) ?? []}
art={artByCut.get(cut.id) ?? { src: null, width: 1000, height: 1414 }}
art={artByCut.get(cut.id) ?? FALLBACK_ART}
workId={work.id}
episodeId={episode.id}
readOnly
Expand Down
22 changes: 15 additions & 7 deletions apps/studio/src/components/export-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
// verbatim. No state is persisted here; the engine writes the real output on disk.

import type { ExportManifest, ManifestFile } from "@toony/export";
// Browser-safe subpath: plain constants with no Node/canvas imports (#154), so
// the client bundle single-sources them without pulling in the export engine.
import {
DEFAULT_JPEG_QUALITY,
PLATFORM_DEFAULT_WIDTH,
PLOTLINK_DEFAULT_WIDTH,
STITCHED_DEFAULT_WIDTH,
} from "@toony/export/defaults";
import { useCallback, useMemo, useState } from "react";
import { type ConstraintCheck, formatBytes } from "@/lib/export-view";

Expand All @@ -32,32 +40,32 @@ interface TargetSpec {
defaultWidth: number;
}

// Defaults mirror the engine's per-target defaults so the controls open at the
// values the engine would otherwise apply.
// Per-target defaults come from the export engine (#154 single-source), so the
// controls open at exactly the values the engine would otherwise apply.
const TARGETS = [
{
kind: "platform",
title: "Platform sequence",
blurb: "One image per cut, in reading order.",
supportsFormat: true,
supportsQuality: true,
defaultWidth: 1200,
defaultWidth: PLATFORM_DEFAULT_WIDTH,
},
{
kind: "stitched",
title: "Stitched strip",
blurb: "A single tall image: cuts, gutters, transitions, lettering.",
supportsFormat: true,
supportsQuality: true,
defaultWidth: 1200,
defaultWidth: STITCHED_DEFAULT_WIDTH,
},
{
kind: "plotlink",
title: "PlotLink-ready",
blurb: "WebP package (≤20 images, ≤1 MB each) plus generated markdown.",
supportsFormat: false,
supportsQuality: true,
defaultWidth: 800,
defaultWidth: PLOTLINK_DEFAULT_WIDTH,
},
] as const satisfies readonly TargetSpec[];

Expand All @@ -83,9 +91,9 @@ export interface ExportPanelProps {

export function ExportPanel({ workId, episodeId }: ExportPanelProps) {
const [target, setTarget] = useState<TargetKind>("platform");
const [width, setWidth] = useState<number>(1200);
const [width, setWidth] = useState<number>(PLATFORM_DEFAULT_WIDTH);
const [format, setFormat] = useState<"png" | "jpeg">("png");
const [quality, setQuality] = useState<number>(82);
const [quality, setQuality] = useState<number>(DEFAULT_JPEG_QUALITY);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<{ message: string; code?: string } | null>(null);
const [result, setResult] = useState<SuccessResult | null>(null);
Expand Down
29 changes: 29 additions & 0 deletions apps/studio/src/lib/__tests__/comfyui-probe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Shared ComfyUI probe tests (#154 item 8), in the #157 node:test harness.
//
// Both `/api/config` and the settings page call `probeComfyui`, so testing the
// shared helper covers both paths. The SSRF protocol guard + the no-endpoint /
// bad-URL branches short-circuit BEFORE any `fetch`, so they're verifiable here
// with no network; the reachable/unreachable-via-fetch path needs a live server.

import assert from "node:assert/strict";
import { test } from "node:test";
import { probeComfyui } from "../comfyui-probe.js";

test("an unset endpoint is unconfigured", async () => {
assert.deepEqual(await probeComfyui(null), { state: "unconfigured" });
});

test("a malformed endpoint is unreachable (no fetch)", async () => {
assert.deepEqual(await probeComfyui("not a url"), { state: "unreachable" });
});

test("a non-http(s) endpoint is unreachable via the SSRF protocol guard (#154 item 8)", async () => {
// The guard both call paths now share: file:/ftp:/etc. never trigger a fetch.
for (const endpoint of ["file:///etc/passwd", "ftp://internal.host/x", "gopher://127.0.0.1/"]) {
assert.deepEqual(
await probeComfyui(endpoint),
{ state: "unreachable" },
`expected ${endpoint} to be unreachable`,
);
}
});
58 changes: 58 additions & 0 deletions apps/studio/src/lib/comfyui-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Shared ComfyUI reachability probe (#154).
//
// Both the `/api/config` route and the settings page probe the operator's
// configured ComfyUI `/system_stats` for a coarse reachable/unreachable verdict.
// This is the SINGLE implementation both call — including the SSRF protocol guard
// (http/https only) the route had and the settings page previously LACKED, which
// the settings page now adopts as a strict security improvement (#154 item 8).

/** Coarse ComfyUI connection verdict for the status badge. */
export interface ConnectionStatus {
state: "reachable" | "unreachable" | "unconfigured";
detail?: string;
}

const PING_TIMEOUT_MS = 4_000;

/**
* Probe the configured ComfyUI endpoint's `/system_stats`. Any network/HTTP error
* is "unreachable"; an unset endpoint is "unconfigured". A non-http(s) endpoint is
* "unreachable" without a fetch (the SSRF protocol guard).
*/
export async function probeComfyui(endpoint: string | null): Promise<ConnectionStatus> {
if (endpoint === null) return { state: "unconfigured" };
let base: URL;
try {
base = new URL(endpoint);
} catch {
return { state: "unreachable" };
}
// SSRF posture (#82): a server-side fetch of the OPERATOR's own local-first
// ComfyUI endpoint (typically 127.0.0.1:8188), which they set and trust. As a
// basic guard we only probe http/https (no file:, etc.) and keep the short
// timeout below; we do NOT enumerate/block internal addresses, which would
// break the common case of pointing at a LAN ComfyUI host.
if (base.protocol !== "http:" && base.protocol !== "https:") {
return { state: "unreachable" };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS);
try {
const response = await fetch(new URL("/system_stats", base), { signal: controller.signal });
if (!response.ok) return { state: "unreachable" };
// ComfyUI returns a JSON summary; surface a compact, safe detail if present.
let detail: string | undefined;
try {
const stats = (await response.json()) as { system?: { comfyui_version?: unknown } };
const version = stats.system?.comfyui_version;
if (typeof version === "string" && version.length > 0) detail = `ComfyUI ${version}`;
} catch {
// A reachable endpoint that returns non-JSON is still reachable.
}
return { state: "reachable", detail };
} catch {
return { state: "unreachable" };
} finally {
clearTimeout(timer);
}
}
Loading
Loading