From 8c01f849d7134db4e1cf09f1b13fb36e00517662 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 21:55:46 +0200 Subject: [PATCH 01/11] security: isolate PSD design assets from the local app server --- scripts/design-asset-server.mjs | 173 +++++++++++++++++++++++++++ scripts/design-asset-server.test.mjs | 66 ++++++++++ scripts/dev-public.mjs | 85 ++++++++----- vite.config.ts | 60 ++++++++-- 4 files changed, 345 insertions(+), 39 deletions(-) create mode 100644 scripts/design-asset-server.mjs create mode 100644 scripts/design-asset-server.test.mjs diff --git a/scripts/design-asset-server.mjs b/scripts/design-asset-server.mjs new file mode 100644 index 0000000..1d297fe --- /dev/null +++ b/scripts/design-asset-server.mjs @@ -0,0 +1,173 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; + +function extensionForContentType(contentType) { + const normalized = String(contentType || "").toLowerCase(); + if (normalized.includes("jpeg") || normalized.includes("jpg")) return ".jpg"; + if (normalized.includes("webp")) return ".webp"; + return ".png"; +} + +function hasValidToken(value, expectedToken) { + const received = Array.isArray(value) ? value[0] : value || ""; + const left = Buffer.from(String(received)); + const right = Buffer.from(String(expectedToken)); + return left.length === right.length && timingSafeEqual(left, right); +} + +export function createDesignAssetServer({ + token, + maxDesignBytes = 50 * 1024 * 1024, + ttlMs = 30 * 60 * 1000, +} = {}) { + if (!token) throw new Error("Design asset server requires an access token."); + + const designs = new Map(); + let publicBaseUrl = ""; + + function cleanupDesigns() { + const expiresBefore = Date.now() - ttlMs; + for (const [id, item] of designs) { + if (item.createdAt < expiresBefore) designs.delete(id); + } + } + + function setPublicBaseUrl(value) { + const parsed = new URL(String(value || "")); + if (parsed.protocol !== "https:") { + throw new Error("Public design asset URL must use HTTPS."); + } + publicBaseUrl = parsed.href.replace(/\/+$/, ""); + } + + const server = createServer((req, res) => { + cleanupDesigns(); + const requestUrl = new URL(req.url || "/", "http://127.0.0.1"); + const method = req.method || "GET"; + + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Cache-Control", "no-store"); + + if (method === "GET" && requestUrl.pathname.startsWith("/design/")) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + const id = decodeURIComponent(requestUrl.pathname.slice("/design/".length)); + const item = designs.get(id); + if (!item) { + res.statusCode = 404; + res.end("not found"); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", item.contentType); + res.setHeader("Content-Length", item.buffer.byteLength); + res.end(item.buffer); + return; + } + + if (method === "POST" && requestUrl.pathname === "/design") { + if (!hasValidToken(req.headers["x-openmockup-token"], token)) { + res.statusCode = 403; + res.end("forbidden"); + return; + } + if (!publicBaseUrl) { + res.statusCode = 503; + res.end("public asset URL is not ready"); + return; + } + + const contentTypeHeader = req.headers["content-type"] || "application/octet-stream"; + const contentType = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader; + if (!String(contentType).toLowerCase().startsWith("image/")) { + res.statusCode = 415; + res.end("design must be an image"); + return; + } + + const contentLength = Number(req.headers["content-length"] || 0); + if (Number.isFinite(contentLength) && contentLength > maxDesignBytes) { + res.statusCode = 413; + res.end("design is too large"); + return; + } + + const chunks = []; + let receivedBytes = 0; + let tooLarge = false; + + req.on("data", (chunk) => { + if (tooLarge) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + receivedBytes += buffer.byteLength; + if (receivedBytes > maxDesignBytes) { + tooLarge = true; + chunks.length = 0; + return; + } + chunks.push(buffer); + }); + + req.on("end", () => { + if (tooLarge) { + res.statusCode = 413; + res.end("design is too large"); + return; + } + + const id = `${randomUUID()}${extensionForContentType(contentType)}`; + const buffer = Buffer.concat(chunks); + designs.set(id, { buffer, contentType, createdAt: Date.now() }); + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ url: `${publicBaseUrl}/design/${encodeURIComponent(id)}` })); + }); + + req.on("error", () => { + if (!res.headersSent) { + res.statusCode = 500; + res.end("upload failed"); + } + }); + return; + } + + if (requestUrl.pathname === "/design" || requestUrl.pathname.startsWith("/design/")) { + res.statusCode = 405; + res.setHeader("Allow", requestUrl.pathname === "/design" ? "POST" : "GET"); + res.end("method not allowed"); + return; + } + + res.statusCode = 404; + res.end("not found"); + }); + + async function listen(port = 0, host = "127.0.0.1") { + await new Promise((resolve, reject) => { + const handleError = (error) => { + server.off("listening", handleListening); + reject(error); + }; + const handleListening = () => { + server.off("error", handleError); + resolve(); + }; + server.once("error", handleError); + server.once("listening", handleListening); + server.listen(port, host); + }); + + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not resolve design asset server address."); + return `http://${host}:${address.port}`; + } + + async function close() { + if (!server.listening) return; + await new Promise((resolve) => server.close(() => resolve())); + } + + return { close, listen, server, setPublicBaseUrl }; +} diff --git a/scripts/design-asset-server.test.mjs b/scripts/design-asset-server.test.mjs new file mode 100644 index 0000000..3672528 --- /dev/null +++ b/scripts/design-asset-server.test.mjs @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createDesignAssetServer } from "./design-asset-server.mjs"; + +const openServers = []; + +afterEach(async () => { + await Promise.all(openServers.splice(0).map((assetServer) => assetServer.close())); +}); + +async function startServer(options = {}) { + const assetServer = createDesignAssetServer({ token: "test-token", ...options }); + openServers.push(assetServer); + const localUrl = await assetServer.listen(0); + assetServer.setPublicBaseUrl("https://assets.example.test"); + return { assetServer, localUrl }; +} + +describe("isolated design asset server", () => { + it("does not expose an application root and rejects unauthenticated uploads", async () => { + const { localUrl } = await startServer(); + + expect((await fetch(`${localUrl}/`)).status).toBe(404); + expect((await fetch(`${localUrl}/design`, { + method: "POST", + headers: { "Content-Type": "image/png" }, + body: Buffer.from([1, 2, 3]), + })).status).toBe(403); + }); + + it("publishes only token-authorized image assets with public GET access", async () => { + const { localUrl } = await startServer(); + const source = Buffer.from([137, 80, 78, 71]); + const upload = await fetch(`${localUrl}/design`, { + method: "POST", + headers: { + "Content-Type": "image/png", + "X-OpenMockup-Token": "test-token", + }, + body: source, + }); + + expect(upload.status).toBe(200); + const value = await upload.json(); + const publicUrl = new URL(value.url); + expect(publicUrl.origin).toBe("https://assets.example.test"); + expect(publicUrl.pathname).toMatch(/^\/design\/[0-9a-f-]+\.png$/i); + + const asset = await fetch(`${localUrl}${publicUrl.pathname}`); + expect(asset.status).toBe(200); + expect(asset.headers.get("access-control-allow-origin")).toBe("*"); + expect(Buffer.from(await asset.arrayBuffer())).toEqual(source); + }); + + it("enforces the configured upload size limit", async () => { + const { localUrl } = await startServer({ maxDesignBytes: 3 }); + const response = await fetch(`${localUrl}/design`, { + method: "POST", + headers: { + "Content-Type": "image/png", + "X-OpenMockup-Token": "test-token", + }, + body: Buffer.from([1, 2, 3, 4]), + }); + expect(response.status).toBe(413); + }); +}); diff --git a/scripts/dev-public.mjs b/scripts/dev-public.mjs index 6912794..bf0a8d2 100644 --- a/scripts/dev-public.mjs +++ b/scripts/dev-public.mjs @@ -1,10 +1,15 @@ +import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; import { appendFileSync, existsSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import process from "node:process"; +import { createDesignAssetServer } from "./design-asset-server.mjs"; const APP_PORT = String(process.env.OPENMOCKUP_PORT || process.env.PORT || "5173"); +const parsedAppPort = Number(APP_PORT); +const ASSET_PORT = String(process.env.OPENMOCKUP_ASSET_PORT || (Number.isFinite(parsedAppPort) ? parsedAppPort + 1 : 5174)); const LOCAL_URL = (process.env.OPENMOCKUP_LOCAL_URL || `http://127.0.0.1:${APP_PORT}`).replace(/\/+$/, ""); +const ASSET_LOCAL_URL = `http://127.0.0.1:${ASSET_PORT}`; const CLOUD_FLARED = findCloudflared(); const PID_FILE = join(process.cwd(), ".openmockup-pids.bat"); const URL_FILE = join(process.cwd(), ".openmockup-public-url.txt"); @@ -16,6 +21,15 @@ const TUNNEL_CONNECT_WAIT_MS = Number(process.env.OPENMOCKUP_TUNNEL_CONNECT_WAIT const RETRY_MS = Number(process.env.OPENMOCKUP_RETRY_MS || 750); const OPEN_DELAY_MS = Number(process.env.OPENMOCKUP_OPEN_DELAY_MS || 1500); const TUNNEL_PROTOCOL = process.env.OPENMOCKUP_TUNNEL_PROTOCOL || "http2"; +const MAX_DESIGN_BYTES = Math.max(1, Number(process.env.OPENMOCKUP_MAX_DESIGN_MB || 50)) * 1024 * 1024; +const DESIGN_TTL_MS = Number(process.env.OPENMOCKUP_DESIGN_TTL_MS || 30 * 60 * 1000); +const ASSET_TOKEN = randomBytes(32).toString("hex"); + +const assetServer = createDesignAssetServer({ + token: ASSET_TOKEN, + maxDesignBytes: MAX_DESIGN_BYTES, + ttlMs: DESIGN_TTL_MS, +}); let viteProcess; let tunnelProcess; @@ -103,13 +117,14 @@ function stopAll(code = 0) { if (viteProcess && !viteProcess.killed) viteProcess.kill("SIGTERM"); if (tunnelProcess && !tunnelProcess.killed) tunnelProcess.kill("SIGTERM"); + void assetServer.close().catch(() => {}); removeRuntimeFiles(); setTimeout(() => process.exit(code), 250); } function printCloudflaredHelp() { - console.error(`\n[openmockup] cloudflared was not found.\n\nInstall it first, then run this command again:\n\nWindows:\n winget install Cloudflare.cloudflared\n\nmacOS:\n brew install cloudflared\n\nManual fallback:\n 1. npm run dev\n 2. cloudflared tunnel --url ${LOCAL_URL}\n 3. restart Vite with OPENMOCKUP_PUBLIC_BASE_URL=\n`); + console.error(`\n[openmockup] cloudflared was not found.\n\nInstall it first, then run this command again:\n\nWindows:\n winget install Cloudflare.cloudflared\n\nmacOS:\n brew install cloudflared\n\nThen use:\n npm run dev:public\n\nDo not point a public tunnel directly at the Vite app port. OpenMockup Studio intentionally tunnels only its isolated temporary design-asset server.\n`); } function sleep(ms) { @@ -163,14 +178,16 @@ function startVite(publicBaseUrl) { return false; } - log(`Using public Photopea asset base: ${publicBaseUrl}`); - log(`The app UI will open locally: ${LOCAL_URL}`); + log(`Using isolated public Photopea asset base: ${publicBaseUrl}`); + log(`The app UI stays local: ${LOCAL_URL}`); viteProcess = spawn(process.execPath, [viteBin, "--host", "127.0.0.1", "--port", APP_PORT, "--strictPort"], { stdio: "inherit", env: { ...process.env, OPENMOCKUP_PUBLIC_BASE_URL: publicBaseUrl, + OPENMOCKUP_ASSET_SERVER_URL: ASSET_LOCAL_URL, + OPENMOCKUP_ASSET_TOKEN: ASSET_TOKEN, }, }); @@ -184,6 +201,7 @@ function startVite(publicBaseUrl) { async function startPublicApp(publicBaseUrl) { if (publicUrlStarted) return; publicUrlStarted = true; + assetServer.setPublicBaseUrl(publicBaseUrl); if (!startVite(publicBaseUrl)) return; @@ -204,6 +222,7 @@ async function startPublicApp(publicBaseUrl) { writeRuntimeFiles(publicBaseUrl); log(`Public Photopea asset base: ${publicBaseUrl}`); log(`OpenMockup Studio app: ${LOCAL_URL}`); + log("Only /design/ assets are exposed through the tunnel; the UI remains localhost-only."); log("Keep this terminal open while using PSD/Photopea mode."); await sleep(OPEN_DELAY_MS); @@ -233,35 +252,43 @@ function handleTunnelOutput(chunk) { void startPublicApp(publicBaseUrl); } -removeRuntimeFiles(); -log("Starting Cloudflare Tunnel for Photopea asset loading..."); -log(`Tunnel target: ${LOCAL_URL}`); -log(`Tunnel protocol: ${TUNNEL_PROTOCOL}`); - -// The Cloudflare URL is only used as a public asset URL for Photopea. -// The browser UI opens locally on 127.0.0.1 to avoid trycloudflare DNS/browser issues. -tunnelProcess = spawn(CLOUD_FLARED, ["tunnel", "--url", LOCAL_URL, "--protocol", TUNNEL_PROTOCOL, "--no-autoupdate"], { - stdio: ["ignore", "pipe", "pipe"], -}); +async function main() { + removeRuntimeFiles(); + const listeningUrl = await assetServer.listen(Number(ASSET_PORT)); + log(`Isolated design asset server ready: ${listeningUrl}`); + log("Starting Cloudflare Tunnel for Photopea asset loading..."); + log(`Tunnel target: ${ASSET_LOCAL_URL} (design assets only)`); + log(`Local UI target: ${LOCAL_URL} (never tunneled)`); + log(`Tunnel protocol: ${TUNNEL_PROTOCOL}`); + + tunnelProcess = spawn(CLOUD_FLARED, ["tunnel", "--url", ASSET_LOCAL_URL, "--protocol", TUNNEL_PROTOCOL, "--no-autoupdate"], { + stdio: ["ignore", "pipe", "pipe"], + }); -tunnelProcess.stdout.on("data", handleTunnelOutput); -tunnelProcess.stderr.on("data", handleTunnelOutput); + tunnelProcess.stdout.on("data", handleTunnelOutput); + tunnelProcess.stderr.on("data", handleTunnelOutput); -tunnelProcess.on("error", (error) => { - if (error && error.code === "ENOENT") { - printCloudflaredHelp(); - } else { - console.error("[openmockup] Could not start cloudflared:", error); - } - stopAll(1); -}); + tunnelProcess.on("error", (error) => { + if (error && error.code === "ENOENT") { + printCloudflaredHelp(); + } else { + console.error("[openmockup] Could not start cloudflared:", error); + } + stopAll(1); + }); -tunnelProcess.on("exit", (code) => { - if (!shuttingDown) { - console.error("[openmockup] Cloudflare Tunnel stopped. PSD/Photopea asset loading is no longer available."); - stopAll(code ?? 1); - } -}); + tunnelProcess.on("exit", (code) => { + if (!shuttingDown) { + console.error("[openmockup] Cloudflare Tunnel stopped. PSD/Photopea asset loading is no longer available."); + stopAll(code ?? 1); + } + }); +} process.on("SIGINT", () => stopAll(0)); process.on("SIGTERM", () => stopAll(0)); + +main().catch((error) => { + console.error("[openmockup] Could not start isolated PSD mode:", error); + stopAll(1); +}); diff --git a/vite.config.ts b/vite.config.ts index 7dcd825..46f3e56 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,7 @@ const MAX_DESIGN_BYTES = Math.max(1, Number(process.env.OPENMOCKUP_MAX_DESIGN_MB const ALLOW_PUBLIC_UPLOADS = process.env.OPENMOCKUP_ALLOW_PUBLIC_UPLOADS === "1"; const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); -function normalizePublicBaseUrl(value: string | undefined): string | null { +function normalizeUrl(value: string | undefined): string | null { if (!value) return null; return value.replace(/\/+$/, ""); } @@ -54,9 +54,11 @@ function acceptsUpload(req: IncomingMessage): boolean { function openMockupDesignServer(): Plugin { const designs = new Map(); - const publicBaseUrl = normalizePublicBaseUrl( + const publicBaseUrl = normalizeUrl( process.env.OPENMOCKUP_PUBLIC_BASE_URL || process.env.VITE_OPENMOCKUP_PUBLIC_BASE_URL, ); + const isolatedAssetServerUrl = normalizeUrl(process.env.OPENMOCKUP_ASSET_SERVER_URL); + const isolatedAssetToken = process.env.OPENMOCKUP_ASSET_TOKEN || ""; function cleanupDesigns(): void { const expiresBefore = Date.now() - DESIGN_TTL_MS; @@ -65,6 +67,35 @@ function openMockupDesignServer(): Plugin { } } + async function forwardToIsolatedAssetServer(buffer: Buffer, contentType: string): Promise<{ status: number; contentType: string; body: string }> { + if (!isolatedAssetServerUrl || !isolatedAssetToken) { + return { status: 500, contentType: "text/plain", body: "Isolated asset server is not configured." }; + } + + try { + const response = await fetch(`${isolatedAssetServerUrl}/design`, { + method: "POST", + headers: { + "Content-Type": contentType, + "Content-Length": String(buffer.byteLength), + "X-OpenMockup-Token": isolatedAssetToken, + }, + body: buffer, + }); + return { + status: response.status, + contentType: response.headers.get("Content-Type") || "text/plain", + body: await response.text(), + }; + } catch (error) { + return { + status: 502, + contentType: "text/plain", + body: error instanceof Error ? `Isolated asset server failed: ${error.message}` : "Isolated asset server failed.", + }; + } + } + function middleware(req: IncomingMessage, res: ServerResponse, next: () => void): void { const url = req.url ?? "/"; @@ -114,12 +145,24 @@ function openMockupDesignServer(): Plugin { req.on("end", () => { if (tooLarge) return; - const contentType = req.headers["content-type"] ?? "application/octet-stream"; - const normalizedContentType = Array.isArray(contentType) ? contentType[0] : contentType; - const id = `${randomUUID()}${extensionForContentType(normalizedContentType)}`; + const contentTypeHeader = req.headers["content-type"] ?? "application/octet-stream"; + const contentType = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader; + const buffer = Buffer.concat(chunks); + + if (isolatedAssetServerUrl) { + void forwardToIsolatedAssetServer(buffer, contentType).then((forwarded) => { + if (res.writableEnded) return; + res.statusCode = forwarded.status; + res.setHeader("Content-Type", forwarded.contentType); + res.end(forwarded.body); + }); + return; + } + + const id = `${randomUUID()}${extensionForContentType(contentType)}`; designs.set(id, { - buffer: Buffer.concat(chunks), - contentType: normalizedContentType, + buffer, + contentType, createdAt: Date.now(), }); const path = `/__openmockup/design/${id}`; @@ -175,9 +218,6 @@ export default defineConfig(({ mode }) => { base: isStaticDemo ? normalizeBasePath(process.env.OPENMOCKUP_BASE_PATH ?? "/OpenMockup-Studio/") : "/", - server: { - allowedHosts: [".trycloudflare.com"], - }, plugins: isStaticDemo ? [react()] : [react(), openMockupDesignServer()], }; }); From 9f5a8bf47f39e12252e507d8e73f150bb459f218 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 21:56:58 +0200 Subject: [PATCH 02/11] ci: harden release provenance and add browser smoke coverage --- .github/workflows/ci.yml | 53 +++++++++++++++++-- .github/workflows/release.yml | 34 +++++++++--- scripts/e2e-demo.mjs | 99 +++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 scripts/e2e-demo.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 609a9bc..d32f8c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - "release/**" + - "hardening/**" pull_request: permissions: @@ -16,8 +17,22 @@ concurrency: jobs: check: - runs-on: ubuntu-latest - timeout-minutes: 10 + name: Check (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + node: 20.19.0 + label: ubuntu-node20 + - os: ubuntu-latest + node: 22.19.0 + label: ubuntu-node22 + - os: windows-latest + node: 22.19.0 + label: windows-node22 steps: - name: Checkout uses: actions/checkout@v7 @@ -25,7 +40,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: npm - name: Install dependencies @@ -36,3 +51,35 @@ jobs: - name: Build static demo run: npm run build:demo + + browser-smoke: + name: Browser export smoke + needs: check + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 22.19.0 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build demo at root path + run: npm run build:demo + env: + OPENMOCKUP_BASE_PATH: / + + - name: Install pinned browser test runtime + run: npm install --no-save --package-lock=false playwright@1.55.0 + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run browser export smoke + run: node scripts/e2e-demo.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce70865..2740d2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,14 +12,19 @@ jobs: windows-portable: runs-on: windows-latest timeout-minutes: 25 + env: + CLOUDFLARED_VERSION: "2026.8.3" + CLOUDFLARED_WINDOWS_AMD64_SHA256: "83e726ed18ea78c5ad5213c4c3a3a27051393950d2bc8ed4de69bec12d14eaae" steps: - name: Checkout uses: actions/checkout@v7 + with: + fetch-depth: 1 - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 22.19.0 cache: npm - name: Install dependencies @@ -28,7 +33,7 @@ jobs: - name: Verify project run: npm run check - - name: Resolve and verify release version + - name: Resolve and verify release source shell: pwsh run: | $package = Get-Content package.json -Raw | ConvertFrom-Json @@ -37,7 +42,17 @@ jobs: if ($env:GITHUB_REF_NAME -ne $expectedBranch) { throw "Release branch $env:GITHUB_REF_NAME does not match package.json version $expectedBranch" } + + git fetch origin main --depth=1 + if ($LASTEXITCODE -ne 0) { throw "Could not fetch main for release source verification." } + $mainSha = (git rev-parse FETCH_HEAD).Trim() + $headSha = (git rev-parse HEAD).Trim() + if ($headSha -ne $mainSha) { + throw "Release branch must point at the current main commit. release=$headSha main=$mainSha" + } + "RELEASE_TAG=$releaseTag" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_SHA=$headSha" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Assemble portable Windows package shell: pwsh @@ -76,9 +91,16 @@ jobs: Copy-Item "packaging\windows\start-openmockup-psd.bat" (Join-Path $stageRoot "start-openmockup-psd.bat") Copy-Item "packaging\windows\README.txt" (Join-Path $stageRoot "README-PORTABLE.txt") - Invoke-WebRequest ` - -Uri "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe" ` - -OutFile (Join-Path $stageRoot "tools\cloudflared.exe") + $cloudflaredPath = Join-Path $stageRoot "tools\cloudflared.exe" + $cloudflaredUrl = "https://github.com/cloudflare/cloudflared/releases/download/$env:CLOUDFLARED_VERSION/cloudflared-windows-amd64.exe" + Invoke-WebRequest -Uri $cloudflaredUrl -OutFile $cloudflaredPath + + $cloudflaredHash = (Get-FileHash $cloudflaredPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($cloudflaredHash -ne $env:CLOUDFLARED_WINDOWS_AMD64_SHA256) { + throw "cloudflared checksum mismatch. expected=$env:CLOUDFLARED_WINDOWS_AMD64_SHA256 actual=$cloudflaredHash" + } + & $cloudflaredPath --version + if ($LASTEXITCODE -ne 0) { throw "Pinned cloudflared binary did not execute successfully." } $zipPath = Join-Path $env:GITHUB_WORKSPACE "$packageName.zip" Remove-Item $zipPath -Force -ErrorAction SilentlyContinue @@ -104,6 +126,6 @@ jobs: "OpenMockup-Studio-Windows-x64.zip" ` "OpenMockup-Studio-Windows-x64.zip.sha256" ` --repo $env:GITHUB_REPOSITORY ` - --target main ` + --target $env:RELEASE_SHA ` --title "OpenMockup Studio $env:RELEASE_TAG" ` --generate-notes diff --git a/scripts/e2e-demo.mjs b/scripts/e2e-demo.mjs new file mode 100644 index 0000000..e6b83c2 --- /dev/null +++ b/scripts/e2e-demo.mjs @@ -0,0 +1,99 @@ +import { spawn } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; +import { chromium } from "playwright"; + +const HOST = "127.0.0.1"; +const PORT = 4173; +const BASE_URL = `http://${HOST}:${PORT}`; +const viteBin = join(process.cwd(), "node_modules", "vite", "bin", "vite.js"); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForServer(timeoutMs = 15_000) { + const startedAt = Date.now(); + let lastError = "not ready"; + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(BASE_URL, { signal: AbortSignal.timeout(1500) }); + if (response.status < 500) return; + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await sleep(250); + } + throw new Error(`Vite preview did not become ready: ${lastError}`); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const previewProcess = spawn(process.execPath, [ + viteBin, + "preview", + "--host", + HOST, + "--port", + String(PORT), + "--strictPort", +], { + stdio: ["ignore", "pipe", "pipe"], +}); + +previewProcess.stdout.on("data", (chunk) => process.stdout.write(chunk)); +previewProcess.stderr.on("data", (chunk) => process.stderr.write(chunk)); + +let browser; +try { + await waitForServer(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ acceptDownloads: true }); + + await page.goto(BASE_URL, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "OpenMockup Studio" }).waitFor(); + await page.getByRole("button", { name: "Try sample project" }).click(); + await page.getByText(/2 designs loaded/).waitFor(); + await page.getByRole("button", { name: "Use my files" }).click(); + + const fileInputs = page.locator('input[type="file"]'); + await fileInputs.nth(0).setInputFiles(join(process.cwd(), "public", "examples", "sample-poster-mockup.png")); + await fileInputs.nth(1).setInputFiles([ + join(process.cwd(), "public", "examples", "sample-design-sun.png"), + join(process.cwd(), "public", "examples", "sample-design-leaf.png"), + ]); + + await page.getByText(/2 designs loaded/).waitFor(); + const refreshButton = page.getByRole("button", { name: "Refresh Preview" }); + await refreshButton.waitFor({ state: "visible" }); + await refreshButton.click(); + await page.locator('img[alt="Generated mockup preview"]').waitFor({ state: "visible" }); + + const presetDownloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export Preset JSON" }).click(); + const presetDownload = await presetDownloadPromise; + const presetPath = await presetDownload.path(); + assert(presetPath, "Preset download did not produce a file."); + assert(presetDownload.suggestedFilename().endsWith(".json"), "Preset download filename is not JSON."); + assert((await stat(presetPath)).size > 20, "Preset download is unexpectedly empty."); + + const exportButton = page.getByRole("button", { name: /Export All/ }); + await exportButton.waitFor({ state: "visible" }); + const zipDownloadPromise = page.waitForEvent("download", { timeout: 30_000 }); + await exportButton.click(); + const zipDownload = await zipDownloadPromise; + const zipPath = await zipDownload.path(); + assert(zipPath, "Batch export did not produce a ZIP file."); + assert(zipDownload.suggestedFilename().endsWith(".zip"), "Batch export filename is not a ZIP."); + assert((await stat(zipPath)).size > 100, "Batch ZIP is unexpectedly empty."); + + await page.getByText(/2 mockups exported|2 mockup/i).waitFor({ timeout: 10_000 }).catch(() => {}); + console.log("Browser smoke passed: file selection, preview, preset download and batch ZIP export."); +} finally { + if (browser) await browser.close(); + if (!previewProcess.killed) previewProcess.kill("SIGTERM"); +} From 974ef0b15fc78a0af1b0af2475359680785a7b09 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 21:58:23 +0200 Subject: [PATCH 03/11] perf: bound batch preview memory after ZIP export --- src/components/PreviewPane.tsx | 11 ++++++++--- src/lib/export/download.test.ts | 21 ++++++++++++++++++++- src/lib/export/download.ts | 25 ++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx index 0bfd34a..95e5f92 100644 --- a/src/components/PreviewPane.tsx +++ b/src/components/PreviewPane.tsx @@ -86,6 +86,7 @@ export function PreviewPane({ const modeLabel = mockupKind === "image" ? "Image Mockups" : mockupKind === "psd" ? "PSD Mockups" : "Mockups"; const activeMockup = mockups.find((mockup) => mockup.id === activeMockupId) || mockups[0]; const activeDesign = designs.find((design) => design.id === activeDesignId) || designs[0]; + const visiblePreviewGallery = previewGallery.filter((item) => item.url).slice(0, 12); async function copyErrors(): Promise { const text = batchErrors.map((error, index) => `${index + 1}. ${error.mockupName} + ${error.designName}: ${error.message}`).join("\n"); @@ -206,14 +207,18 @@ export function PreviewPane({ - {previewGallery.length ? ( + {visiblePreviewGallery.length ? (

Batch Preview Grid

- {previewGallery.length} rendered + + {previewGallery.length > visiblePreviewGallery.length + ? `${visiblePreviewGallery.length} shown of ${previewGallery.length} rendered` + : `${visiblePreviewGallery.length} rendered`} +
- {previewGallery.map((item) => ( + {visiblePreviewGallery.map((item) => (
{item.fileName}
{item.fileName}
diff --git a/src/lib/export/download.test.ts b/src/lib/export/download.test.ts index e48f40e..fb8d606 100644 --- a/src/lib/export/download.test.ts +++ b/src/lib/export/download.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { makeUniqueArchivePaths, renderFileName, slugify } from "./download"; +import { makeUniqueArchivePaths, releaseNonGalleryResults, renderFileName, slugify } from "./download"; describe("export file names", () => { it("creates portable slugs", () => { @@ -54,4 +54,23 @@ describe("export file names", () => { expect(makeUniqueArchivePaths(["Design.PNG", "design.png"])) .toEqual(["Design.PNG", "design-2.png"]); }); + + it("releases non-gallery blobs without changing exported item counts", () => { + const revoked: string[] = []; + const results = Array.from({ length: 4 }, (_, index) => ({ + fileName: `${index}.png`, + blob: new Blob([`image-${index}`], { type: "image/png" }), + url: `blob:test-${index}`, + })); + + releaseNonGalleryResults(results, 2, (url) => revoked.push(url)); + + expect(results).toHaveLength(4); + expect(results[0].blob.size).toBeGreaterThan(0); + expect(results[1].url).toBe("blob:test-1"); + expect(results[2].blob.size).toBe(0); + expect(results[2].url).toBe(""); + expect(results[3].blob.size).toBe(0); + expect(revoked).toEqual(["blob:test-2", "blob:test-3"]); + }); }); diff --git a/src/lib/export/download.ts b/src/lib/export/download.ts index 777298c..7addd1c 100644 --- a/src/lib/export/download.ts +++ b/src/lib/export/download.ts @@ -9,6 +9,8 @@ export interface FileNameContext { format?: string; } +export const DEFAULT_BATCH_GALLERY_LIMIT = 12; + export function downloadBlob(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -64,6 +66,23 @@ export function makeUniqueArchivePaths(paths: string[]): string[] { }); } +export function releaseNonGalleryResults( + results: ExportResult[], + keepCount = DEFAULT_BATCH_GALLERY_LIMIT, + revokeObjectUrl: (url: string) => void = (url) => URL.revokeObjectURL(url), +): void { + const safeKeepCount = Math.max(0, Math.floor(keepCount)); + for (let index = safeKeepCount; index < results.length; index += 1) { + const result = results[index]; + if (result.url) revokeObjectUrl(result.url); + results[index] = { + ...result, + blob: new Blob([], { type: result.blob.type }), + url: "", + }; + } +} + export async function downloadZip(results: ExportResult[], zipName: string, errors: BatchError[] = []): Promise { const zip = new JSZip(); const archivePaths = makeUniqueArchivePaths(results.map((result) => result.fileName)); @@ -89,8 +108,12 @@ export async function downloadZip(results: ExportResult[], zipName: string, erro ]; zip.file("_openmockup-report.csv", reportRows.join("\n")); - const blob = await zip.generateAsync({ type: "blob" }); + const blob = await zip.generateAsync({ type: "blob", streamFiles: true }); downloadBlob(blob, zipName.endsWith(".zip") ? zipName : `${zipName}.zip`); + + // Keep only a small number of decoded gallery candidates alive after the ZIP is ready. + // The array length stays unchanged so status/history counts remain accurate. + releaseNonGalleryResults(results); } export function savePreset(settings: MockupSettings, name = "openmockup-preset"): void { From 15245b47ce2beca2326034a1d8b8159657712f60 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 22:00:42 +0200 Subject: [PATCH 04/11] fix: use a fetch-compatible body for isolated asset forwarding --- vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 46f3e56..6391bf3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -80,7 +80,7 @@ function openMockupDesignServer(): Plugin { "Content-Length": String(buffer.byteLength), "X-OpenMockup-Token": isolatedAssetToken, }, - body: buffer, + body: new Uint8Array(buffer), }); return { status: response.status, From dfd4d5b3c16bcf9e97fbfbaa6fefef4e5165c1d3 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 22:02:06 +0200 Subject: [PATCH 05/11] docs: align privacy, release, and repository hardening guidance --- .env.example | 18 +++++++++++----- CHANGELOG.md | 24 ++++++++++++++++++++- README.md | 40 +++++++++++++++++------------------ docs/ARCHITECTURE.md | 42 +++++++++++++++++++++++++++++++------ docs/RELEASING.md | 24 ++++++++++++++------- docs/REPOSITORY_SETTINGS.md | 23 ++++++++++++++++++++ docs/ROADMAP.md | 6 ++++-- 7 files changed, 136 insertions(+), 41 deletions(-) create mode 100644 docs/REPOSITORY_SETTINGS.md diff --git a/.env.example b/.env.example index 686bbd6..df2b64b 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,17 @@ -# Optional: public base URL used when Photopea must load temporary design assets -# Example for a tunnel or VPS reverse proxy: -# OPENMOCKUP_PUBLIC_BASE_URL=https://openmockup.example.com +# PSD / Photopea mode +# `npm run dev:public` creates a separate localhost-only asset server and tunnels +# only that asset server. The OpenMockup Studio Vite UI remains on 127.0.0.1. +# These internal values are normally generated by scripts/dev-public.mjs and +# should not be set manually for the standard local workflow. +# OPENMOCKUP_ASSET_PORT=5174 +# OPENMOCKUP_ASSET_SERVER_URL=http://127.0.0.1:5174 +# OPENMOCKUP_ASSET_TOKEN= +# OPENMOCKUP_PUBLIC_BASE_URL=https://example.trycloudflare.com -# Optional limits for the temporary Vite design endpoint used by PSD/Photopea mode +# Limits for temporary design assets exposed to Photopea. OPENMOCKUP_MAX_DESIGN_MB=50 OPENMOCKUP_DESIGN_TTL_MS=1800000 -# Keep disabled for local/tunnel use. Enable only behind your own access controls. + +# Legacy/self-hosted Vite design endpoint only. Keep disabled unless the entire +# deployment is protected by authentication, a private network, or equivalent controls. OPENMOCKUP_ALLOW_PUBLIC_UPLOADS=0 diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe90d5..6c3b1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The project follows a lightweight changelog format. ## Unreleased +### Security and privacy + +- Isolated PSD/Photopea design assets from the local Vite application server. `npm run dev:public` now tunnels only a dedicated asset server on a separate localhost port; the OpenMockup UI itself is never exposed through the temporary Cloudflare Tunnel. +- Added a per-run cryptographically random token between the localhost Vite proxy and the isolated asset server. Public access is limited to unpredictable `GET /design/` asset URLs. +- Restricted the isolated asset server to image uploads, bounded upload size, short-lived in-memory assets, and a minimal route surface. + +### Reliability and release hardening + +- Expanded CI to Node.js 20.19, Node.js 22.19, and Windows Node.js 22.19. +- Added a Chromium browser smoke test covering real file selection, preview rendering, preset download, and batch ZIP export. +- Release branches must now point at the exact current `main` commit before packaging; release tags target the verified commit SHA instead of a mutable branch name. +- Pinned the bundled Windows `cloudflared` binary to an explicit version and SHA-256 checksum instead of downloading `latest` without verification. + +### Performance + +- Bounded retained batch-preview memory after ZIP creation. Only the first 12 rendered previews keep their Blob/Object URL; additional completed results are released after the archive is generated while export/history counts remain intact. +- Enabled JSZip streaming mode during ZIP generation. + +### Maintenance + +- Updated `@types/node` to 26.4.0 and `@vitejs/plugin-react` to 6.1.1. + ## 0.19.0 - 2026-08-27 ### Editing and demo @@ -38,7 +60,7 @@ The project follows a lightweight changelog format. ### Repository and contributor experience - Refreshed the README around the batch-mockup use case and faster onboarding. -- Added a single npm run check quality gate used locally and in CI. +- Added a single `npm run check` quality gate used locally and in CI. - Added contribution, security, architecture, roadmap, issue, and pull-request guidance. - Grouped GitHub Actions dependency updates to reduce maintenance noise. - Improved page title and social/SEO metadata for hosted deployments. diff --git a/README.md b/README.md index fe357a7..ffb00ee 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ### [▶ Try OpenMockup Studio in your browser](https://slp-dev1.github.io/OpenMockup-Studio/) -The public demo needs no installation and supports PNG, JPG, and WebP mockups. Click **Try sample project** to see it working immediately. Files stay in the browser. PSD Smart Objects require the local version because that workflow uses Photopea and a temporary asset endpoint. +The public demo needs no installation and supports PNG, JPG, and WebP mockups. Click **Try sample project** to see it working immediately. Files stay in the browser. PSD Smart Objects require the local version because that workflow uses Photopea and a temporary public asset URL. ![OpenMockup Studio interface](docs/screenshot.png) @@ -29,6 +29,7 @@ OpenMockup Studio is built for Etsy, WooCommerce, marketplace sellers, artists, - **Visual placement editor:** move, scale, rotate, anchor, fit, adjust opacity, or drag perspective corners. - **Marketplace-ready exports:** crop, resize, watermark, rename, convert, and ZIP results. - **Collision-safe batches:** duplicate output names are preserved automatically instead of being overwritten. +- **Bounded batch gallery:** large exports do not keep every rendered image alive after ZIP creation. - **Local-first workflow:** flat-image rendering stays in your browser. - **Self-hostable:** no account or hosted service is required. @@ -50,10 +51,10 @@ The demo intentionally disables PSD uploads. Use the Windows package or develope | Launcher | Use it for | | --- | --- | | `start-openmockup.bat` | PNG, JPG, and WebP mockups; local image-mode server | -| `start-openmockup-psd.bat` | PSD mode with Photopea and the bundled temporary asset tunnel helper | +| `start-openmockup-psd.bat` | PSD mode with Photopea and an isolated temporary design-asset tunnel | | `stop.bat` | Stop a remaining local server/tunnel process | -The portable package includes its own runtime, dependencies, and PSD-mode tunnel helper. **You do not need to install Node.js, npm, or cloudflared.** The app opens at `http://127.0.0.1:5173`; keep the launcher window open while you work. +The portable package includes its own runtime, dependencies, and pinned PSD-mode tunnel helper. **You do not need to install Node.js, npm, or cloudflared.** The app opens at `http://127.0.0.1:5173`; keep the launcher window open while you work. ### Developers / source install @@ -70,6 +71,8 @@ Source development requires Node.js 20.19+ (Node 22 recommended). For PSD suppor npm run dev:public ``` +`dev:public` keeps Vite on localhost and starts a second, minimal localhost asset server. Cloudflare tunnels only that second port. The browser posts designs to the local Vite proxy; Vite forwards them to the isolated server with a per-run random token. Photopea receives only the resulting temporary public design URL. + ## Typical workflow 1. Add one or more PSD, PNG, JPG, or WebP mockups. @@ -87,7 +90,8 @@ npm run dev:public | PSD Smart Objects | — | Yes | | 4-corner perspective | Yes | — | | Rendering | Browser Canvas | Photopea iframe | -| Public asset URL needed | No | Yes | +| Public asset URL needed | No | Yes, temporary design asset only | +| App UI publicly tunneled | No | No | | Batch ZIP export | Yes | Yes | | Account required | No | No | | Public demo | Yes | No | @@ -96,9 +100,11 @@ npm run dev:public Flat image rendering stays in the browser. That includes the public GitHub Pages demo: there is no upload backend in the static demo build. -PSD mode is different: Photopea must be able to download the selected design from a public HTTPS address. `start-openmockup-psd.bat`, `start.bat`, or `npm run dev:public` creates a temporary Cloudflare Tunnel for that asset flow. The OpenMockup UI itself remains on localhost. +PSD mode is different: Photopea must be able to download the selected transformed design from a public HTTPS address. `start-openmockup-psd.bat` or `npm run dev:public` creates a temporary Cloudflare Tunnel **only to an isolated design-asset server**. The OpenMockup Studio UI and Vite server remain bound to localhost and are not routed through that tunnel. + +The isolated server stores temporary images in memory, limits their size and lifetime, and exposes only unpredictable read URLs. The browser-to-asset upload path stays local and is authenticated with a random per-run token between local processes. -Do not use PSD mode for confidential assets unless you understand and accept this data flow. OpenMockup Studio is not affiliated with Photopea. +Do not use PSD mode for confidential assets unless you understand and accept that the transformed design must be retrievable by Photopea over the temporary public URL. OpenMockup Studio is not affiliated with Photopea. ## Commands @@ -106,7 +112,7 @@ Do not use PSD mode for confidential assets unless you understand and accept thi | --- | --- | | `npm run dev` | Start local development mode | | `npm start` | Start on `127.0.0.1:5173` | -| `npm run dev:public` | Start with a temporary Cloudflare Tunnel | +| `npm run dev:public` | Start PSD mode with an isolated temporary design-asset tunnel | | `npm run typecheck` | Run TypeScript checks | | `npm run typecheck:strict` | Include unused-code checks | | `npm test` | Run the automated test suite once | @@ -120,23 +126,15 @@ Do not use PSD mode for confidential assets unless you understand and accept thi Flat image mode can be hosted as a static Vite build. The repository includes a GitHub Pages workflow that builds in `demo` mode, uses the repository subpath as Vite's base path, omits the temporary PSD design-server plugin, and deploys `dist/` as a Pages artifact. -PSD mode additionally needs the temporary-design endpoint used by the Photopea bridge. - -For a server deployment: - -```bash -npm ci -npm run build -npm run preview -``` +For ordinary local PSD use, prefer `npm run dev:public` rather than exposing the Vite server itself. A production/server deployment that offers PSD assets to remote users needs a deliberately authenticated asset service with rate limits instead of simply setting `OPENMOCKUP_ALLOW_PUBLIC_UPLOADS=1` on an Internet-facing Vite instance. -Example environment variables are documented in [`.env.example`](.env.example). Public uploads are disabled by default; only enable them behind authentication, a private network, or equivalent access controls. +Example environment variables are documented in [`.env.example`](.env.example). ## Troubleshooting PSD mode -**Photopea cannot fetch the design:** use `start-openmockup-psd.bat`, `start.bat`, or `npm run dev:public`; localhost-only asset URLs are not reachable by Photopea. +**Photopea cannot fetch the design:** use `start-openmockup-psd.bat` or `npm run dev:public`. Do not create a tunnel directly to the Vite UI port. -**A `trycloudflare.com` address expired:** open the app at `http://127.0.0.1:5173`, stop the previous process, then start PSD mode again to create a new temporary tunnel. +**A `trycloudflare.com` address expired:** stop the previous PSD-mode process and start it again. Continue using the OpenMockup UI at `http://127.0.0.1:5173`; the temporary public URL is for design assets, not for browsing the app. **A Smart Object is not detected:** verify that the PSD/PSB really contains a Smart Object, unlock unusual nested layers where possible, and simplify highly unusual document structures before retrying. @@ -144,10 +142,12 @@ PSD batches run serially to keep the Photopea session stable. ## Project health -The repository uses strict TypeScript checks, Vitest, production and demo builds in GitHub Actions, Dependabot, and versioned Windows release builds with SHA-256 checksums. Core placement, perspective, naming, mockup, persistence, cache, PSB parsing, and export behavior has automated coverage. +The repository uses strict TypeScript checks, Vitest, production and demo builds, Node 20/22 Linux CI, Windows CI, a Chromium browser export smoke, Dependabot, and versioned Windows release builds with SHA-256 checksums. The bundled `cloudflared` release helper is pinned and checksum-verified. Core placement, perspective, naming, mockup, persistence, cache, PSB parsing, asset isolation, and export behavior has automated coverage. - [Roadmap](docs/ROADMAP.md) - [Architecture](docs/ARCHITECTURE.md) +- [Releasing](docs/RELEASING.md) +- [Recommended repository rules](docs/REPOSITORY_SETTINGS.md) - [Contributing](CONTRIBUTING.md) - [Security policy](SECURITY.md) - [Changelog](CHANGELOG.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b681823..efa6770 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,34 +28,64 @@ Mockup files + design files | Path | Responsibility | | --- | --- | | `src/components/` | Focused React interface components | -| `src/lib/app/` | App-level mockup, persistence, and preview-cache logic | +| `src/lib/app/` | App-level mockup, persistence, sample-project, and preview-cache logic | | `src/lib/config/` | Product and export profiles | -| `src/lib/export/` | Conversion, naming, ZIP, and download helpers | +| `src/lib/export/` | Conversion, naming, ZIP, memory cleanup, and download helpers | | `src/lib/photopea/` | Photopea bridge and generated rendering scripts | | `src/lib/` | Image rendering, placement, PSD detection, shared helpers | -| `scripts/` | Local launcher, tunnel, and cleanup helpers | +| `scripts/design-asset-server.mjs` | Minimal temporary asset server used only by PSD mode | +| `scripts/dev-public.mjs` | Local PSD launcher and Cloudflare Tunnel orchestration | ## Flat-image mode -Flat PNG, JPG, and WebP mockups are rendered with browser APIs. This is the simplest hosting mode and does not need a public asset URL. +Flat PNG, JPG, and WebP mockups are rendered with browser APIs. This is the simplest hosting mode and does not need a public asset URL. The GitHub Pages demo uses this path only. ## PSD mode -PSD rendering runs through Photopea in an iframe. The selected design must be retrievable by Photopea, so local PSD mode can start a temporary Cloudflare Tunnel and exposes only the temporary design endpoint needed by that workflow. +Photopea runs in an iframe and must be able to fetch the selected transformed design over public HTTPS. The local PSD workflow deliberately separates that public asset path from the application server: + +```text +Browser on 127.0.0.1:5173 + | + | POST /__openmockup/design + v +Local Vite proxy + | + | X-OpenMockup-Token (server-to-server only) + v +Isolated asset server on 127.0.0.1:5174 + | + | Cloudflare Tunnel targets this port only + v +https://.trycloudflare.com/design/ + | + v + Photopea +``` + +The secret token never needs to be exposed to Photopea or to the browser. Public requests can only fetch an unpredictable temporary design asset. The Vite application port is not routed through the tunnel. + +The isolated server keeps designs in memory, enforces a size limit and TTL, rejects non-image uploads, and exposes no application UI or general-purpose file route. This boundary is security-sensitive. Changes to tunnel behavior, public uploads, message handling, or the Photopea bridge should be reviewed carefully and tested with untrusted filenames and malformed input. +## Batch memory model + +Rendering must stay bounded after a large batch. Results are needed in memory until JSZip has generated the download. After archive creation, OpenMockup keeps only a small preview subset and releases Object URLs and Blob references for the remaining results. Do not reintroduce an unbounded rendered-image gallery. + ## Quality gate ```bash npm run check ``` -This runs strict TypeScript checks, the Vitest suite, and a production build. GitHub Actions executes the same command for pushes and pull requests. +This runs strict TypeScript checks, the Vitest suite, and a production build. CI additionally checks the static demo on Linux Node 20/22 and Windows Node 22, then runs a real Chromium export smoke test. ## Design principles - Keep browser-only flat rendering independent from PSD/tunnel requirements. +- Never expose the Vite UI through the temporary PSD asset tunnel. +- Keep public routes minimal, temporary, unguessable, and read-only where possible. - Prefer pure, testable helpers outside large React components. - Revoke temporary browser resources and keep batch state bounded. - Fail clearly when a PSD or Smart Object layout is unsupported. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1591c65..cd86dd4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,15 +1,25 @@ # Releasing OpenMockup Studio -OpenMockup Studio publishes portable Windows builds from a protected release branch convention. +OpenMockup Studio publishes portable Windows builds from a strict release-branch convention. ## Release checklist -1. Update `package.json` to the intended semantic version. +1. Update `package.json` and `package-lock.json` to the intended semantic version. 2. Move user-facing changes from `Unreleased` into a dated changelog section. -3. Run `npm ci` and `npm run check` through the pull-request CI. +3. Run the pull-request CI and require every Linux/Windows check plus the Chromium browser smoke to pass. 4. Merge the release-preparation pull request into `main` only when CI is green. -5. Create a branch named `release/vX.Y.Z` from the final `main` commit. -6. The Release workflow verifies that the branch name matches `package.json`, runs the full checks again, builds the portable Windows x64 ZIP, creates its SHA-256 checksum, and publishes tag `vX.Y.Z` against `main`. -7. Verify both downloadable assets on the GitHub Release before closing the release issue. +5. Create `release/vX.Y.Z` from the final current `main` commit. Do not make additional commits on the release branch. +6. The Release workflow verifies all of the following before packaging: + - branch name matches `package.json`; + - `HEAD` exactly equals the current remote `main` commit; + - project checks pass; + - bundled `cloudflared` matches the pinned version and SHA-256 checksum. +7. The Windows portable ZIP and checksum are built from that verified commit. +8. The GitHub release tag is created against the verified commit SHA, not against a moving branch name. +9. Verify both downloadable assets on the GitHub Release. -The workflow refuses to overwrite an existing release tag. +The workflow refuses to overwrite an existing release tag or release from a branch that has drifted from `main`. + +## Repository rules + +The workflow enforces release provenance, but repository-side branch rules are still recommended. The desired settings are documented in [REPOSITORY_SETTINGS.md](REPOSITORY_SETTINGS.md). These are GitHub repository administration settings, not files that CI can enforce by itself. diff --git a/docs/REPOSITORY_SETTINGS.md b/docs/REPOSITORY_SETTINGS.md new file mode 100644 index 0000000..cb2d73b --- /dev/null +++ b/docs/REPOSITORY_SETTINGS.md @@ -0,0 +1,23 @@ +# Recommended GitHub repository rules + +These settings live in GitHub repository administration and cannot be represented only by files committed to the repository. + +## `main` + +Create a branch ruleset targeting `main` with: + +- require changes through a pull request; +- require the CI workflow before merge; +- block force pushes; +- block branch deletion; +- require the branch to be up to date before merge if multiple contributors are active. + +The required CI jobs should include the Linux Node 20/22 checks, Windows Node 22 check, and browser export smoke. + +## `release/v*` + +Release branches are intentionally short-lived. Restrict creation and updates to maintainers if the repository plan supports it. The release workflow independently refuses to publish unless the release branch points at the exact current `main` commit. + +## Merge policy + +Squash merge is recommended for feature and dependency pull requests so each reviewed change lands as one main-branch commit. Delete merged feature/release branches after the release completes. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d8984a1..5865031 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,8 +6,8 @@ The roadmap is ordered by expected user value and by the technical foundation ea - [x] Reproducible installs, strict type checks, automated tests, and CI - [x] Extract testable mockup, persistence, and preview-cache modules from `App.tsx` -- [ ] Add component-level tests for file selection, presets, and batch cancellation -- [ ] Add browser-based export smoke tests with small generated fixtures +- [x] Add a browser-based export smoke covering sample loading, real file selection, preview, preset download, and batch ZIP export +- [ ] Add deeper component-level tests for preset editing and cancellation edge cases ## Editing and realism @@ -18,6 +18,7 @@ The roadmap is ordered by expected user value and by the technical foundation ea ## Reliability and performance +- [x] Bound retained preview memory after large batch exports - [ ] Faster batch previews with controlled parallel image rendering - [ ] Better PSD timeout recovery and resumable batch exports - [ ] Export manifest import for retrying only failed combinations @@ -25,6 +26,7 @@ The roadmap is ordered by expected user value and by the technical foundation ea ## Hosting and distribution +- [x] Isolate the temporary PSD asset tunnel from the local application server - [ ] Authenticated self-hosted asset service with rate limits - [x] Static image-mode demo deployment via GitHub Pages - [x] Versioned releases and downloadable Windows packages From 3244bfb74287a56b23bf508cc43d50e4b0e035a3 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 22:05:47 +0200 Subject: [PATCH 06/11] fix: load Playwright from an isolated pinned test runtime --- scripts/e2e-demo.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/e2e-demo.mjs b/scripts/e2e-demo.mjs index e6b83c2..b1183d5 100644 --- a/scripts/e2e-demo.mjs +++ b/scripts/e2e-demo.mjs @@ -1,8 +1,14 @@ import { spawn } from "node:child_process"; import { stat } from "node:fs/promises"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import process from "node:process"; -import { chromium } from "playwright"; + +const playwrightModule = process.env.OPENMOCKUP_PLAYWRIGHT_MODULE || "playwright"; +const playwrightSpecifier = playwrightModule.startsWith("/") + ? pathToFileURL(playwrightModule).href + : playwrightModule; +const { chromium } = await import(playwrightSpecifier); const HOST = "127.0.0.1"; const PORT = 4173; From d9fea57749ee1578529a06d0ab82451cd6cb9357 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 22:06:04 +0200 Subject: [PATCH 07/11] ci: isolate the pinned Playwright installation from project dependencies --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d32f8c9..22c8ea7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,10 +76,18 @@ jobs: OPENMOCKUP_BASE_PATH: / - name: Install pinned browser test runtime - run: npm install --no-save --package-lock=false playwright@1.55.0 + shell: bash + run: | + PLAYWRIGHT_ROOT="$RUNNER_TEMP/openmockup-playwright" + mkdir -p "$PLAYWRIGHT_ROOT" + npm install --prefix "$PLAYWRIGHT_ROOT" --no-save --package-lock=false --ignore-scripts playwright@1.55.0 + echo "OPENMOCKUP_PLAYWRIGHT_ROOT=$PLAYWRIGHT_ROOT" >> "$GITHUB_ENV" + echo "OPENMOCKUP_PLAYWRIGHT_MODULE=$PLAYWRIGHT_ROOT/node_modules/playwright/index.mjs" >> "$GITHUB_ENV" + echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_ROOT/browsers" >> "$GITHUB_ENV" - name: Install Chromium - run: npx playwright install --with-deps chromium + shell: bash + run: "$OPENMOCKUP_PLAYWRIGHT_ROOT/node_modules/.bin/playwright install --with-deps chromium" - name: Run browser export smoke run: node scripts/e2e-demo.mjs From f6c618193e46a496abba9a5d5af9f68777f6b549 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 22:08:18 +0200 Subject: [PATCH 08/11] test: make browser smoke selectors deterministic --- scripts/e2e-demo.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/e2e-demo.mjs b/scripts/e2e-demo.mjs index b1183d5..91b6c65 100644 --- a/scripts/e2e-demo.mjs +++ b/scripts/e2e-demo.mjs @@ -39,6 +39,10 @@ function assert(condition, message) { if (!condition) throw new Error(message); } +async function waitForDesignsLoaded(page) { + await page.locator(".status-pill").filter({ hasText: "2 designs loaded" }).waitFor({ state: "visible" }); +} + const previewProcess = spawn(process.execPath, [ viteBin, "preview", @@ -63,7 +67,7 @@ try { await page.goto(BASE_URL, { waitUntil: "networkidle" }); await page.getByRole("heading", { name: "OpenMockup Studio" }).waitFor(); await page.getByRole("button", { name: "Try sample project" }).click(); - await page.getByText(/2 designs loaded/).waitFor(); + await waitForDesignsLoaded(page); await page.getByRole("button", { name: "Use my files" }).click(); const fileInputs = page.locator('input[type="file"]'); @@ -73,7 +77,7 @@ try { join(process.cwd(), "public", "examples", "sample-design-leaf.png"), ]); - await page.getByText(/2 designs loaded/).waitFor(); + await waitForDesignsLoaded(page); const refreshButton = page.getByRole("button", { name: "Refresh Preview" }); await refreshButton.waitFor({ state: "visible" }); await refreshButton.click(); @@ -97,7 +101,6 @@ try { assert(zipDownload.suggestedFilename().endsWith(".zip"), "Batch export filename is not a ZIP."); assert((await stat(zipPath)).size > 100, "Batch ZIP is unexpectedly empty."); - await page.getByText(/2 mockups exported|2 mockup/i).waitFor({ timeout: 10_000 }).catch(() => {}); console.log("Browser smoke passed: file selection, preview, preset download and batch ZIP export."); } finally { if (browser) await browser.close(); From dfc4c4d9eaafbe9ccea9bd34f7667cdf5a114021 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 22:12:06 +0200 Subject: [PATCH 09/11] test: surface browser preview failures with UI diagnostics --- scripts/e2e-demo.mjs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/e2e-demo.mjs b/scripts/e2e-demo.mjs index 91b6c65..4da4218 100644 --- a/scripts/e2e-demo.mjs +++ b/scripts/e2e-demo.mjs @@ -43,6 +43,24 @@ async function waitForDesignsLoaded(page) { await page.locator(".status-pill").filter({ hasText: "2 designs loaded" }).waitFor({ state: "visible" }); } +async function waitForGeneratedPreview(page, timeoutMs = 20_000) { + const preview = page.locator('img[alt="Generated mockup preview"]'); + const status = page.locator(".status-pill"); + const startedAt = Date.now(); + let lastStatus = ""; + + while (Date.now() - startedAt < timeoutMs) { + if (await preview.isVisible().catch(() => false)) return; + lastStatus = (await status.textContent().catch(() => ""))?.trim() || ""; + if (/failed|could not|error|invalid|unsupported/i.test(lastStatus)) { + throw new Error(`Preview failed in the UI: ${lastStatus}`); + } + await sleep(250); + } + + throw new Error(`Preview did not appear within ${timeoutMs}ms. Last UI status: ${lastStatus || ""}`); +} + const previewProcess = spawn(process.execPath, [ viteBin, "preview", @@ -63,6 +81,12 @@ try { await waitForServer(); browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ acceptDownloads: true }); + page.on("pageerror", (error) => console.error(`[browser pageerror] ${error.message}`)); + page.on("console", (message) => { + if (["error", "warning"].includes(message.type())) { + console.error(`[browser ${message.type()}] ${message.text()}`); + } + }); await page.goto(BASE_URL, { waitUntil: "networkidle" }); await page.getByRole("heading", { name: "OpenMockup Studio" }).waitFor(); @@ -81,7 +105,7 @@ try { const refreshButton = page.getByRole("button", { name: "Refresh Preview" }); await refreshButton.waitFor({ state: "visible" }); await refreshButton.click(); - await page.locator('img[alt="Generated mockup preview"]').waitFor({ state: "visible" }); + await waitForGeneratedPreview(page); const presetDownloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "Export Preset JSON" }).click(); From 8feddded88820ebe31d0fcc9d1db06512d830de9 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Mon, 7 Sep 2026 23:09:04 +0200 Subject: [PATCH 10/11] fix: replace corrupted sample mockup PNG --- public/examples/sample-poster-mockup.png | Bin 782 -> 4724 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/examples/sample-poster-mockup.png b/public/examples/sample-poster-mockup.png index 059c3c7236486b118b304d4db2b5f5c03a99327e..0d4eb461d3c3ab3e655d7f90058529fa02266e00 100644 GIT binary patch literal 4724 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz}&*Y1QfC3k6+8cAY|+5;uunK>+LN=Z>K<+ zV-J&Kc6eOT+U3^!=t;uvTHmkHPv$jGj>#}=;s|QtnDr#@Y@kA?dH-{}^Yc#J{_y!f;F&Y9b4bj{T??gD9 z4oqb|u(fC{t)dI;u@(WR6Fv-?8e+xD3?G8GUpHq0C5#*QKfSEnFTZE!zD;s2r}y_T zF$Cm#zLT8{^1VWv*V z!|9z|>6}mpBK0s*E1@znhGdKPduRZQr|XkM92md`Gdj#qpDy+!yh%x#?;Y<)t7D1r zcc!2FyyT9kQ`zal==<%Z)qk35&SctHUkiD?EwbjNFozEPa}YJcfQfnKlo^aSR_kqX zVPN*|g9QK`^Ce!Zn3Fr;cAd#^$&db^xGM(iEY;_64;m@UeDOl%P@&{?sDW%D!r|v z!1OQ*+)rU}fCgo@Eh4~Pnv-yI2N!dW@P`>^SSEhF8^6wE36m2SQ1P7v z2BzOf_VtW}it7Rk-K#HZWIj?_N_evg4Qfh=|47Yi49A~v8B7hkT`V_G1X!Xns)||) J?c{9Y006L+|LOn$ literal 782 zcmb`F-%FEG7{{OY^<5q^wzD;9%4p}cnTn>%A9D3o2H4$=3Uvxd2}a5UKnWtZO`H>xRjW*JaFaN`!vO38A5{&C)xmgPh{~poj=sG0 z<1xU>Lfrm#_M;Rfb3SmQrwM2^VR*DBYdewO%>r+hn{c5d{TQG0js96wz?oy!+~dt3 zbh(MZ-LCKv;00JgSK=!S5QvD^x`8NBt(s$eE@A-Mgxcn}p~24#ib0EbOPjI`YDGkp zfA@tbfuu%8VqhmKey$s&ZZ^FYA%>ZK_5D`{m6kc(>u3#e6v$|ffp3#Q0+3;vpl;WK zibI^&^{3D|@89+i&zG$4P`H&&)bkA6muyE{lBcHT^$!Lf$#uGdld==mAR(xpoyHFX z{`KV;uBTn1B7ZDNbF&vz5`6T#L(s~`G=60sYBtWg2vTg*8`qb3X{rk;McM#btK}&a zKHTwTOwsnQNiBC3tyN3yqN`;7AIrA@X6D?VZ5f}z@I=F{d%MI-6D7h^eqqlri}oip zxN+d!&HGM+2x59raHe&ppJL`yYTxAa%y3Nyzlj%Dv=31V(j7pK%mJxDLB0W~m;)$C m-U^YYAkAd5Q`k;e5&i1y($r~7 Date: Mon, 7 Sep 2026 23:13:14 +0200 Subject: [PATCH 11/11] test: exercise preset download through visible advanced tools --- scripts/e2e-demo.mjs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/e2e-demo.mjs b/scripts/e2e-demo.mjs index 4da4218..4f11837 100644 --- a/scripts/e2e-demo.mjs +++ b/scripts/e2e-demo.mjs @@ -107,9 +107,16 @@ try { await refreshButton.click(); await waitForGeneratedPreview(page); - const presetDownloadPromise = page.waitForEvent("download"); - await page.getByRole("button", { name: "Export Preset JSON" }).click(); - const presetDownload = await presetDownloadPromise; + const advancedToolsSummary = page.locator("summary").filter({ hasText: "Advanced Tools" }); + await advancedToolsSummary.waitFor({ state: "visible" }); + await advancedToolsSummary.click(); + + const presetButton = page.getByRole("button", { name: "Export Preset JSON" }); + await presetButton.waitFor({ state: "visible" }); + const [presetDownload] = await Promise.all([ + page.waitForEvent("download", { timeout: 10_000 }), + presetButton.click(), + ]); const presetPath = await presetDownload.path(); assert(presetPath, "Preset download did not produce a file."); assert(presetDownload.suggestedFilename().endsWith(".json"), "Preset download filename is not JSON."); @@ -117,9 +124,10 @@ try { const exportButton = page.getByRole("button", { name: /Export All/ }); await exportButton.waitFor({ state: "visible" }); - const zipDownloadPromise = page.waitForEvent("download", { timeout: 30_000 }); - await exportButton.click(); - const zipDownload = await zipDownloadPromise; + const [zipDownload] = await Promise.all([ + page.waitForEvent("download", { timeout: 30_000 }), + exportButton.click(), + ]); const zipPath = await zipDownload.path(); assert(zipPath, "Batch export did not produce a ZIP file."); assert(zipDownload.suggestedFilename().endsWith(".zip"), "Batch export filename is not a ZIP.");