Deutschlandfunk stayed silent: its signed URL answers 302 to f111.rndfnk.com, an origin the signed media-src never listed, so the browser blocked the redirected load and reported only the station's own URL in securitypolicyviolation. The hub now resolves each https stream's redirect chain server side when a settings save adds or changes it and stores the extra origins as the stream's via list. media-src lists them next to the signed origins. A CDN host of three or more labels widens to its siblings (*.rndfnk.com); an IP, a two-label host and a name under a country code's generic second level stay exact. Hardening: - Only the signed URL itself may lie at a non-global address; every redirect hop dials a global address only, checked on the address actually dialed (NAT64 and IPv4-compatible IPv6 count by the embedded IPv4 address). - A resolve answers within three socket timeouts even for a server that drips its headers; a late probe goes on in a daemon thread and a url still in flight is not probed twice. - A failed resolution stores via null and retries on a save ten minutes later; the radio page says so instead of blaming the CSP. - A save whose settings changed while it was resolved fails instead of overwriting them (compare and set under the settings lock). - A new stream URL at an already signed origin takes the passkey too. - Settings written before via existed load as via [] and never dial. Residuals: - The passkey proof is spent before the resolve, so a save racing another one fails and the operator signs again. - A save that hits a due retry blocks up to twelve seconds and may reload the page. - A page loaded before the upgrade throws on the missing via until it reloads. - A private multi-tenant suffix such as github.io still widens. - getaddrinfo has no timeout; it runs inside the daemon thread. Operator step after install: remove Deutschlandfunk in Radio, Sender bearbeiten, save, add it again and save with the passkey. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 88 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
9.8 KiB
JavaScript
229 lines
9.8 KiB
JavaScript
const Ambience = (() => {
|
|
"use strict";
|
|
const RAD = Math.PI / 180;
|
|
const DAY_MS = 86400000;
|
|
const J2000 = 2451545;
|
|
const AU = 149597870.7;
|
|
const EARTH = 6378.14;
|
|
const GLASS = 0.8;
|
|
const CARD = 0.92;
|
|
const LIGHT_FROM = 4;
|
|
// Trip-wire: one blocked redirect also fails the media element and rejects play(), in no fixed order; the highest rank is the cause, the rest are its symptoms.
|
|
const TROUBLE_RANKS = {refused: 0, media: 1, csp: 2};
|
|
const SKY = [
|
|
[-90, "#03050b", "#070a14"],
|
|
[-18, "#04060e", "#0a0f1f"],
|
|
[-12, "#080d1f", "#18203d"],
|
|
[-6, "#111c3d", "#3b3763"],
|
|
[-3, "#1c2b5a", "#8a5b6c"],
|
|
[0, "#2b4377", "#de8a5e"],
|
|
[3, "#44679f", "#efab6d"],
|
|
[6, "#5884bf", "#f1c690"],
|
|
[12, "#5d92d0", "#c8dbee"],
|
|
[30, "#4e89d5", "#b4d2f1"],
|
|
[90, "#3f7fd8", "#a9cbef"],
|
|
];
|
|
const STAGES = [[-18, "night"], [-12, "astronomical"], [-6, "nautical"], [0, "civil"], [6, "golden"], [Infinity, "day"]];
|
|
const THEMES = {
|
|
light: {
|
|
bg: "#eef2f7", panel: "#ffffff", sunken: "#f2f4f8", hover: "#eaeef5", text: "#121821", muted: "#434e5f", faint: "#6b7586",
|
|
line: "#dde3eb", "line-strong": "#c7cfda", accent: "#2f55d4", "accent-soft": "#e6ecfd", "on-accent": "#ffffff",
|
|
ok: "#16804a", "ok-soft": "#e3f3ea", bad: "#c2352a", "bad-soft": "#fcebea", warn: "#8a5200", "warn-soft": "#fff1d9",
|
|
info: "#2a5bd0", "info-soft": "#e7eefc", violet: "#6f3fc4", "violet-soft": "#f0e9fb", teal: "#0b7f78", "teal-soft": "#def2f0",
|
|
"neutral-soft": "#eceff4", direct: "#3a6ff7", broadcast: "#12a39a", alarm: "#e0453a", security: "#8a4bd8", grant: "#2f9e58",
|
|
"add-bg": "#e7f6ec", "add-text": "#14632f", "del-bg": "#fdeeed", "del-text": "#9b2219", "hunk-bg": "#edf1fd",
|
|
shadow: "0 1px 2px rgb(15 23 42 / 6%), 0 4px 14px rgb(15 23 42 / 6%)", "shadow-lift": "0 12px 36px rgb(15 23 42 / 16%)",
|
|
},
|
|
dark: {
|
|
bg: "#0b0f15", panel: "#121821", sunken: "#0e131a", hover: "#1a212b", text: "#e7ebf2", muted: "#a3adbb", faint: "#6f7988",
|
|
line: "#252d38", "line-strong": "#343e4b", accent: "#8fadff", "accent-soft": "#1b2440", "on-accent": "#0b1020",
|
|
ok: "#4cc47e", "ok-soft": "#12291c", bad: "#ff7b72", "bad-soft": "#341614", warn: "#f2b84b", "warn-soft": "#33260f",
|
|
info: "#7da5ff", "info-soft": "#18233f", violet: "#b994ff", "violet-soft": "#251a3b", teal: "#43c9bf", "teal-soft": "#0f2a28",
|
|
"neutral-soft": "#1d242e", direct: "#6f9bff", broadcast: "#3cc7bd", alarm: "#ff6b61", security: "#b58cff", grant: "#4cc97a",
|
|
"add-bg": "#0f2a1a", "add-text": "#8fe0aa", "del-bg": "#33151a", "del-text": "#ffaaa3", "hunk-bg": "#18213a",
|
|
shadow: "0 1px 2px rgb(0 0 0 / 40%), 0 6px 18px rgb(0 0 0 / 28%)", "shadow-lift": "0 16px 44px rgb(0 0 0 / 55%)",
|
|
},
|
|
};
|
|
const sin = (deg) => Math.sin(deg * RAD);
|
|
const cos = (deg) => Math.cos(deg * RAD);
|
|
const tan = (deg) => Math.tan(deg * RAD);
|
|
const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
|
|
const asin = (value) => Math.asin(clamp(value, -1, 1)) / RAD;
|
|
const acos = (value) => Math.acos(clamp(value, -1, 1)) / RAD;
|
|
const atan2 = (y, x) => Math.atan2(y, x) / RAD;
|
|
const wrap = (deg) => ((deg % 360) + 360) % 360;
|
|
|
|
function julian(ms) {
|
|
return ms / DAY_MS + 2440587.5;
|
|
}
|
|
|
|
function centuries(ms) {
|
|
return (julian(ms) - J2000) / 36525;
|
|
}
|
|
|
|
function sidereal(ms) {
|
|
const days = julian(ms) - J2000;
|
|
const t = days / 36525;
|
|
return wrap(280.46061837 + 360.98564736629 * days + 0.000387933 * t * t - (t * t * t) / 38710000);
|
|
}
|
|
|
|
function obliquity(t) {
|
|
const omega = 125.04 - 1934.136 * t;
|
|
return 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60 + 0.00256 * cos(omega);
|
|
}
|
|
|
|
function equatorial(lambda, beta, eps) {
|
|
return {
|
|
ra: wrap(atan2(sin(lambda) * cos(eps) - tan(beta) * sin(eps), cos(lambda))),
|
|
dec: asin(sin(beta) * cos(eps) + cos(beta) * sin(eps) * sin(lambda)),
|
|
};
|
|
}
|
|
|
|
function horizontal(ha, dec, lat) {
|
|
return {
|
|
alt: asin(sin(lat) * sin(dec) + cos(lat) * cos(dec) * cos(ha)),
|
|
az: wrap(atan2(sin(ha), cos(ha) * sin(lat) - tan(dec) * cos(lat)) + 180),
|
|
};
|
|
}
|
|
|
|
function sun(ms, lat, lon) {
|
|
const t = centuries(ms);
|
|
const l0 = wrap(280.46646 + t * (36000.76983 + t * 0.0003032));
|
|
const m = 357.52911 + t * (35999.05029 - 0.0001537 * t);
|
|
const e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);
|
|
const center = sin(m) * (1.914602 - t * (0.004817 + 0.000014 * t)) + sin(2 * m) * (0.019993 - 0.000101 * t) + sin(3 * m) * 0.000289;
|
|
const lambda = wrap(l0 + center - 0.00569 - 0.00478 * sin(125.04 - 1934.136 * t));
|
|
const eps = obliquity(t);
|
|
const place = equatorial(lambda, 0, eps);
|
|
const y = tan(eps / 2) ** 2;
|
|
const eot = (4 * (y * sin(2 * l0) - 2 * e * sin(m) + 4 * e * y * sin(m) * cos(2 * l0) - 0.5 * y * y * sin(4 * l0) - 1.25 * e * e * sin(2 * m))) / RAD;
|
|
const distance = (1.000001018 * (1 - e * e)) / (1 + e * cos(m + center));
|
|
const minutes = (((ms % DAY_MS) + DAY_MS) % DAY_MS) / 60000;
|
|
const ha = (minutes + eot + 4 * lon) / 4 - 180;
|
|
return {...place, lambda, eot, distance, ha, ...horizontal(ha, place.dec, lat)};
|
|
}
|
|
|
|
function moon(ms, lat, lon) {
|
|
const t = centuries(ms);
|
|
const anomaly = 134.9 + 477198.85 * t;
|
|
const lambda = wrap(218.32 + 481267.881 * t + 6.29 * sin(anomaly) - 1.27 * sin(259.2 - 413335.38 * t) + 0.66 * sin(235.7 + 890534.23 * t)
|
|
+ 0.21 * sin(269.9 + 954397.7 * t) - 0.19 * sin(357.5 + 35999.05 * t) - 0.11 * sin(186.6 + 966404.05 * t));
|
|
const beta = 5.13 * sin(93.3 + 483202.03 * t) + 0.28 * sin(228.2 + 960400.87 * t) - 0.28 * sin(318.3 + 6003.18 * t) - 0.17 * sin(217.6 - 407332.2 * t);
|
|
const parallax = 0.9508 + 0.0518 * cos(anomaly) + 0.0095 * cos(259.2 - 413335.38 * t) + 0.0078 * cos(235.7 + 890534.23 * t) + 0.0028 * cos(269.9 + 954397.7 * t);
|
|
const place = equatorial(lambda, beta, obliquity(t));
|
|
const distance = EARTH / sin(parallax);
|
|
const ha = sidereal(ms) + lon - place.ra;
|
|
const seen = horizontal(ha, place.dec, lat);
|
|
const light = sun(ms, lat, lon);
|
|
const dra = light.ra - place.ra;
|
|
const elongation = acos(sin(light.dec) * sin(place.dec) + cos(light.dec) * cos(place.dec) * cos(dra));
|
|
const phase = atan2(light.distance * AU * sin(elongation), distance - light.distance * AU * cos(elongation));
|
|
const limb = wrap(atan2(cos(light.dec) * sin(dra), sin(light.dec) * cos(place.dec) - cos(light.dec) * sin(place.dec) * cos(dra)));
|
|
const parallactic = atan2(sin(ha), tan(lat) * cos(place.dec) - sin(place.dec) * cos(ha));
|
|
return {
|
|
...place, lambda, beta, distance, phase, limb,
|
|
alt: seen.alt - parallax * cos(seen.alt),
|
|
az: seen.az,
|
|
lit: (1 + cos(phase)) / 2,
|
|
tilt: wrap(limb - parallactic),
|
|
waxing: wrap(lambda - light.lambda) < 180,
|
|
};
|
|
}
|
|
|
|
function hex(text) {
|
|
return [1, 3, 5].map((index) => parseInt(text.slice(index, index + 2), 16));
|
|
}
|
|
|
|
function mix(from, to, share) {
|
|
return from.map((value, index) => value + (to[index] - value) * share);
|
|
}
|
|
|
|
function css(rgb) {
|
|
return `rgb(${rgb.map((value) => Math.round(value)).join(" ")})`;
|
|
}
|
|
|
|
function luminance(rgb) {
|
|
const [r, g, b] = rgb.map((value) => {
|
|
const channel = value / 255;
|
|
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
}
|
|
|
|
function contrast(first, second) {
|
|
const [high, low] = [luminance(first), luminance(second)].sort((a, b) => b - a);
|
|
return (high + 0.05) / (low + 0.05);
|
|
}
|
|
|
|
function skyAt(alt) {
|
|
const level = clamp(alt, SKY[0][0], SKY[SKY.length - 1][0]);
|
|
const index = Math.max(SKY.findIndex(([edge]) => edge >= level), 1);
|
|
const [low, lowTop, lowBottom] = SKY[index - 1];
|
|
const [high, highTop, highBottom] = SKY[index];
|
|
const share = (level - low) / (high - low);
|
|
return {top: mix(hex(lowTop), hex(highTop), share), bottom: mix(hex(lowBottom), hex(highBottom), share)};
|
|
}
|
|
|
|
function stage(alt) {
|
|
return STAGES.find(([edge]) => alt < edge)[1];
|
|
}
|
|
|
|
function starlight(alt) {
|
|
return clamp((-alt - 6) / 10, 0, 1);
|
|
}
|
|
|
|
function modeAt(alt) {
|
|
return alt >= LIGHT_FROM ? "light" : "dark";
|
|
}
|
|
|
|
function modeOf(top, bottom) {
|
|
return luminance(mix(hex(top), hex(bottom), 0.5)) > 0.2 ? "light" : "dark";
|
|
}
|
|
|
|
function stars(count, seed) {
|
|
let state = seed >>> 0;
|
|
const next = () => {
|
|
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
|
return state / 4294967296;
|
|
};
|
|
return Array.from({length: count}, () => ({ra: next() * 360, dec: asin(next() * 2 - 1), glow: next() ** 2}));
|
|
}
|
|
|
|
function minutesOf(text) {
|
|
const [hours, minutes] = text.split(":").map(Number);
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
function slotOf(date) {
|
|
return {day: (date.getDay() + 6) % 7, minute: date.getHours() * 60 + date.getMinutes()};
|
|
}
|
|
|
|
function covers(stream, slot) {
|
|
const start = minutesOf(stream.from);
|
|
const end = minutesOf(stream.to);
|
|
if (start < end) {
|
|
return stream.days.includes(slot.day) && slot.minute >= start && slot.minute < end;
|
|
}
|
|
return (stream.days.includes(slot.day) && slot.minute >= start) || (stream.days.includes((slot.day + 6) % 7) && slot.minute < end);
|
|
}
|
|
|
|
function activeStream(streams, slot) {
|
|
return streams.find((stream) => stream.on && covers(stream, slot)) || null;
|
|
}
|
|
|
|
function stationOrigins(stream) {
|
|
return [new URL(stream.url).origin, ...(stream.via || [])];
|
|
}
|
|
|
|
function retold(told, url, kind, cause) {
|
|
const key = `${url}|${kind}`;
|
|
const rank = TROUBLE_RANKS[cause];
|
|
return !told || told.key !== key || rank > told.rank ? {key, rank} : null;
|
|
}
|
|
|
|
return {GLASS, CARD, SKY, THEMES, julian, sidereal, horizontal, sun, moon, hex, mix, css, luminance, contrast, skyAt, stage, starlight, modeAt, modeOf, stars, slotOf, activeStream, stationOrigins, retold};
|
|
})();
|
|
if (typeof module === "object" && module.exports) {
|
|
module.exports = Ambience;
|
|
}
|