#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
236 lines
9.1 KiB
JavaScript
236 lines
9.1 KiB
JavaScript
/* 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。若使用 Wi‑Fi,请检查路由器是否正常。</p></li>' +
|
||
'<li><p class="step-title"><span class="step-num">2</span><span>尝试切换到移动流量</span></p>' +
|
||
'<p class="step-body">部分宽带或校园网可能对镜像域名有限制。请关闭 Wi‑Fi,使用手机 <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
|
||
});
|