Compare commits
4
Commits
6d38beaf26
...
ac759a60fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac759a60fe | ||
|
|
75f9686533 | ||
|
|
daee65e0d5 | ||
|
|
69b62b6af3 |
+146
-3
@@ -229,10 +229,33 @@
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
.modal h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
@@ -512,6 +535,11 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.device-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
font-size: 15px;
|
||||
margin-bottom: 24px;
|
||||
@@ -652,12 +680,31 @@
|
||||
<th>Requests</th>
|
||||
<th>Last Seen</th>
|
||||
<th>User Agent</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="debug-devices-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-section">
|
||||
<h3>🚫 Banned IPs</h3>
|
||||
<div style="overflow-x: auto">
|
||||
<table class="debug-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Banned At</th>
|
||||
<th>Reason</th>
|
||||
<th>Banned By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="debug-banned-tbody"><tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -781,8 +828,11 @@
|
||||
|
||||
function openModal(html) {
|
||||
document.getElementById("modal-container").innerHTML = `
|
||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||
<div class="modal">${html}</div>
|
||||
<div class="modal-overlay">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
${html}
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
@@ -877,7 +927,7 @@
|
||||
<div class="field">
|
||||
<label>Cache TTL (seconds)</label>
|
||||
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
|
||||
<div class="hint">How long a render stays in cache. 0 = always re-render. Each access extends TTL.</div>
|
||||
<div class="hint">How long a render stays in cache. 0 = always re-render.</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="display:flex;gap:12px;">
|
||||
@@ -1138,6 +1188,7 @@
|
||||
document.getElementById("debug-devices-tbody").innerHTML = data.devices
|
||||
.map((d) => {
|
||||
const ago = Math.round((Date.now() - d.lastSeen) / 1000);
|
||||
const ipEnc = encodeURIComponent(d.ip);
|
||||
return `
|
||||
<tr>
|
||||
<td>${esc(d.ip)}</td>
|
||||
@@ -1145,10 +1196,20 @@
|
||||
<td>${d.requestCount}</td>
|
||||
<td>${ago}s ago</td>
|
||||
<td class="url-cell" title="${esc(d.userAgent)}">${esc(d.userAgent.slice(0, 60))}</td>
|
||||
<td>
|
||||
<div class="device-actions">
|
||||
<button class="btn btn-xs btn-ghost" onclick="adminRefresh('${ipEnc}')" title="Force cache refresh">↺</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="adminDisconnect('${ipEnc}')" title="Disconnect">⏏</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="adminBan('${ipEnc}', '${esc(d.ip)}')" title="Ban IP">🚫</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// ── Banned IPs table ──
|
||||
fetchBannedIPs();
|
||||
|
||||
if (!debugTickInterval) {
|
||||
debugTickInterval = setInterval(tickDebugValues, 500);
|
||||
}
|
||||
@@ -1177,6 +1238,88 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// ADMIN ACTIONS
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function adminDisconnect(ipEnc) {
|
||||
try {
|
||||
await api("POST", "/api/admin/disconnect/" + ipEnc);
|
||||
toast("Device disconnected");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function adminRefresh(ipEnc) {
|
||||
try {
|
||||
await api("POST", "/api/admin/refresh/" + ipEnc);
|
||||
toast("Cache refreshed");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function adminBan(ipEnc, ipDisplay) {
|
||||
openModal(`
|
||||
<h2>Ban IP</h2>
|
||||
<div class="confirm-text">Ban <strong>${esc(ipDisplay)}</strong>? Future connections will receive 403.</div>
|
||||
<div class="field">
|
||||
<label>Reason</label>
|
||||
<input type="text" id="ban-reason" placeholder="Reason (optional)" value="Banned by administrator">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-danger" onclick="doAdminBan('${ipEnc}', '${esc(ipDisplay)}')">Ban</button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
async function doAdminBan(ipEnc, ipDisplay) {
|
||||
const reason = document.getElementById("ban-reason")?.value || "Banned by administrator";
|
||||
try {
|
||||
await api("POST", "/api/admin/ban/" + ipEnc, { reason });
|
||||
closeModal();
|
||||
toast(`Banned ${decodeURIComponent(ipEnc)}`);
|
||||
fetchBannedIPs();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function adminUnban(ipEnc) {
|
||||
try {
|
||||
await api("POST", "/api/admin/unban/" + ipEnc);
|
||||
toast("IP unbanned");
|
||||
fetchBannedIPs();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBannedIPs() {
|
||||
try {
|
||||
const banned = await api("GET", "/api/banned");
|
||||
const tbody = document.getElementById("debug-banned-tbody");
|
||||
if (!tbody) return;
|
||||
if (!banned.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = banned.map((b) => {
|
||||
const ipEnc = encodeURIComponent(b.ip);
|
||||
const dt = new Date(b.banned_at * 1000).toLocaleString();
|
||||
return `
|
||||
<tr>
|
||||
<td>${esc(b.ip)}</td>
|
||||
<td>${esc(dt)}</td>
|
||||
<td>${esc(b.reason || "—")}</td>
|
||||
<td>${esc(b.banned_by || "—")}</td>
|
||||
<td><button class="btn btn-xs btn-ghost" onclick="adminUnban('${ipEnc}')">Unban</button></td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// KEYBOARD
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
+201
-4
@@ -16,6 +16,7 @@ const CONFIG = {
|
||||
MAX_CONCURRENT_RENDERS: 16,
|
||||
QUEUE_TIMEOUT_MS: 30000,
|
||||
CACHE_CLEANUP_INTERVAL_MS: 30000,
|
||||
WARMUP_INTERVAL_MS: 60000,
|
||||
} as const;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -115,6 +116,15 @@ db.run(`
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||
ip TEXT PRIMARY KEY,
|
||||
banned_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
reason TEXT,
|
||||
banned_by TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrations for upgrading from older schema
|
||||
try {
|
||||
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
|
||||
@@ -143,6 +153,30 @@ function rowToView(row: ViewRow): View {
|
||||
};
|
||||
}
|
||||
|
||||
// Seed a default view on first run (only when table is empty)
|
||||
const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n;
|
||||
if (viewCount === 0) {
|
||||
db.run(
|
||||
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
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 },
|
||||
]),
|
||||
"ssr",
|
||||
0,
|
||||
60,
|
||||
1920,
|
||||
1080,
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function dbLoadAll(): View[] {
|
||||
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
|
||||
rowToView,
|
||||
@@ -192,6 +226,30 @@ function dbDelete(id: string): boolean {
|
||||
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
|
||||
}
|
||||
|
||||
function dbBanIP(ip: string, reason?: string): void {
|
||||
db.run(
|
||||
"INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)",
|
||||
[ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"]
|
||||
);
|
||||
}
|
||||
|
||||
function dbUnbanIP(ip: string): boolean {
|
||||
return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0;
|
||||
}
|
||||
|
||||
function dbIsBanned(ip: string): boolean {
|
||||
return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null;
|
||||
}
|
||||
|
||||
function dbGetBannedIPs(): Array<{ ip: string; banned_at: number; reason: string; banned_by: string }> {
|
||||
return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{
|
||||
ip: string;
|
||||
banned_at: number;
|
||||
reason: string;
|
||||
banned_by: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// IN-MEMORY VIEW CACHE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -215,9 +273,16 @@ function viewCacheAll(): View[] {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const devices = new Map<string, DeviceEntry>();
|
||||
const warmedUpViews = new Set<string>();
|
||||
const DEVICE_TTL_MS = 120_000;
|
||||
|
||||
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void {
|
||||
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean {
|
||||
// Check if IP is banned
|
||||
if (dbIsBanned(ip)) {
|
||||
console.warn(`[security] Blocked connection from banned IP: ${ip}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = devices.get(ip);
|
||||
if (existing) {
|
||||
existing.viewId = viewId;
|
||||
@@ -227,7 +292,16 @@ function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: st
|
||||
existing.requestCount++;
|
||||
} else {
|
||||
devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 });
|
||||
|
||||
// First user connecting to this view - trigger warmup
|
||||
if (!warmedUpViews.has(viewId)) {
|
||||
warmedUpViews.add(viewId);
|
||||
warmupView(viewId).catch(err => {
|
||||
console.error(`[warmup] Failed to warm up view ${viewId}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function devicesPrune(): void {
|
||||
@@ -467,6 +541,64 @@ function cachePurgeExpired(): void {
|
||||
|
||||
setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS);
|
||||
|
||||
async function warmupView(viewId: string): Promise<void> {
|
||||
const view = viewCacheGet(viewId);
|
||||
if (!view) {
|
||||
console.warn(`[warmup] View ${viewId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[warmup] Starting precache for view "${view.name}" (${view.urls.length} URLs)`);
|
||||
|
||||
for (let i = 0; i < view.urls.length; i++) {
|
||||
const key = renderCacheKey(view.id, i);
|
||||
const cached = renderCache.get(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (!cached || now >= cached.expiresAt) {
|
||||
try {
|
||||
await getOrRender(view, i);
|
||||
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[warmup] Precache complete for view "${view.name}"`);
|
||||
}
|
||||
|
||||
async function warmupCache(): Promise<void> {
|
||||
const activeDevices = devicesSnapshot();
|
||||
if (activeDevices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[warmup] Starting periodic precache for ${activeDevices.length} active device(s)`);
|
||||
|
||||
const views = viewCacheAll();
|
||||
for (const view of views) {
|
||||
for (let i = 0; i < view.urls.length; i++) {
|
||||
const key = renderCacheKey(view.id, i);
|
||||
const cached = renderCache.get(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (!cached || now >= cached.expiresAt) {
|
||||
try {
|
||||
await getOrRender(view, i);
|
||||
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[warmup] Periodic precache complete`);
|
||||
}
|
||||
|
||||
setInterval(warmupCache, CONFIG.WARMUP_INTERVAL_MS);
|
||||
|
||||
function cacheInvalidate(viewId: string): void {
|
||||
const prefix = `${viewId}:`;
|
||||
for (const key of renderCache.keys()) {
|
||||
@@ -478,6 +610,8 @@ function cacheInvalidate(viewId: string): void {
|
||||
renderQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
// Allow re-warming if view is recreated
|
||||
warmedUpViews.delete(viewId);
|
||||
}
|
||||
|
||||
function drainRenderQueue(): void {
|
||||
@@ -504,7 +638,6 @@ function drainRenderQueue(): void {
|
||||
|
||||
const cached = renderCache.get(next.key);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
cached.expiresAt = Date.now() + view.cacheTtlSec * 1000;
|
||||
next.resolve(cached.body);
|
||||
continue;
|
||||
}
|
||||
@@ -564,7 +697,6 @@ async function getOrRender(view: View, urlIndex: number): Promise<string> {
|
||||
|
||||
const cached = renderCache.get(key);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
cached.expiresAt = now + view.cacheTtlSec * 1000;
|
||||
return cached.body;
|
||||
}
|
||||
|
||||
@@ -1163,6 +1295,11 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
return json(getDebugData());
|
||||
}
|
||||
|
||||
// GET /api/banned
|
||||
if (path === "/api/banned" && method === "GET") {
|
||||
return json(dbGetBannedIPs());
|
||||
}
|
||||
|
||||
// POST /api/views
|
||||
if (path === "/api/views" && method === "POST") {
|
||||
try {
|
||||
@@ -1262,6 +1399,60 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/disconnect/:ip
|
||||
const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/);
|
||||
if (disconnectMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(disconnectMatch[1]);
|
||||
devices.delete(ip);
|
||||
console.log(`[admin] Disconnected device: ${ip}`);
|
||||
return json({ ok: true, message: `Device ${ip} disconnected` });
|
||||
}
|
||||
|
||||
// POST /api/admin/ban/:ip
|
||||
const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/);
|
||||
if (banMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(banMatch[1]);
|
||||
try {
|
||||
const body = JSON.parse(await req.text());
|
||||
const reason = body.reason || "Banned by administrator";
|
||||
const bannedDevice = devices.get(ip);
|
||||
dbBanIP(ip, reason);
|
||||
devices.delete(ip);
|
||||
if (bannedDevice) cacheInvalidate(bannedDevice.viewId);
|
||||
console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`);
|
||||
return json({ ok: true, message: `IP ${ip} banned` });
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/unban/:ip
|
||||
const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/);
|
||||
if (unbanMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(unbanMatch[1]);
|
||||
const success = dbUnbanIP(ip);
|
||||
if (success) {
|
||||
console.log(`[admin] Unbanned IP: ${ip}`);
|
||||
return json({ ok: true, message: `IP ${ip} unbanned` });
|
||||
} else {
|
||||
return json({ error: "IP not found in ban list" }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/refresh/:ip
|
||||
const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/);
|
||||
if (refreshMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(refreshMatch[1]);
|
||||
const device = devices.get(ip);
|
||||
if (device) {
|
||||
cacheInvalidate(device.viewId);
|
||||
console.log(`[admin] Forced cache refresh for view: ${device.viewId}`);
|
||||
return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` });
|
||||
} else {
|
||||
return json({ error: "Device not found" }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
return json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
@@ -1373,12 +1564,18 @@ const server = Bun.serve<{ interval: number }>({
|
||||
});
|
||||
}
|
||||
|
||||
deviceTrack(
|
||||
const tracked = deviceTrack(
|
||||
ip,
|
||||
view.id,
|
||||
view.name,
|
||||
req.headers.get("User-Agent") ?? "unknown",
|
||||
);
|
||||
if (!tracked) {
|
||||
return new Response(errorPage("Access denied"), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return serveView(view);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user