Skip to content
Open
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
26 changes: 3 additions & 23 deletions app/api/detected-assets/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server"
import fs from "node:fs/promises"
import { safeJoin } from "@/lib/cast/server/safe-join"
import { isENOENT } from "@/lib/cast/server/api-helpers"
import { detectAssetFiles } from "@/lib/cast/server/storage"
import { SLUG_RE } from "@/lib/cast/schemas"

export const runtime = "nodejs"
Expand All @@ -13,11 +11,9 @@ export const runtime = "nodejs"
* Resolver lookup order: png, jpg, jpeg, webp (first hit wins).
*
* Every slug is SLUG_RE-validated before any filesystem call. Lookups go
* through safeJoin('inputs', 'assets', ...).
* through the StorageAdapter via `detectAssetFiles`.
*/

const LOOKUP_ORDER = ["png", "jpg", "jpeg", "webp"] as const

export async function GET(req: Request): Promise<NextResponse> {
const url = new URL(req.url)
const slugsParam = url.searchParams.get("slugs") ?? ""
Expand All @@ -42,23 +38,7 @@ export async function GET(req: Request): Promise<NextResponse> {
}
}

const results: { slug: string; foundFile: string | null }[] = []
for (const slug of slugs) {
let found: string | null = null
for (const ext of LOOKUP_ORDER) {
// TODO(symlink-hardening): re-validate with realpath
const candidate = safeJoin("inputs", "assets", `${slug}.${ext}`)
try {
await fs.access(candidate)
found = `${slug}.${ext}`
break
} catch (err) {
if (!isENOENT(err)) throw err
// miss — try next ext
}
}
results.push({ slug, foundFile: found })
}
const results = await detectAssetFiles(slugs)

