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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user