Files
StasonJatham 62a7d2eb2e provider capability model, on-demand scan/backfill, more providers
Readability capabilities (exists/object_readable/listable) are now tracked
separately instead of a single exposure flag, matching how providers differ:
- Azure 'Blob' mode = object readable, not listable; 'Container' = listable
- Cloudflare R2 public URLs are never anonymously listable (root 500); R2 is
  object-read only and is no longer probed for listing
- S3/GCS: 200 list = listable; 403 + readable index.html/robots.txt =
  object readable only
New columns: cloud_assets.listable, cloud_assets.object_readable.

Azure: account-level container listing is never anonymous; discover containers
by probing a bounded common-name set (or the container from a URL path), then
list blobs.

Classifier: added the legacy AWS '.s3-<region>.amazonaws.com' bucket form.

On-demand scan: POST /api/scan {target} classifies a host or URL, follows CNAME
for custom domains, probes, and indexes readable resources; Azure container
comes from the URL path. UI has a Scan box.

Backfill: POST /api/reprobe re-probes already-discovered assets with the current
checks and indexes any readable ones, recovering findings from earlier probes.

DNS: CNAME-only enrichment (A/AAAA resolved only for matches), 64 workers,
larger queue.

Verified live against real public resources:
  veteran1.nyc3.digitaloceanspaces.com            listable, entries
  bucket-01.s3-eu-west-1.amazonaws.com            listable, entries
  tartugeo-podcast.storage.googleapis.com         listable, entries
  eroticfeeldev.blob.core.windows.net/images      listable, entries
2026-09-22 14:47:49 +02:00

