Files
agento3/static/sw.js
akiba 122c408fff fix: resolve 3 security audit issues
#3: Stored XSS in stats dashboard - escape p[path] with html.escape()
#4: Caddy timeout race - increase read/write_timeout 30s -> 60s
#5: Missing CSP header - add Content-Security-Policy to Caddyfile
2026-06-30 14:03:48 +00:00

236 lines
9.1 KiB
JavaScript
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.
/* AO3 Mirror Service Worker v5 — Client-side cache + offline fallback + navigation intercept
*
* Deployed at /sw-YYYYMMDD.js (versioned), /sw.js redirects to latest.
* Server injects <script>navigator.serviceWorker.register('/sw.js')</script> into HTML pages.
*/
'use strict';
const MIRROR_DOMAINS = ['agento3.miscs.dev'];
const PRIMARY_DOMAIN = 'agento3.miscs.dev';
// Static asset types to cache aggressively
const STATIC_EXTENSIONS = [
'.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico',
'.woff', '.woff2', '.ttf', '.svg', '.webp', '.json',
];
// Cache names
const STATIC_CACHE = 'ao3-static-v1';
const HTML_CACHE = 'ao3-html-v1';
// Navigation timeout (25s, like go3)
const NAV_FETCH_TIMEOUT_MS = 25000;
// ─── Helpers ──────────────────────────────────────────────────────────
function primaryMirrorHost() {
try {
var scopeHost = new URL(self.registration.scope).hostname;
if (scopeHost && scopeHost.indexOf('.') !== -1) return scopeHost;
} catch (e) {}
for (var i = 0; i < MIRROR_DOMAINS.length; i++) {
var h = MIRROR_DOMAINS[i];
if (h && h.indexOf('.') !== -1) return h;
}
return PRIMARY_DOMAIN;
}
function isStaticAsset(url) {
var path = url.pathname.toLowerCase();
for (var i = 0; i < STATIC_EXTENSIONS.length; i++) {
if (path.endsWith(STATIC_EXTENSIONS[i])) return true;
}
return false;
}
function hostFromRequest(request) {
try {
return new URL(request.url).hostname || '';
} catch (e) {
return '';
}
}
function repairNavigationURL(url) {
var host = url.hostname;
if (!host || host.indexOf('.') !== -1 || host === 'localhost') return url;
var mirror = primaryMirrorHost();
if (!mirror) return url;
var fixed = new URL(url.toString());
fixed.hostname = mirror;
fixed.pathname = '/' + host + (fixed.pathname || '/');
return fixed;
}
// ─── Offline / mirror picker page ─────────────────────────────────────
function buildMirrorPickerPage(currentHost, mirrors) {
var mirrorItems = mirrors
.filter(function (h) { return h; })
.map(function (h) {
return '<li><a href="https://' + h + '/' + '">' + h + '</a></li>';
})
.join('');
return (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>无法访问镜像站点</title>' +
'<style>' +
'body{margin:0;background:#f6f6f6;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#222}' +
'.wrap{max-width:680px;margin:40px auto;padding:0 16px}' +
'.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:24px 28px;box-shadow:0 6px 20px rgba(0,0,0,.05)}' +
'h1{margin:0 0 8px;color:#860000;font-size:22px}' +
'.lead{color:#555;line-height:1.6;margin:0 0 20px}' +
'.steps{margin:0;padding:0;list-style:none}' +
'.steps>li{margin:0 0 20px;padding:0 0 20px;border-bottom:1px solid #eee}' +
'.steps>li:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}' +
'.step-title{display:flex;align-items:flex-start;gap:10px;font-weight:600;color:#333;margin:0 0 8px;line-height:1.5}' +
'.step-num{flex-shrink:0;width:26px;height:26px;border-radius:50%;background:#860000;color:#fff;font-size:14px;line-height:26px;text-align:center}' +
'.step-body{color:#555;line-height:1.65;margin:0;font-size:15px}' +
'.mirrors{margin:8px 0 0;padding-left:20px}' +
'.mirrors li{margin:8px 0}' +
'a{color:#900;text-decoration:none;font-weight:500}a:hover{text-decoration:underline}' +
'code{background:#f3f3f3;padding:2px 6px;border-radius:4px;font-size:.92em}' +
'.muted{color:#888;font-size:13px;line-height:1.6;margin-top:20px;padding-top:16px;border-top:1px solid #eee}' +
'</style></head><body><div class="wrap"><div class="card">' +
'<h1>无法连接到镜像站点</h1>' +
'<p class="lead">浏览器未能与 <code>' + currentHost + '</code> 建立网络连接例如断网、DNS 失败或被防火墙拦截)。请按下面顺序逐步排查。</p>' +
'<ol class="steps">' +
'<li><p class="step-title"><span class="step-num">1</span><span>先检查网络连接</span></p>' +
'<p class="step-body">确认设备已联网:可尝试打开其他网站或 App。若使用 WiFi请检查路由器是否正常。</p></li>' +
'<li><p class="step-title"><span class="step-num">2</span><span>尝试切换到移动流量</span></p>' +
'<p class="step-body">部分宽带或校园网可能对镜像域名有限制。请关闭 WiFi使用手机 <strong>4G / 5G 流量</strong> 重新访问。</p></li>' +
'<li><p class="step-title"><span class="step-num">3</span><span>尝试备用域名</span></p>' +
'<p class="step-body">点击下方备用镜像站点:</p>' +
'<ul class="mirrors">' + mirrorItems + '</ul></li>' +
'</ol>' +
'<p class="muted">页面由 AO3 Mirror Service Worker 提供。若以上步骤后仍无法打开,请稍后再试。</p>' +
'</div></div></body></html>'
);
}
function offlineGuideResponse(currentHost) {
return new Response(buildMirrorPickerPage(currentHost, MIRROR_DOMAINS), {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
// ─── Network strategies ───────────────────────────────────────────────
function handleNavigation(request) {
var currentHost = hostFromRequest(request);
var url = new URL(request.url);
// Repair malformed navigation URLs
var navUrl = repairNavigationURL(url);
var fetchInit = {};
if (navUrl.href !== request.url) {
fetchInit = {
method: request.method,
headers: request.headers,
credentials: request.credentials,
redirect: 'follow',
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
};
}
var ctrl = new AbortController();
var timer = setTimeout(function () { ctrl.abort(); }, NAV_FETCH_TIMEOUT_MS);
var fetchTarget = navUrl.href !== request.url ? new Request(navUrl.href, fetchInit) : request;
return fetch(fetchTarget, { signal: ctrl.signal })
.finally(function () { clearTimeout(timer); })
.then(function (response) {
if (response.redirected && response.url) {
return Response.redirect(response.url, 302);
}
return response;
})
.catch(function () {
return offlineGuideResponse(currentHost);
});
}
function handleStatic(request) {
// Cache-first for static assets
return caches.open(STATIC_CACHE).then(function (cache) {
return cache.match(request).then(function (cached) {
if (cached) {
// Background revalidation
fetch(request).then(function (response) {
if (response && response.ok) {
cache.put(request, response);
}
}).catch(function () {});
return cached;
}
// Network with cache fallback
return fetch(request).then(function (response) {
if (response && response.ok) {
var cloned = response.clone();
cache.put(request, cloned);
}
return response;
}).catch(function () {
// Offline — return whatever we have
return cache.match(request);
});
});
});
}
// ─── Install / Activate ───────────────────────────────────────────────
self.addEventListener('install', function (event) {
self.skipWaiting();
});
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
// Clean old caches
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.map(function (key) {
if (key !== STATIC_CACHE && key !== HTML_CACHE) {
return caches.delete(key);
}
})
);
})
);
});
// ─── Fetch handler ────────────────────────────────────────────────────
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
var url = new URL(event.request.url);
// Don't intercept SW or monitor paths
if (url.pathname.startsWith('/sw') || url.pathname === '/mirror-domains.json') return;
if (url.pathname === '/_monitor' || url.pathname.startsWith('/_monitor/')) return;
if (url.pathname === '/stats' || url.pathname === '/metrics' || url.pathname === '/health') return;
// Static assets: cache-first
if (isStaticAsset(url)) {
event.respondWith(handleStatic(event.request));
return;
}
// Navigation (HTML pages): network-first with offline fallback
if (event.request.mode === 'navigate' || event.request.destination === 'document') {
event.respondWith(
handleNavigation(event.request).catch(function () {
return offlineGuideResponse(hostFromRequest(event.request));
})
);
}
// Other requests pass through to server normally
});