chore: static analysis baseline

Pre-analysis checkpoint before implementing fixes for:
- bakeStyles field mapping
- error logging improvements
- HTML sanitization hardening
This commit is contained in:
2026-06-24 07:46:05 +02:00
parent e862827d2d
commit 3a21070d62
4 changed files with 175 additions and 10 deletions
+3
View File
@@ -12,6 +12,7 @@
"add": "^2.0.6",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0",
"typescript": "^6.0.3",
},
},
},
@@ -282,6 +283,8 @@
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
+2 -1
View File
@@ -17,6 +17,7 @@
"@types/bun": "^1.3.13",
"add": "^2.0.6",
"oxfmt": "^0.49.0",
"oxlint": "^1.64.0"
"oxlint": "^1.64.0",
"typescript": "^6.0.3"
}
}
+89 -6
View File
@@ -179,6 +179,10 @@
background: rgba(46, 204, 113, 0.1);
color: var(--green);
}
.badge-wait {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.card-url-preview {
font-size: 13px;
color: var(--muted);
@@ -578,6 +582,7 @@
<button class="btn btn-ghost btn-sm" id="debug-toggle" onclick="toggleDebug()">
🔍 Debug
</button>
<button class="btn btn-ghost btn-sm" onclick="openImportModal()">📤 Import</button>
<button class="btn btn-primary" onclick="openCreateModal()">+ New View</button>
</div>
</header>
@@ -782,13 +787,15 @@
.map((v) => {
const viewUrl = origin + "/v/" + v.id;
const urlPreview = v.urls.map((u, i) =>
`${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
`${u.url} (${u.durationSec}s${u.waitBeforeCaptureMs ? ` +${u.waitBeforeCaptureMs}ms` : ""})${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
).join(" → ");
const anyBake = v.urls.some((u) => u.bakeStyles);
const anyWait = v.urls.some((u) => u.waitBeforeCaptureMs > 0);
const badges = [
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</span>' : "",
anyBake ? '<span class="badge badge-bake">BAKE</span>' : "",
anyWait ? '<span class="badge badge-wait">WAIT</span>' : "",
]
.filter(Boolean)
.join(" ");
@@ -798,6 +805,7 @@
<div class="card-header">
<span class="card-title">${esc(v.name)}</span>
<div style="display:flex;gap:6px;">
<button class="btn btn-icon btn-ghost btn-sm" onclick="exportView('${v.id}')" title="Export">📥</button>
<button class="btn btn-icon btn-ghost btn-sm" onclick="openEditModal('${v.id}')" title="Edit">✏️</button>
<button class="btn btn-icon btn-danger btn-sm" onclick="confirmDelete('${v.id}','${esc(v.name)}')" title="Delete">🗑</button>
</div>
@@ -873,6 +881,75 @@
}
}
// ═══════════════════════════════════════════════
// EXPORT / IMPORT
// ═══════════════════════════════════════════════
async function exportView(id) {
try {
const response = await fetch("/api/views/" + id + "?export=true");
if (!response.ok) throw new Error("Export failed");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `view-${id}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
toast("View exported!");
} catch (err) {
toast(err.message, "error");
}
}
function openImportModal() {
openModal(`
<h2>Import View</h2>
<form id="import-form" onsubmit="handleImport(event)">
<div class="field">
<label>Paste View JSON</label>
<textarea id="import-json" rows="12" placeholder='{"name":"My View","urls":[{"url":"https://example.com","durationSec":30}],"method":"ssr"}' required style="font-family:monospace;font-size:13px;"></textarea>
<div class="hint">Paste the JSON content from an exported view file.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Import View</button>
</div>
</form>`);
}
async function handleImport(e) {
e.preventDefault();
const jsonText = document.getElementById("import-json").value.trim();
if (!jsonText) {
toast("Please paste JSON content", "error");
return;
}
try {
// Validate JSON structure
const data = JSON.parse(jsonText);
if (!data.name || !data.urls || !Array.isArray(data.urls)) {
toast("Invalid view JSON: missing required fields", "error");
return;
}
await api("POST", "/api/views/import", data);
closeModal();
toast("View imported successfully!");
loadViews();
} catch (err) {
if (err.message.includes("JSON")) {
toast("Invalid JSON format", "error");
} else {
toast(err.message, "error");
}
}
}
// ═══════════════════════════════════════════════
// EDITOR BUILDER
// ═══════════════════════════════════════════════
@@ -881,9 +958,9 @@
const isEdit = !!existing;
const title = isEdit ? "Edit View" : "Create New View";
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
const urls = existing ? existing.urls : [{ url: "", durationSec: 30, waitBeforeCaptureMs: 0 }];
let urlRowsHTML = urls
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode, u.bakeStyles))
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, u.waitBeforeCaptureMs, i, urls.length, u.forceDarkMode, u.bakeStyles))
.join("");
const method = existing ? existing.method : "ssr";
const metaChecked = existing ? existing.metaRefreshEnabled : false;
@@ -950,16 +1027,20 @@
</form>`;
}
function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode, bakeStyles) {
function buildUrlRowHTML(url, durationSec, waitBeforeCaptureMs, index, total, forceDarkMode, bakeStyles) {
const isSingle = total <= 1;
const disabledAttr = isSingle ? "disabled" : "";
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
const darkChecked = forceDarkMode ? "checked" : "";
const bakeChecked = bakeStyles ? "checked" : "";
const waitVal = waitBeforeCaptureMs ?? 0;
return `
<div class="url-row" data-index="${index}">
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required>
<div style="display:flex;gap:8px;flex:1;">
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required title="Display duration">
<input type="number" placeholder="Wait ms" value="${waitVal}" min="0" max="60000" class="wait-input" title="Wait before capture (ms)">
</div>
<label class="dark-toggle" title="Force dark mode">
<input type="checkbox" class="dark-mode-input" ${darkChecked}>
🌙
@@ -980,7 +1061,7 @@
const container = document.getElementById("url-rows");
const rows = container.querySelectorAll(".url-row");
const div = document.createElement("div");
div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false, false);
div.innerHTML = buildUrlRowHTML("", 30, 0, rows.length, rows.length + 1, false, false);
container.appendChild(div.firstElementChild);
updateUrlRowStates();
}
@@ -1037,9 +1118,11 @@
if (!urlVal) continue;
const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false;
const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false;
const waitBeforeCaptureMs = parseInt(row.querySelector(".wait-input")?.value) || 0;
urls.push({
url: urlVal,
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
waitBeforeCaptureMs,
forceDarkMode,
bakeStyles,
});
+81 -3
View File
@@ -334,9 +334,9 @@ if (viewCount === 0) {
crypto.randomUUID(),
"Demo View",
JSON.stringify([
{ url: "https://www.google.com", durationSec: 30, forceDarkMode: false, bakeStyles: false },
{ url: "https://duck.ai", durationSec: 30, forceDarkMode: false, bakeStyles: false },
{ url: "https://www.msn.com", durationSec: 30, forceDarkMode: false, bakeStyles: false },
{ url: "https://www.google.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
{ url: "https://duck.ai", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
{ url: "https://www.msn.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
]),
"ssr",
0,
@@ -839,6 +839,7 @@ async function renderViewToCache(view: View, urlIndex: number, key: string): Pro
const urlItem = view.urls[urlIndex];
const forceDarkMode = urlItem.forceDarkMode ?? false;
const bakeStyles = urlItem.bakeStyles ?? false;
const waitBeforeCaptureMs = urlItem.waitBeforeCaptureMs ?? 0;
let body: string;
if (view.method === "mjpeg") {
@@ -847,6 +848,7 @@ async function renderViewToCache(view: View, urlIndex: number, key: string): Pro
view.viewportWidth,
view.viewportHeight,
forceDarkMode,
waitBeforeCaptureMs,
);
body = jpeg.toString("base64");
} else {
@@ -856,6 +858,7 @@ async function renderViewToCache(view: View, urlIndex: number, key: string): Pro
view.viewportHeight,
forceDarkMode,
bakeStyles,
waitBeforeCaptureMs,
);
}
@@ -1308,6 +1311,7 @@ async function renderScreenshotFrame(
viewportWidth = 1920,
viewportHeight = 1080,
forceDarkMode = false,
waitBeforeCaptureMs = 0,
): Promise<Buffer> {
const page = await pagePool.acquire();
try {
@@ -1326,6 +1330,11 @@ async function renderScreenshotFrame(
}
}
// Wait before capture (for animations, dynamic content, etc.)
if (waitBeforeCaptureMs > 0) {
await new Promise((r) => setTimeout(r, waitBeforeCaptureMs));
}
return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }));
} finally {
await pagePool.release(page);
@@ -1428,6 +1437,7 @@ function json(data: unknown, status = 200): Response {
async function handleAPI(req: Request, path: string): Promise<Response> {
const method = req.method;
const url = new URL(req.url);
// GET /api/views
if (path === "/api/views" && method === "GET") {
@@ -1464,6 +1474,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
urls: body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
})),
@@ -1507,6 +1518,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
view.urls = body.urls.map((u) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
}));
@@ -1541,6 +1553,72 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
console.log(`[api] Deleted ${viewId}`);
return json({ ok: true });
}
// GET /api/views/:id/export - Export single view as JSON
if (method === "GET" && url.searchParams.get("export") === "true") {
const view = viewCacheGet(viewId);
if (!view) return json({ error: "View not found" }, 404);
const exportData = {
name: view.name,
urls: view.urls,
method: view.method,
metaRefreshEnabled: view.metaRefreshEnabled,
cacheTtlSec: view.cacheTtlSec,
viewportWidth: view.viewportWidth,
viewportHeight: view.viewportHeight,
};
return new Response(JSON.stringify(exportData, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="view-${viewId}.json"`,
},
});
}
}
// POST /api/views/import - Import a view from JSON
if (path === "/api/views/import" && method === "POST") {
try {
const importData = JSON.parse(await req.text());
if (!importData.name?.trim()) return json({ error: "Name is required" }, 400);
if (!importData.urls?.length) return json({ error: "At least one URL is required" }, 400);
for (const u of importData.urls) {
if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400);
if (typeof u.durationSec !== "number" || u.durationSec <= 0)
return json({ error: "Each URL must have positive durationSec" }, 400);
}
const method = importData.method ?? "ssr";
if (!["mjpeg", "ssr"].includes(method))
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
const view: View = {
id: crypto.randomUUID(),
name: importData.name.trim(),
urls: importData.urls.map((u: URLItem) => ({
url: u.url.trim(),
durationSec: Math.max(1, Math.round(u.durationSec)),
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
forceDarkMode: u.forceDarkMode ?? false,
bakeStyles: u.bakeStyles ?? false,
})),
method: method,
metaRefreshEnabled: importData.metaRefreshEnabled ?? false,
cacheTtlSec: Math.max(0, importData.cacheTtlSec ?? 60),
viewportWidth: Math.max(320, Math.min(7680, importData.viewportWidth ?? 1920)),
viewportHeight: Math.max(240, Math.min(4320, importData.viewportHeight ?? 1080)),
createdAt: Math.floor(Date.now() / 1000),
};
dbInsert(view);
viewCache.set(view.id, view);
console.log(`[api] Imported "${view.name}" (${view.id}) method=${view.method}`);
return json(view, 201);
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
}
// POST /api/admin/disconnect/:ip