464 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bucketeer · CT resource monitor</title>
<link rel="icon" href="/assets/favicon.png">
<script>/* set theme before first paint to avoid a flash */
try{var t=localStorage.getItem('bucketeer-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}</script>
<style>
/* Tokens from DESIGN.md. Neutral-first, no shadows, system fonts, light only. */
:root {
--primary:#262626; --on-primary:#fff; --primary-hover:#404040; --primary-active:#171717;
--text-primary:#000; --text-secondary:#525252; --text-muted:#737373;
--canvas:#fff; --surface-subtle:#fafafa; --surface-control:#f2f2f2; --surface-hover:#ededed;
--border:#e5e5e5; --border-strong:#d4d4d4; --focus:#2563eb;
--cap-bg:#eef2ff; --cap-text:#4338ca; --size-bg:#e0f2fe; --size-text:#075985;
--err-bg:#fef2f2; --err-text:#b91c1c; --ok-bg:#f0fdf4; --ok-text:#166534;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--page-gutter:16px;
color-scheme: light dark;
}
/* Dark palette (DESIGN.md specifies light only; this is an explicit product
decision). Neutral-first, same hierarchy, contrast-preserving. */
[data-theme=dark]{
--primary:#e7e9ee; --on-primary:#14161a; --primary-hover:#d3d7de; --primary-active:#ffffff;
--text-primary:#f4f5f7; --text-secondary:#b3b9c4; --text-muted:#858d9a;
--canvas:#0f1115; --surface-subtle:#171a20; --surface-control:#1c2027; --surface-hover:#252b35;
--border:#2a2f39; --border-strong:#3a414e; --focus:#69a8ff;
--cap-bg:#1e2440; --cap-text:#aab5ff; --size-bg:#0e2a3a; --size-text:#7cc4ff;
--err-bg:#3a1616; --err-text:#ffb4b4; --ok-bg:#0f2a1a; --ok-text:#7ee2a8;
}
*,*::before,*::after{box-sizing:border-box}
body{margin:0;background:var(--canvas);color:var(--text-primary);font:400 16px/24px var(--font);-webkit-font-smoothing:antialiased}
h1{margin:0;font:500 24px/32px var(--font);letter-spacing:-.01em}
a{color:inherit}
.site-shell,.search-layout{width:calc(100% - 2*var(--page-gutter));margin-inline:auto}
.site-shell{max-width:1280px}
.search-layout{max-width:768px}
@media (min-width:640px){:root{--page-gutter:24px}}
@media (min-width:1024px){:root{--page-gutter:32px}}
header{border-bottom:1px solid var(--border)}
.header{height:72px;display:flex;align-items:center;gap:24px}
.brand{display:flex;align-items:center;gap:8px;font:500 18px/28px var(--font);text-decoration:none}
.brand-logo{width:33px;height:30px;display:block;
background:url('/assets/logo-light.webp') center/contain no-repeat}
[data-theme=dark] .brand-logo{background-image:url('/assets/logo-dark.webp')}
.theme-btn{width:40px;height:40px;display:grid;place-items:center;background:transparent;border:1px solid var(--border);
border-radius:9999px;color:var(--text-secondary);cursor:pointer;padding:0}
.theme-btn:hover{background:var(--surface-hover);color:var(--text-primary)}
.theme-btn svg{width:18px;height:18px;display:block}
nav{display:flex;gap:16px;font:400 18px/28px var(--font)}
nav a{text-decoration:none;color:var(--text-secondary)}
nav a[aria-current=page]{color:var(--text-primary);text-decoration:underline;text-underline-offset:6px}
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.live{display:inline-flex;align-items:center;gap:6px;font:400 14px/20px var(--font);color:var(--text-muted)}
.dot{width:8px;height:8px;border-radius:9999px;background:var(--ok-text)}
.dot.paused{background:var(--text-muted)}
main{padding:48px 0 80px}
.query{position:relative;display:flex;align-items:center}
.query input{width:100%;height:48px;border:1px solid transparent;border-radius:9999px;background:var(--surface-control);
color:var(--text-primary);font:400 16px/24px var(--font);padding:0 48px 0 20px}
.query input::placeholder{color:var(--text-secondary)}
.query input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
.query .clear{position:absolute;right:4px;border:0;background:transparent;color:var(--text-secondary);
width:40px;height:40px;border-radius:9999px;cursor:pointer;font-size:18px;display:none}
.query .clear.show{display:block}
.query .clear:hover{background:var(--surface-hover);color:var(--text-primary)}
.controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:16px}
.filters{display:flex;gap:8px;flex-wrap:wrap}
.chip{height:32px;padding:0 12px;border:1px solid var(--border-strong);border-radius:9999px;
background:var(--surface-control);color:var(--text-secondary);font:400 14px/20px var(--font);
cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.chip:hover{background:var(--surface-hover);color:var(--text-primary)}
.chip[aria-pressed=true]{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
.sort{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--text-secondary);font:400 14px/20px var(--font)}
select{height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--canvas);
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 12px}
.scan{display:flex;gap:8px;margin-top:16px}
.scan input{flex:1;height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--surface-control);
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 16px}
.scan input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
.scan button{min-height:40px;padding:0 20px;border-radius:9999px;border:1px solid var(--primary);
background:var(--primary);color:var(--on-primary);font:500 14px/20px var(--font);cursor:pointer}
.notice{margin-top:16px;padding:12px;border-radius:8px;background:var(--surface-subtle);border:1px solid var(--border)}
.notice-ok{background:var(--ok-bg);border-color:transparent}
.samples{margin-top:8px;border-top:1px solid var(--border);padding-top:8px}
.sample{color:var(--text-secondary);font-size:13px;overflow-wrap:anywhere}
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-top:24px;color:var(--text-muted);font:400 14px/20px var(--font)}
.summary strong{color:var(--text-primary);font-weight:500}
ul.result-list{list-style:none;margin:0;padding:0}
/* Native virtualization: off-screen rows are not laid out or painted. */
.result-row{padding-block:24px;border-bottom:1px solid var(--border);
content-visibility:auto;contain-intrinsic-size:auto 128px}
.result-head{display:flex;align-items:flex-start;gap:12px;min-width:0}
.result-title{font:500 20px/28px var(--font);color:var(--text-primary);text-decoration:none;overflow-wrap:anywhere}
.result-title:hover{text-decoration:underline}
.result-description{margin:4px 0 0;color:var(--text-secondary);font:400 16px/24px var(--font);overflow-wrap:anywhere}
.badge-group{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.result-meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin-top:12px;color:var(--text-muted);font:400 14px/20px var(--font)}
.badge{font:500 12px/16px var(--font);padding:2px 6px;border-radius:4px;background:var(--surface-control);color:var(--text-secondary);white-space:nowrap}
.badge-cap{background:var(--cap-bg);color:var(--cap-text)}
.badge-size{background:var(--size-bg);color:var(--size-text)}
.badge-ok{background:var(--ok-bg);color:var(--ok-text)}
.badge-err{background:var(--err-bg);color:var(--err-text)}
.mono{font-family:var(--mono);font-size:14px}
.empty{padding:32px 0;color:var(--text-secondary)}
.error{padding:12px;border-radius:8px;background:var(--err-bg);color:var(--err-text);font:400 14px/20px var(--font)}
.skeleton{height:16px;border-radius:6px;background:var(--surface-control);margin:12px 0}
.skeleton.w60{width:60%}.skeleton.w40{width:40%}
.more{display:flex;justify-content:center;margin-top:24px}
.more button{min-height:40px;padding:10px 20px;border-radius:9999px;border:1px solid var(--border-strong);
background:var(--canvas);color:var(--text-primary);font:500 14px/20px var(--font);cursor:pointer}
.more button:hover{background:var(--surface-hover)}
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
footer{color:var(--text-muted);font:400 14px/20px var(--font);padding:0 0 64px}
footer p{max-width:768px;margin-inline:auto}
:where(a,button,input,select,[tabindex]):focus-visible{outline:2px solid var(--focus);outline-offset:3px}
@media (pointer:coarse){.chip,.clear,.more button{min-height:44px}.chip{min-width:44px}}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<header>
<div class="site-shell header">
<a class="brand" href="/"><span class="brand-logo" role="img" aria-label="Bucketeer"></span>Bucketeer</a>
<nav aria-label="Primary">
<a href="?view=assets" id="nav-assets" aria-current="page">Assets</a>
<a href="?view=contents" id="nav-contents">Contents</a>
<a href="/metrics">Metrics</a>
<a href="/healthz">Health</a>
</nav>
<div class="header-right">
<span class="live" id="live"><span class="dot" id="livedot"></span><span id="livetext">live · 15s</span></span>
<button class="theme-btn" id="themebtn" type="button" aria-label="Switch to dark mode"></button>
</div>
</div>
</header>
<main>
<div class="search-layout">
<h1 class="visually-hidden">Cloud resources discovered in Certificate Transparency</h1>
<div class="query">
<label class="visually-hidden" for="q">Search</label>
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search hostname, bucket, database…">
<button class="clear" id="clear" type="button" aria-label="Clear search">×</button>
</div>
<form class="scan" id="scanform">
<label class="visually-hidden" for="scan">Scan a host or URL</label>
<input id="scan" type="text" autocomplete="off" spellcheck="false"
placeholder="Scan a host or URL (bucket, Azure container path, custom domain)…">
<button type="submit">Scan</button>
</form>
<div id="scanout" hidden></div>
<div class="controls" id="asset-controls">
<div class="filters" id="filters" role="group" aria-label="Filter by exposure">
<button class="chip" data-exposure="" aria-pressed="true">All</button>
<button class="chip" data-exposure="public_read" aria-pressed="false">Public read</button>
<button class="chip" data-exposure="reachable" aria-pressed="false">Reachable</button>
<button class="chip" data-exposure="auth_required" aria-pressed="false">Auth required</button>
<button class="chip" data-exposure="not_found" aria-pressed="false">Not found</button>
<button class="chip" data-exposure="private" aria-pressed="false">Private</button>
</div>
<div class="sort">
<label for="sort">Sort</label>
<select id="sort">
<option value="exposure">Exposure</option>
<option value="last_seen">Recently seen</option>
<option value="first_seen">Newest</option>
<option value="provider">Provider</option>
<option value="hostname">Hostname</option>
</select>
</div>
</div>
<div class="summary" id="summary" aria-live="polite"></div>
<div id="state"></div>
<ul class="result-list" id="results"></ul>
<div class="more" id="more" hidden><button id="morebtn" type="button">Load more</button></div>
</div>
</main>
<footer>
<p>
A detected endpoint is not automatically accessible. <span class="badge badge-ok">public_read</span>
means an anonymous read succeeded at probe time and readable resources are indexed below (keys and
sizes only, never contents). Databases are only reported <span class="badge badge-size">reachable</span>
unless an unauthenticated protocol read was explicitly enabled; no credentials are ever sent.
</p>
</footer>
<script>
const PAGE = 100, REFRESH_MS = 15000;
const $ = (id) => document.getElementById(id);
const params = new URLSearchParams(location.search);
const state = {
view: params.get('view') === 'contents' ? 'contents' : 'assets',
q: params.get('q') || '',
exposure: params.get('exposure') || '',
sort: params.get('sort') || 'exposure',
assets: [], assetsTotal: 0, assetsOffset: 0,
entries: [], entriesTotal: 0, entriesOffset: 0,
loading: false, abort: null, timer: null, countdown: REFRESH_MS / 1000, ok: true,
};
$('q').value = state.q;
$('sort').value = state.sort;
$('clear').classList.toggle('show', state.q.length > 0);
document.querySelectorAll('#filters .chip').forEach((c) =>
c.setAttribute('aria-pressed', String(c.dataset.exposure === state.exposure)));
const SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/></svg>';
const MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
const currentTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
function setTheme(t, persist) {
document.documentElement.setAttribute('data-theme', t);
if (persist) { try { localStorage.setItem('bucketeer-theme', t); } catch (e) {} }
$('themebtn').innerHTML = t === 'dark' ? SUN : MOON;
$('themebtn').setAttribute('aria-label', t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
}
setTheme(currentTheme(), false);
$('themebtn').onclick = () => setTheme(currentTheme() === 'dark' ? 'light' : 'dark', true);
if (window.matchMedia) matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
try { if (!localStorage.getItem('bucketeer-theme')) setTheme(e.matches ? 'dark' : 'light', false); } catch (_) {}
});
const esc = (s) => (s ?? '').toString().replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const key = (a) => a.provider + '|' + a.service + '|' + a.provider_hostname;
const badge = (v, cls) => `<span class="badge ${cls || ''}">${esc(v)}</span>`;
function exposureBadge(v) {
const cls = v === 'public_read' ? 'badge-ok' : v === 'reachable' ? 'badge-size'
: (v === 'not_found' || v === 'private') ? 'badge-err' : '';
return badge(v || 'unknown', cls);
}
function ago(ts) {
if (!ts) return null;
const s = Math.max(0, (Date.now() - new Date(ts)) / 1000);
if (s < 60) return Math.floor(s) + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function bytes(n) {
if (!n) return '';
const u = ['B','KB','MB','GB','TB']; let i = 0, v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
}
function timeEl(ts, label) {
const a = ago(ts);
return a ? `<span>${label} <time datetime="${esc(ts)}">${a}</time></span>` : '';
}
function assetRow(a) {
const meta = [];
if (a.region) meta.push(`<span>${esc(a.region)}</span>`);
if (a.hostname && a.hostname !== a.provider_hostname) meta.push(`<span>CT host <span class="mono">${esc(a.hostname)}</span></span>`);
meta.push(timeEl(a.last_seen, 'seen'));
meta.push(timeEl(a.probed_at, 'probed'));
return `<li class="result-row">
<div class="result-head">
<a class="result-title" href="https://${esc(a.provider_hostname)}/" target="_blank" rel="noopener noreferrer nofollow">${esc(a.provider_hostname)}</a>
${exposureBadge(a.exposure_state)}
</div>
${a.exposure_detail ? `<p class="result-description">${esc(a.exposure_detail)}</p>` : ''}
<div class="badge-group">
${badge(a.provider, 'badge-cap')}${badge(a.service, 'badge-size')}
${a.listable ? badge('listable', 'badge-ok') : (a.object_readable ? badge('object readable', 'badge-ok') : '')}
${a.resource_identifier ? badge(a.resource_identifier) : ''}
${a.public_semantic && a.public_semantic !== 'none' ? badge(a.public_semantic) : ''}
</div>
<div class="result-meta">${meta.filter(Boolean).join('')}</div>
</li>`;
}
function entryRow(e) {
const meta = [timeEl(e.indexed_at, 'indexed')];
if (e.size) meta.push(`<span>${bytes(e.size)}</span>`);
if (e.content_type) meta.push(`<span>${esc(e.content_type)}</span>`);
return `<li class="result-row">
<div class="result-head">
<span class="result-title mono">${esc(e.name)}</span>
${badge(e.kind)}
</div>
<div class="badge-group">
${badge(e.provider, 'badge-cap')}${badge(e.service, 'badge-size')}
<span class="badge mono">${esc(e.provider_hostname)}</span>
</div>
<div class="result-meta">${meta.join('')}</div>
</li>`;
}
function render() {
const ul = $('results');
if (state.view === 'assets') {
ul.innerHTML = state.assets.map(assetRow).join('');
if (!state.assets.length) showEmpty('No assets match.');
} else {
ul.innerHTML = state.entries.map(entryRow).join('');
if (!state.entries.length) showEmpty('No indexed contents match. Only resources confirmed publicly readable are indexed.');
}
const loaded = state.view === 'assets' ? state.assets.length : state.entries.length;
const total = state.view === 'assets' ? state.assetsTotal : state.entriesTotal;
$('more').hidden = loaded >= total;
$('morebtn').textContent = `Load more (${loaded} of ${total})`;
}
function showEmpty(msg) {
$('state').innerHTML = `<div class="empty">${esc(msg)}${state.q ? '' : ''}</div>`;
}
async function fetchJSON(url, signal) {
const r = await fetch(url, { signal });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
async function loadAssets(reset) {
if (reset) { state.assets = []; state.assetsOffset = 0; }
const p = new URLSearchParams({ q: state.q, exposure: state.exposure, sort: state.sort,
limit: String(PAGE), offset: String(state.assetsOffset) });
const data = await fetchJSON('api/assets?' + p);
mergeAssets(data.assets || []);
state.assetsTotal = data.total || 0;
state.assetsOffset = state.assets.length;
}
function mergeAssets(rows) {
const seen = new Set(state.assets.map(key));
for (const a of rows) { const k = key(a); if (!seen.has(k)) { seen.add(k); state.assets.push(a); } }
if (state.sort === 'hostname') state.assets.sort((a, b) => a.provider_hostname.localeCompare(b.provider_hostname));
}
async function loadEntries(reset) {
if (reset) { state.entries = []; state.entriesOffset = 0; }
const p = new URLSearchParams({ q: state.q, limit: String(PAGE), offset: String(state.entriesOffset) });
const data = await fetchJSON('api/entries?' + p);
const seen = new Set(state.entries.map((e) => e.id));
for (const e of (data.entries || [])) if (!seen.has(e.id)) state.entries.push(e);
state.entriesTotal = data.total || 0;
state.entriesOffset = state.entries.length;
}
async function reload(showSkeleton) {
if (state.abort) state.abort.abort();
const ac = new AbortController();
state.abort = ac;
if (showSkeleton) $('state').innerHTML = '<div class="skeleton w60"></div><div class="skeleton w40"></div><div class="skeleton w60"></div>';
try {
if (state.view === 'assets') await loadAssets(true); else await loadEntries(true);
$('state').innerHTML = '';
render();
await loadStats();
setLive(true);
} catch (e) {
if (e.name === 'AbortError') return;
$('state').innerHTML = `<div class="error">Could not load data (${esc(e.message)}). Retrying in 15s.</div>`;
setLive(false);
}
}
async function loadStats() {
const s = await fetchJSON('api/stats');
const readable = (s.by_exposure || []).find((x) => x.label === 'public_read');
$('summary').innerHTML =
`<span><strong>${s.total ?? 0}</strong> assets</span>` +
`<span><strong>${readable ? readable.count : 0}</strong> publicly readable</span>` +
`<span><strong>${s.reachable ?? 0}</strong> reachable endpoints</span>` +
`<span><strong>${s.entries ?? 0}</strong> indexed items</span>`;
}
function setLive(ok) {
state.ok = ok;
$('livedot').classList.toggle('paused', !ok);
$('livetext').textContent = (ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}
function syncURL() {
const p = new URLSearchParams();
p.set('view', state.view);
if (state.q) p.set('q', state.q);
if (state.view === 'assets' && state.exposure) p.set('exposure', state.exposure);
if (state.view === 'assets' && state.sort !== 'exposure') p.set('sort', state.sort);
history.replaceState(null, '', '?' + p);
$('nav-assets').setAttribute('aria-current', state.view === 'assets' ? 'page' : 'false');
$('nav-contents').setAttribute('aria-current', state.view === 'contents' ? 'page' : 'false');
$('asset-controls').hidden = state.view !== 'assets';
$('q').placeholder = state.view === 'assets' ? 'Search hostname, bucket, database…' : 'Search indexed contents…';
}
function scanResult(r) {
if (r.error) return `<div class="error">${esc(r.host || r.target)}: ${esc(r.error)}</div>`;
const samples = (r.sample || []).map((s) => `<div class="mono sample">${esc(s)}</div>`).join('');
const chain = r.cname_chain && r.cname_chain.length > 1
? `<div class="result-meta"><span>chain: ${r.cname_chain.map(esc).join(' → ')}</span></div>` : '';
return `<div class="notice ${r.exposure === 'public_read' ? 'notice-ok' : ''}">
<div class="result-head">
<span class="result-title mono">${esc(r.host)}</span>${exposureBadge(r.exposure)}
</div>
${r.detail ? `<p class="result-description">${esc(r.detail)}</p>` : ''}
<div class="badge-group">${badge(r.provider, 'badge-cap')}${badge(r.service, 'badge-size')}${badge((r.entries || 0) + ' entries')}</div>
${chain}${samples ? `<div class="samples">${samples}</div>` : ''}
</div>`;
}
$('scanform').addEventListener('submit', async (e) => {
e.preventDefault();
const target = $('scan').value.trim();
if (!target) return;
const out = $('scanout');
out.hidden = false;
out.innerHTML = '<div class="notice"><div class="skeleton w40"></div></div>';
try {
const resp = await fetch('api/scan', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target }),
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const rows = await resp.json() || [];
out.innerHTML = rows.map(scanResult).join('');
if (rows.some((r) => r.entries > 0 || r.exposure === 'public_read')) reload(false);
} catch (err) {
out.innerHTML = `<div class="error">Scan failed: ${esc(err.message)}</div>`;
}
});
function setView(v) { state.view = v; syncURL(); reload(true); }
$('nav-assets').onclick = (e) => { e.preventDefault(); setView('assets'); };
$('nav-contents').onclick = (e) => { e.preventDefault(); setView('contents'); };
$('morebtn').onclick = async () => {
if (state.loading) return;
state.loading = true; $('morebtn').disabled = true;
try { if (state.view === 'assets') await loadAssets(false); else await loadEntries(false); render(); }
finally { state.loading = false; $('morebtn').disabled = false; }
};
let debounce;
$('q').addEventListener('input', () => {
state.q = $('q').value.trim();
$('clear').classList.toggle('show', state.q.length > 0);
syncURL();
clearTimeout(debounce);
debounce = setTimeout(() => reload(false), 250);
});
$('clear').onclick = () => { $('q').value = ''; state.q = ''; $('clear').classList.remove('show'); syncURL(); reload(true); $('q').focus(); };
document.querySelectorAll('#filters .chip').forEach((c) => c.onclick = () => {
state.exposure = c.dataset.exposure;
document.querySelectorAll('#filters .chip').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.exposure === state.exposure)));
syncURL(); reload(true);
});
$('sort').onchange = () => { state.sort = $('sort').value; syncURL(); reload(true); };
document.addEventListener('visibilitychange', () => { if (!document.hidden) reload(false); });
function schedule() {
clearInterval(state.timer);
state.timer = setInterval(() => {
if (document.hidden) return;
state.countdown--;
if (state.countdown <= 0) { state.countdown = REFRESH_MS / 1000; reload(false); }
$('livetext').textContent = (state.ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}, 1000);
}
syncURL();
reload(true);
schedule();
</script>
</body>
</html>