harden: relay timeouts, DNS resolver reuse, cache hygiene + frontend polish
Server (relay/SSRF): - Bound connect+TLS+request under CONNECT_TIMEOUT and body download under BODY_TIMEOUT so a stalling upstream can't pin a relay permit indefinitely - Add inbound header_read_timeout to defeat slow-loris connections - Allowlist relayed Content-Type (fall back to octet-stream) to block content-confusion from attacker-controlled stream origins - Reuse one DNS resolver process-wide instead of rebuilding it per request (restores hickory's DNS cache; drops per-segment resolver setup) - Probe cache: random per-process hash seed (DefaultHasher was deterministic) + bounded growth with expired-entry eviction - Real Ctrl-C handler for a clean shutdown (honours the banner) - Collapse the wrap_raw no-op alias into wrap Frontend: - Guide: visible focus ring tracking arrow-key navigation - Player: buffering/tuning indicator while <video> is stalled - prefers-contrast: more pass (lifts --ink-dim to meet WCAG AA) - Service worker: date-stamped cache names so shell updates propagate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+56
-20
@@ -7,9 +7,10 @@
|
||||
mod relay;
|
||||
mod ssrf;
|
||||
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::hash::{BuildHasher, Hash, Hasher};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -36,6 +37,12 @@ use relay::{
|
||||
const MAX_INFLIGHT_RELAY: usize = 32;
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
const PROBE_TTL: Duration = Duration::from_secs(300);
|
||||
/// Bound how long a client may take to send its request headers — defeats
|
||||
/// slow-loris connections that would otherwise hold a Tokio task open forever.
|
||||
const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Hard ceiling on cached probe entries; past this we evict expired ones (and,
|
||||
/// if still full, clear) so the cache can't grow without bound over a long run.
|
||||
const PROBE_CACHE_MAX: usize = 4096;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Metrics {
|
||||
@@ -57,6 +64,10 @@ struct AppState {
|
||||
metrics: Metrics,
|
||||
relay_gate: Semaphore,
|
||||
probe_cache: Mutex<HashMap<u64, ProbeCacheEntry>>,
|
||||
// Random per-process seed for the probe-cache key. DefaultHasher is
|
||||
// deterministic across runs, so an attacker could engineer URL collisions;
|
||||
// RandomState reseeds every boot, making the key unpredictable.
|
||||
cache_hasher: RandomState,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -75,28 +86,44 @@ async fn main() {
|
||||
metrics: Metrics::default(),
|
||||
relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY),
|
||||
probe_cache: Mutex::new(HashMap::new()),
|
||||
cache_hasher: RandomState::new(),
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind(bind).await.expect("bind");
|
||||
eprintln!("The Pondering Orb is gazing on http://{bind} (Ctrl-C to close the eye)");
|
||||
|
||||
loop {
|
||||
let (stream, _peer) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log_line("accept_error", &e.to_string(), None, None);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let io = TokioIo::new(stream);
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req| handle(st.clone(), req));
|
||||
if let Err(e) = http1::Builder::new().serve_connection(io, svc).await {
|
||||
// client disconnects are noise; log at debug granularity only
|
||||
let _ = e;
|
||||
}
|
||||
});
|
||||
let serve = async {
|
||||
loop {
|
||||
let (stream, _peer) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log_line("accept_error", &e.to_string(), None, None);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let io = TokioIo::new(stream);
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req| handle(st.clone(), req));
|
||||
let conn = http1::Builder::new()
|
||||
.header_read_timeout(HEADER_READ_TIMEOUT)
|
||||
.serve_connection(io, svc);
|
||||
if let Err(e) = conn.await {
|
||||
// client disconnects are noise; log at debug granularity only
|
||||
let _ = e;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Honour the banner: a real Ctrl-C handler exits cleanly instead of relying
|
||||
// on the default SIGINT kill (and keeps tokio's `signal` feature in use).
|
||||
tokio::select! {
|
||||
_ = serve => {}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
eprintln!("\nThe eye closes.");
|
||||
log_line("shutdown", "ctrl-c", None, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +244,9 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
|
||||
_ => return json(StatusCode::BAD_REQUEST, "{\"ok\":false,\"status\":0,\"latency_ms\":0}"),
|
||||
};
|
||||
|
||||
// hash the URL for the cache key (never store the raw URL — token hygiene)
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
// hash the URL for the cache key (never store the raw URL — token hygiene).
|
||||
// Random per-process seed → keys aren't attacker-predictable across runs.
|
||||
let mut hasher = state.cache_hasher.build_hasher();
|
||||
raw.hash(&mut hasher);
|
||||
let key = hasher.finish();
|
||||
|
||||
@@ -244,6 +272,14 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
|
||||
state.metrics.probe_dead.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if let Ok(mut cache) = state.probe_cache.lock() {
|
||||
// Bound growth: at the ceiling, drop expired entries first; if that
|
||||
// doesn't free space (all entries still fresh), clear and start over.
|
||||
if cache.len() >= PROBE_CACHE_MAX {
|
||||
cache.retain(|_, (ts, ..)| ts.elapsed() < PROBE_TTL);
|
||||
if cache.len() >= PROBE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
cache.insert(key, (Instant::now(), outcome.ok, outcome.status, outcome.latency_ms));
|
||||
}
|
||||
log_line("probe", if outcome.ok { "live" } else { "dead" }, parsed.host_str(), Some(outcome.latency_ms));
|
||||
|
||||
+83
-41
@@ -25,6 +25,12 @@ const MAX_MANIFEST: usize = 10 * 1024 * 1024; // 10 MB
|
||||
const MAX_SEGMENT: usize = 64 * 1024 * 1024; // 64 MB
|
||||
const MAX_REDIRECTS: u32 = 4;
|
||||
const UA: &str = "PonderingOrb/0.1 (+hardened-relay)";
|
||||
/// Bound connect + TLS handshake + request → response-headers. A stalling
|
||||
/// upstream must never pin one of the relay's permits open indefinitely.
|
||||
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
/// Bound the body download too — the size cap stops a *large* body, but a
|
||||
/// slow-drip body would otherwise hold the permit far longer than that implies.
|
||||
const BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RelayError {
|
||||
@@ -36,6 +42,7 @@ pub enum RelayError {
|
||||
Http(String),
|
||||
TooLarge,
|
||||
TooManyRedirects,
|
||||
Timeout,
|
||||
Upstream(StatusCode),
|
||||
}
|
||||
|
||||
@@ -50,6 +57,7 @@ impl std::fmt::Display for RelayError {
|
||||
RelayError::Http(e) => write!(f, "http: {e}"),
|
||||
RelayError::TooLarge => write!(f, "response exceeds size cap"),
|
||||
RelayError::TooManyRedirects => write!(f, "too many redirects"),
|
||||
RelayError::Timeout => write!(f, "upstream timed out"),
|
||||
RelayError::Upstream(s) => write!(f, "upstream status {s}"),
|
||||
}
|
||||
}
|
||||
@@ -105,43 +113,49 @@ async fn fetch_once(
|
||||
.await
|
||||
.map_err(RelayError::Ssrf)?;
|
||||
|
||||
let tcp = TcpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| RelayError::Connect(e.to_string()))?;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
// Bound connect → TLS → request → response-headers as one unit. A stalling
|
||||
// upstream cannot hold one of the 32 relay permits open past CONNECT_TIMEOUT.
|
||||
let resp = tokio::time::timeout(CONNECT_TIMEOUT, async {
|
||||
let tcp = TcpStream::connect(addr)
|
||||
.await
|
||||
.map_err(|e| RelayError::Connect(e.to_string()))?;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
|
||||
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
|
||||
let tls = connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| RelayError::Tls(e.to_string()))?;
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
|
||||
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
|
||||
let tls = connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| RelayError::Tls(e.to_string()))?;
|
||||
|
||||
let io = TokioIo::new(tls);
|
||||
let (mut sender, conn) = http1::handshake(io)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
let io = TokioIo::new(tls);
|
||||
let (mut sender, conn) = http1::handshake(io)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
|
||||
let mut pq = url.path().to_string();
|
||||
if let Some(q) = url.query() {
|
||||
pq.push('?');
|
||||
pq.push_str(q);
|
||||
}
|
||||
let req = Request::builder()
|
||||
.uri(pq)
|
||||
.header(hyper::header::HOST, host.as_str())
|
||||
.header(hyper::header::USER_AGENT, UA)
|
||||
.header(hyper::header::ACCEPT, "*/*")
|
||||
.body(Empty::<Bytes>::new())
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
let mut pq = url.path().to_string();
|
||||
if let Some(q) = url.query() {
|
||||
pq.push('?');
|
||||
pq.push_str(q);
|
||||
}
|
||||
let req = Request::builder()
|
||||
.uri(pq)
|
||||
.header(hyper::header::HOST, host.as_str())
|
||||
.header(hyper::header::USER_AGENT, UA)
|
||||
.header(hyper::header::ACCEPT, "*/*")
|
||||
.body(Empty::<Bytes>::new())
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
|
||||
let resp = sender
|
||||
.send_request(req)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||
sender
|
||||
.send_request(req)
|
||||
.await
|
||||
.map_err(|e| RelayError::Http(e.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| RelayError::Timeout)??;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
@@ -171,18 +185,48 @@ async fn fetch_once(
|
||||
}
|
||||
|
||||
let limited = Limited::new(resp.into_body(), max_bytes);
|
||||
let collected = limited.collect().await.map_err(|_| RelayError::TooLarge)?;
|
||||
let collected = tokio::time::timeout(BODY_TIMEOUT, limited.collect())
|
||||
.await
|
||||
.map_err(|_| RelayError::Timeout)?
|
||||
.map_err(|_| RelayError::TooLarge)?;
|
||||
let body = collected.to_bytes();
|
||||
|
||||
Ok(Upstream {
|
||||
status,
|
||||
content_type,
|
||||
content_type: sanitize_content_type(&content_type),
|
||||
body,
|
||||
final_url: url.clone(),
|
||||
redirects: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clamp the upstream-declared Content-Type to a known-safe media allowlist.
|
||||
/// An attacker-controlled stream origin could otherwise label segment bytes as
|
||||
/// `text/html` or `application/javascript`; if a tab ever navigates straight to
|
||||
/// a /relay URL, that declared type (not just sniffing, which nosniff blocks)
|
||||
/// would govern interpretation. Anything off-list collapses to octet-stream.
|
||||
fn sanitize_content_type(ct: &str) -> String {
|
||||
// Compare on the bare type, ignoring any `; charset=`/`; codecs=` parameters.
|
||||
let base = ct.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||
const ALLOWED: &[&str] = &[
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/x-mpegurl",
|
||||
"audio/mpegurl",
|
||||
"audio/x-mpegurl",
|
||||
"video/mp2t",
|
||||
"video/mp4",
|
||||
"video/iso.segment",
|
||||
"audio/aac",
|
||||
"audio/mp4",
|
||||
"application/octet-stream",
|
||||
];
|
||||
if ALLOWED.contains(&base.as_str()) {
|
||||
ct.to_string()
|
||||
} else {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry: validated GET with bounded, re-validated redirect following.
|
||||
pub async fn fetch_validated(
|
||||
connector: &TlsConnector,
|
||||
@@ -350,7 +394,7 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
|
||||
Ok(abs) => {
|
||||
let mut s = String::with_capacity(line.len() + 40);
|
||||
s.push_str(&line[..uri_start]);
|
||||
s.push_str(&wrap_raw(&abs));
|
||||
s.push_str(&wrap(&abs));
|
||||
s.push_str(&line[end..]);
|
||||
s
|
||||
}
|
||||
@@ -358,14 +402,12 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// "/relay?url=<pct-encoded absolute>"
|
||||
/// "/relay?url=<pct-encoded absolute>". The percent-encoding leaves only
|
||||
/// RFC3986-unreserved bytes, so the result is safe both on a bare URI line and
|
||||
/// inside an already-quoted `URI="..."` tag attribute (no `"` can appear).
|
||||
fn wrap(abs: &Url) -> String {
|
||||
format!("/relay?url={}", pct(abs.as_str()))
|
||||
}
|
||||
fn wrap_raw(abs: &Url) -> String {
|
||||
// for inside an already-quoted attribute
|
||||
wrap(abs)
|
||||
}
|
||||
|
||||
/// Minimal RFC3986 component percent-encoding (encode everything non-unreserved).
|
||||
fn pct(s: &str) -> String {
|
||||
|
||||
+14
-5
@@ -7,10 +7,23 @@
|
||||
//! between check and connect cannot smuggle us onto an internal address.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
|
||||
/// Built once on first use and reused for the process lifetime. Rebuilding the
|
||||
/// resolver per request re-read /etc/resolv.conf and — worse — discarded
|
||||
/// hickory's in-memory DNS cache, forcing a fresh lookup for every HLS segment.
|
||||
static RESOLVER: OnceLock<TokioAsyncResolver> = OnceLock::new();
|
||||
|
||||
fn resolver() -> &'static TokioAsyncResolver {
|
||||
RESOLVER.get_or_init(|| {
|
||||
TokioAsyncResolver::tokio_from_system_conf()
|
||||
.unwrap_or_else(|_| TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SsrfError {
|
||||
Resolve(String),
|
||||
@@ -78,11 +91,7 @@ pub async fn resolve_and_validate(host: &str, port: u16) -> Result<SocketAddr, S
|
||||
return Ok(SocketAddr::new(ip, port));
|
||||
}
|
||||
|
||||
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
|
||||
Ok(r) => r,
|
||||
Err(_) => TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
|
||||
};
|
||||
let lookup = resolver
|
||||
let lookup = resolver()
|
||||
.lookup_ip(host)
|
||||
.await
|
||||
.map_err(|e| SsrfError::Resolve(e.to_string()))?;
|
||||
|
||||
+9
-3
@@ -112,7 +112,7 @@ export class Guide {
|
||||
|
||||
_renderRow(ch, idx) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'guide-row';
|
||||
row.className = 'guide-row' + (idx === this.focusRow ? ' focused' : '');
|
||||
row.style.position = 'absolute';
|
||||
row.style.top = (idx * ROW_H) + 'px';
|
||||
row.style.left = '0'; row.style.right = '0';
|
||||
@@ -189,12 +189,18 @@ export class Guide {
|
||||
_onKey(e) {
|
||||
const list = this.agg.channels;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._scrollToFocus(); break;
|
||||
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._scrollToFocus(); break;
|
||||
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._moveFocus(); break;
|
||||
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._moveFocus(); break;
|
||||
case 'Enter': e.preventDefault(); { const ch = list[this.focusRow]; if (ch){ this.onTune(ch); this.hide(); } } break;
|
||||
case 'Escape': e.preventDefault(); this.hide(); break;
|
||||
}
|
||||
}
|
||||
// Scroll the focused row into view, then repaint so the .focused highlight
|
||||
// tracks the cursor even when no scroll was needed (scroll event wouldn't fire).
|
||||
_moveFocus() {
|
||||
this._scrollToFocus();
|
||||
this._renderViewport();
|
||||
}
|
||||
_scrollToFocus() {
|
||||
const y = this.focusRow * ROW_H;
|
||||
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
|
||||
|
||||
+21
-1
@@ -27,6 +27,7 @@ export class Player {
|
||||
this._fallbackIdx = 0;
|
||||
this._wireControls();
|
||||
this._wireActivity();
|
||||
this._wireBuffering();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +94,31 @@ export class Player {
|
||||
if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; }
|
||||
this.video.removeAttribute('src');
|
||||
try { this.video.load(); } catch {}
|
||||
this._setBuffering(false); // never leave the indicator stuck across a zap
|
||||
}
|
||||
|
||||
destroy() { this._teardown(); }
|
||||
|
||||
// ---------- buffering / stall indicator ----------
|
||||
// Bridge the gap between the scrying static clearing and the first frame:
|
||||
// without a hint, a slow-loading stream is indistinguishable from a dead one.
|
||||
_wireBuffering() {
|
||||
const v = this.video;
|
||||
const on = () => this._setBuffering(true);
|
||||
const off = () => this._setBuffering(false);
|
||||
v.addEventListener('waiting', on);
|
||||
v.addEventListener('stalled', on);
|
||||
v.addEventListener('playing', off);
|
||||
v.addEventListener('canplay', off);
|
||||
v.addEventListener('pause', off);
|
||||
v.addEventListener('emptied', off);
|
||||
}
|
||||
_setBuffering(on) {
|
||||
if (this.els.screen) this.els.screen.classList.toggle('buffering', on);
|
||||
}
|
||||
|
||||
// ---------- dead-stream "the orb clouds over" ----------
|
||||
_showNoSignal() { this.els.nosignal.hidden = false; }
|
||||
_showNoSignal() { this.els.nosignal.hidden = false; this._setBuffering(false); }
|
||||
_hideNoSignal() { this.els.nosignal.hidden = true; }
|
||||
|
||||
// ---------- live-aware chrome ----------
|
||||
|
||||
@@ -91,6 +91,19 @@ img{display:block}
|
||||
transition:opacity .12s linear}
|
||||
.static.show{opacity:.9}
|
||||
|
||||
/* ===================== buffering / tuning ===================== */
|
||||
/* Corner hint shown while <video> is stalled — top-left keeps clear of the
|
||||
brand bug (top-right) and the channel banner (bottom-left). */
|
||||
.screen.buffering .bezel::after{
|
||||
content:"\21BB tuning\2026";
|
||||
position:absolute;left:18px;top:14px;z-index:7;
|
||||
font-size:12px;letter-spacing:2px;color:var(--red);
|
||||
text-shadow:0 0 10px var(--red-glow);
|
||||
background:var(--osd-bg);border:1px solid var(--line);
|
||||
padding:4px 10px;border-radius:6px;
|
||||
animation:orb-buf 1.1s ease-in-out infinite}
|
||||
@keyframes orb-buf{0%,100%{opacity:.45}50%{opacity:1}}
|
||||
|
||||
/* ===================== the orb clouds over ===================== */
|
||||
.nosignal{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:10px;background:#050102;text-align:center;padding:20px}
|
||||
@@ -155,6 +168,10 @@ img{display:block}
|
||||
.guide-chcell{position:sticky;left:0;z-index:2;flex:0 0 var(--ch-col,150px);display:flex;align-items:center;gap:8px;
|
||||
padding:0 8px;background:var(--panel-2);border-right:1px solid var(--line);cursor:pointer}
|
||||
.guide-chcell:hover{background:var(--red-deep)}
|
||||
/* keyboard cursor: arrow-key navigation marks the focused row so it isn't blind */
|
||||
.guide-row.focused{background:rgba(255,43,43,.07)}
|
||||
.guide-row.focused .guide-chcell{background:var(--red-deep);
|
||||
box-shadow:inset 3px 0 0 var(--red),0 0 12px rgba(255,43,43,.25)}
|
||||
.guide-chcell .num{color:var(--red);font-weight:bold;font-variant-numeric:tabular-nums;min-width:24px}
|
||||
.guide-chcell .nm{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.guide-chcell .skull{margin-left:auto;opacity:.55;font-size:12px}
|
||||
@@ -202,6 +219,19 @@ kbd{background:#000;border:1px solid var(--line);border-bottom-width:2px;border-
|
||||
.brand-bug{display:none}
|
||||
}
|
||||
|
||||
/* ===================== high-contrast pass (a11y) ===================== */
|
||||
/* Default --ink-dim (#c98b86 on #050102 ≈ 3.5:1) misses WCAG AA for the small
|
||||
text it's used on everywhere (OSD sub, guide names, time labels, help dd).
|
||||
Lift it and strengthen panels/borders only when the user asks for it. */
|
||||
@media (prefers-contrast: more){
|
||||
:root{
|
||||
--ink-dim:#ffb8b0;
|
||||
--line:#5a1416;
|
||||
--panel:#1e0608;
|
||||
--panel-2:#260a0c;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== reduced motion kill-switch ===================== */
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.crt-vignette{animation:none}
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
// NEVER cached — the relay marks them no-store and we bypass them here, so the SW
|
||||
// is the sole freshness authority for metadata only.
|
||||
|
||||
const SHELL = 'orb-shell-v1';
|
||||
const DATA = 'orb-data-v1';
|
||||
// Bump these on every shell change so returning visitors actually receive the
|
||||
// new assets — a static 'v1' name would serve stale JS/CSS indefinitely. The
|
||||
// activate handler deletes any cache whose name isn't in this current pair.
|
||||
const SHELL = 'orb-shell-20260615';
|
||||
const DATA = 'orb-data-20260615';
|
||||
|
||||
const SHELL_ASSETS = [
|
||||
'/', '/index.html', '/styles.css',
|
||||
|
||||
Reference in New Issue
Block a user