return NextResponse.json(results, {
headers: { "Cache-Control": "no-store" },
Expand Down
29 changes: 6 additions & 23 deletions app/api/outputs/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server"
import fs from "node:fs/promises"
import nodePath from "node:path"
import { safeJoin, PathTraversalError } from "@/lib/cast/server/safe-join"
import { readOutputFile } from "@/lib/cast/server/storage"
import { PathTraversalError } from "@/lib/cast/server/safe-join"
import { isENOENT } from "@/lib/cast/server/api-helpers"
Comment thread
arndvs marked this conversation as resolved.

export const runtime = "nodejs"
Expand All @@ -13,7 +12,8 @@ export const runtime = "nodejs"
* prior run; this route is the only way the browser can pull a generated PNG.
*
* Hardening:
* - safeJoin against ROOTS.outputs (rejects `..`, absolute, null bytes)
* - readOutputFile validates segments (rejects `..`, absolute, null bytes,
* backslash-smuggled components) before delegating to the StorageAdapter
* - .png whitelist — anything else 404s, never reveals MIME of other files
* - X-Content-Type-Options: nosniff so a malicious upstream can't trick
* the browser into rendering the bytes as HTML/JS
Expand All @@ -39,28 +39,11 @@ export async function GET(
return new NextResponse(null, { status: 404 })
}

let resolved: string
try {
// TODO(symlink-hardening): re-validate with realpath before readFile.
resolved = safeJoin("outputs", ...segments)
} catch (err) {
if (err instanceof PathTraversalError) {
return new NextResponse(null, { status: 404 })
}
throw err
}

// Defense in depth — safeJoin already rejected absolute/.. but extension
// check ran on the raw segment. Re-check on the resolved path too.
if (nodePath.extname(resolved).toLowerCase() !== ".png") {
return new NextResponse(null, { status: 404 })
}

let bytes: Buffer
try {
bytes = await fs.readFile(resolved)
bytes = await readOutputFile(...segments)
} catch (err) {
if (isENOENT(err)) {
if (err instanceof PathTraversalError || isENOENT(err)) {
return new NextResponse(null, { status: 404 })
}
return new NextResponse(null, { status: 500 })
Expand Down
29 changes: 5 additions & 24 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server"
import fs from "node:fs/promises"
import { safeJoin } from "@/lib/cast/server/safe-join"
import { isENOENT, jsonError } from "@/lib/cast/server/api-helpers"
import { saveAssetFile } from "@/lib/cast/server/storage"
import { jsonError } from "@/lib/cast/server/api-helpers"
import { magicBytesMatch } from "@/lib/cast/server/magic-bytes"
import { UPLOAD_MAX_BYTES, UPLOAD_MAX_DISPLAY } from "@/lib/cast/upload-constraints"
import { SLUG_RE } from "@/lib/cast/schemas"
Expand All @@ -27,7 +26,6 @@ const MIME_TO_EXT: Record<string, "png" | "jpg" | "webp"> = {
"image/jpeg": "jpg",
"image/webp": "webp",
}
const ALL_EXTS = ["png", "jpg", "jpeg", "webp"] as const

export async function POST(req: Request): Promise<NextResponse> {
// Require Content-Length so we can short-circuit oversize bodies before
Expand Down Expand Up @@ -104,31 +102,14 @@ export async function POST(req: Request): Promise<NextResponse> {
])
}

// Ensure inputs/assets/ exists.
// TODO(symlink-hardening): re-validate assetsDir with realpath
const assetsDir = safeJoin("inputs", "assets")
await fs.mkdir(assetsDir, { recursive: true })

// Delete-then-write: clear any existing variant for this slug.
for (const e of ALL_EXTS) {
// TODO(symlink-hardening): re-validate with realpath
const existing = safeJoin("inputs", "assets", `${productSlug}.${e}`)
try {
await fs.unlink(existing)
} catch (err) {
if (!isENOENT(err)) throw err
}
}

// TODO(symlink-hardening): re-validate target with realpath
const target = safeJoin("inputs", "assets", `${productSlug}.${ext}`)
await fs.writeFile(target, bytes)
// Save via StorageAdapter — deletes any existing variant, then writes the new file.
const savedAs = await saveAssetFile(productSlug, ext, bytes)

return NextResponse.json(
{
ok: true,
productSlug,
savedAs: `inputs/assets/${productSlug}.${ext}`,
savedAs,
size: bytes.byteLength,
},
{ headers: { "Cache-Control": "no-store" } },
Expand Down
72 changes: 72 additions & 0 deletions lib/cast/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Server module barrel export — the public API surface.
*
* Everything exported here becomes the Fastify service API when the backend
* migrates from Next.js API routes. Route handlers in `app/api/` are thin
* pass-throughs that map HTTP → these functions → HTTP responses.
*
* Import rules:
* ✅ app/api/* → lib/cast/server/* (routes call server functions)
* ✅ lib/cast/server/* → lib/cast/* (server uses shared schemas/types)
* ❌ app/api/* → node:fs/promises (no direct I/O in route handlers)
* ❌ app/api/* → @azure/* (no direct Azure calls in routes)
* ❌ components/ → lib/cast/server/* (client code never imports server)
Comment on lines +10 to +13
*/

// -- Config (env var accessors) ---------------------------------------------
// `getGenAIMode` is deliberately excluded — use the re-export from
// pipeline/genai which wraps it with the canonical GenAIMode type.
export {
getOpenAIApiKey,
type StorageBackend,
getStorageBackend,
getAzureConnectionString,
isAzureEnabled,
getQdrantUrl,
getQdrantApiKey,
isQdrantEnabled,
getFatigueThreshold,
type AdsProvider,
getAdsProvider,
getApiBaseUrl,
} from "./config"

// -- Helpers ----------------------------------------------------------------
export * from "./api-helpers"
export * from "./magic-bytes"
export * from "./safe-join"
export * from "./retry"

// -- Loaders ----------------------------------------------------------------
export * from "./brand-loader"
export * from "./brief-loader"

// -- Storage ----------------------------------------------------------------
export * from "./storage"
export {
type Container,
type StorageAdapter,
getStorageAdapter,
} from "./storage-adapter"
// AzureBlobAdapter is NOT re-exported — it is lazy-loaded by
// getStorageAdapter() via dynamic import(). Eagerly re-exporting it
// would pull in @azure/storage-blob for every consumer of this barrel.
export type { AzureBlobAdapter } from "./azure-blob-adapter"

// -- Metadata ---------------------------------------------------------------
export * from "./metadata"

// -- NDJSON streaming -------------------------------------------------------
export * from "./ndjson-emit"

// -- Pipeline stages --------------------------------------------------------
export * from "./pipeline/compose"
export * from "./pipeline/compliance"
export * from "./pipeline/genai"
export * from "./pipeline/manifest-builder"
export * from "./pipeline/resize"
export * from "./pipeline/resolve"
export * from "./pipeline/write"

// -- MCP tools --------------------------------------------------------------
export * from "./mcp-tools"
65 changes: 65 additions & 0 deletions lib/cast/server/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@

import path from "node:path"
import { getStorageAdapter } from "@/lib/cast/server/storage-adapter"
import { PathTraversalError } from "@/lib/cast/server/safe-join"
import type { AspectRatio } from "@/lib/cast/schemas"

const ASSET_EXTS = ["png", "jpg", "jpeg", "webp"] as const
type AssetExt = (typeof ASSET_EXTS)[number]

/**
* Scan `inputs/assets/` for a product photo named after `productSlug`.
Expand Down Expand Up @@ -100,3 +102,66 @@ export async function writeReport(
await (await getStorageAdapter()).writeFile("outputs", key, data, "application/json")
return path.posix.join("outputs", key)
}

/**
* Detect which asset files exist for the given product slugs.
* Returns `{ slug, foundFile }` pairs where `foundFile` is the filename
* (e.g. `"slug.png"`) or `null` if no asset was found.
*/
export async function detectAssetFiles(
slugs: string[],
): Promise<{ slug: string; foundFile: string | null }[]> {
const results: { slug: string; foundFile: string | null }[] = []
for (const slug of slugs) {
const found = await findLocalAsset(slug)
results.push({ slug, foundFile: found ? path.posix.basename(found) : null })
}
return results
Comment thread
arndvs marked this conversation as resolved.
}

/**
* Save an uploaded asset file, replacing any existing variant for the slug.
* Deletes all existing extensions first, then writes the new file.
* Returns the repo-relative path (e.g. `"inputs/assets/slug.png"`).
*/
export async function saveAssetFile(
productSlug: string,
ext: AssetExt,
bytes: Uint8Array,
): Promise<string> {
if (!(ASSET_EXTS as readonly string[]).includes(ext)) {
throw new Error(`invalid asset extension "${ext}" — allowed: ${ASSET_EXTS.join(", ")}`)
}
const adapter = await getStorageAdapter()
for (const e of ASSET_EXTS) {
await adapter.deleteFile("inputs", `assets/${productSlug}.${e}`)
}
const key = `assets/${productSlug}.${ext}`
await adapter.writeFile("inputs", key, Buffer.from(bytes))
Comment thread
arndvs marked this conversation as resolved.
Comment thread
arndvs marked this conversation as resolved.
return path.posix.join("inputs", key)
}

/**
* Read a file from the outputs container.
* Validates individual path segments before delegating to the adapter —
* rejects absolute paths, parent traversal, and null bytes.
* Throws if the file does not exist (ENOENT) or the path is invalid.
*/
export async function readOutputFile(...segments: string[]): Promise<Buffer> {
// Reject obviously invalid raw segments before normalization.
for (const seg of segments) {
if (!seg || path.isAbsolute(seg)) {
throw new PathTraversalError(`invalid output path segment: "${seg}"`)
}
}
// Normalize: split on both / and \ so embedded separators can't smuggle
// traversal components past the per-segment check.
const parts = segments.flatMap((s) => s.split(/[/\\]/)).filter(Boolean)
for (const part of parts) {
if (part === "." || part === ".." || part.includes("\0")) {
throw new PathTraversalError(`invalid output path segment: "${part}"`)
}
Comment on lines +157 to +163
}
const key = parts.join("/")
return (await getStorageAdapter()).readFile("outputs", key)
}