feat(宏观): ifinance 版本展示、手动/每日更新与按需文件缓存
Docker Build and Push / build-image (push) Successful in 3m58s

- 「我」页展示 ifinance 版本/构建时间/构建号/上次检查, 点击手动更新 info.json
- 启动及每 30 分钟检查, 超过 24h 自动刷新索引
- 新增 Service Worker: 按 files[].hash 内容寻址, 只缓存打开过的页面
- 未命中时按需下载单个文件并校验 sha256, 索引升级后清理旧缓存
- 缓存页面通过 CSP sandbox 隔离, 支持看板行内脚本且无法访问宿主数据
This commit is contained in:
jif2.zhang
2026-11-03 15:33:40 +08:00
parent 5622e4b3d4
commit fa172d8974
5 changed files with 466 additions and 35 deletions
+147
View File
@@ -0,0 +1,147 @@
// ifinance 文件缓存 Service Worker
// ------------------------------------------------------------
// 宿主把 iframe 指向 /__ifinance__/<path>?h=<sha256>&t=<mime>, 由本 SW:
// 1. 按 hash 从 Cache Storage 取本地缓存 (内容寻址, 索引更新后自然失效)
// 2. 未命中时只拉当前这一个文件 (按需加载, 不预下载整个模块包)
// 3. 用 sha256 校验后再写入缓存; 校验失败仍然返回内容, 但不落盘
//
// 为什么不用 blob: URL 直接渲染: blob: 文档会继承宿主页面的 CSP
// (script-src 'self'), 内嵌看板的 <script> 会被拦截, 图表无法渲染。
// SW 返回的是普通 http(s) 子文档, 不受宿主 CSP 的脚本限制。
//
// 文件缓存里以 /__ifinance_file__/<hash> 为 key, 供宿主侧清理旧版本。
const PAGE_PREFIX = '/__ifinance__/'
const FILE_CACHE_NAME = 'ifinance-files-v1'
const FILE_KEY_PREFIX = '/__ifinance_file__/'
const REMOTE_BASE = 'https://ifinance.honor3.com'
const NETWORK_TIMEOUT = 20000
self.addEventListener('install', () => {
// 新版本 SW 立刻接管, 避免用户必须再打开一次页面
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (url.origin !== self.location.origin) return
if (!url.pathname.startsWith(PAGE_PREFIX)) return
if (event.request.method !== 'GET') return
event.respondWith(serve(url))
})
async function serve(url) {
const rel = decodeURIComponent(url.pathname.slice(PAGE_PREFIX.length))
const hash = (url.searchParams.get('h') || '').toLowerCase()
const type = url.searchParams.get('t') || guessType(rel)
const keyUrl = self.location.origin + FILE_KEY_PREFIX + (hash || encodeURIComponent(rel))
// 缓存不可用时 (隐私模式/配额/被策略禁用) 仍然正常联网加载
let cache = null
if (typeof caches !== 'undefined') {
try { cache = await caches.open(FILE_CACHE_NAME) } catch { /* ignore */ }
}
if (cache && hash) {
try {
const hit = await cache.match(keyUrl)
if (hit) return respond(await hit.arrayBuffer(), type, 'hit')
} catch { /* 读取失败按未命中处理 */ }
}
try {
const payload = await fetchRemote(rel)
if (cache && hash) {
try {
const digest = await sha256Hex(payload)
if (digest === hash) {
await cache.put(keyUrl, new Response(payload, {
headers: { 'Content-Type': withCharset(type), 'Cache-Control': 'no-store' },
}))
} else {
console.warn('[ifinance-sw] hash 不匹配, 不写入缓存:', rel, digest, '!=', hash)
}
} catch (e) {
// crypto.subtle 不可用等: 只跳过落盘, 不影响本次展示
console.warn('[ifinance-sw] 校验失败, 跳过缓存:', rel, e)
}
}
return respond(payload, type, 'network')
} catch (e) {
console.warn('[ifinance-sw] 加载失败:', rel, e)
return respondText(errorPage(rel, e), 'text/html; charset=utf-8', 502)
}
}
async function fetchRemote(rel) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT)
try {
const res = await fetch(REMOTE_BASE + '/' + rel, {
cache: 'no-cache',
mode: 'cors',
signal: controller.signal,
})
if (!res.ok) throw new Error('HTTP ' + res.status)
return await res.arrayBuffer()
} finally {
clearTimeout(timer)
}
}
function respond(buf, type, source) {
return new Response(buf, {
headers: {
'Content-Type': withCharset(type),
'Cache-Control': 'no-store',
'X-Ifinance-Cache': source,
// 同源内容用 CSP sandbox 隔离成不透明源: 行内脚本照常运行 (看板需要),
// 但读不到宿主 localStorage/cookie, 也不能访问 parent。
'Content-Security-Policy': 'sandbox allow-scripts',
},
})
}
function respondText(text, type, status = 200) {
return new Response(text, {
status,
headers: {
'Content-Type': withCharset(type),
'Cache-Control': 'no-store',
'Content-Security-Policy': 'sandbox allow-scripts',
},
})
}
function withCharset(type) {
const t = String(type || 'text/html')
return /charset=/i.test(t) ? t : t + '; charset=utf-8'
}
function guessType(rel) {
return /\.json$/i.test(rel) ? 'application/json' : 'text/html'
}
async function sha256Hex(buf) {
const digest = await crypto.subtle.digest('SHA-256', buf)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
function errorPage(rel, err) {
const msg = String(err?.message || err || '加载失败')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return '<!doctype html><html><head><meta charset="utf-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<style>body{margin:0;padding:24px;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;' +
'color:#666;background:#f5f5f5}h3{margin:0 0 8px;color:#333;font-size:15px}code{font-size:12px;color:#999;word-break:break-all}</style>' +
'</head><body><h3>内容加载失败</h3><div>请检查网络后重试。</div><p><code>' +
msg + '<br>' + rel.replace(/&/g, '&amp;').replace(/</g, '&lt;') +
'</code></p></body></html>'
}