diff --git a/README.md b/README.md index 6e3c7c5..998bdb6 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,14 @@ UI 仿照 [komari-next](https://github.com/tonyliuzj/komari-next) 制作,数 - 首页统计、区域筛选、搜索、网格和表格视图 - CPU、内存、磁盘、网络和流量信息 - 节点详情页与历史负载图表 -- 四线路 Ping、丢包、波动和鼠标悬浮数据 +- 首页最近 1 小时四线路延迟/丢包时间条,详情页 Ping、丢包、波动和悬浮数据 - WebSocket 实时更新,断线后自动重连 - 多个 Worker 数据合并 - 中文、英文、明暗主题和移动端布局 - Cloudflare Turnstile - 独立登录授权,可查看非公开站点、隐藏节点和长历史 +- 首页右侧主题抽屉:背景 URL/图片上传、独立透明/毛玻璃方案和自定义 CSS +- Cloudflare KV 自动持久化主题设置,不写入上游 D1 - 可选实验性主题后台(节点管理、设置、数据库维护,默认关闭) ## 本地运行 @@ -85,6 +87,8 @@ npm run dev 4. 构建命令留空,部署命令保持默认的 `npx wrangler deploy`。 5. 保存并部署。 +`wrangler.jsonc` 已声明不带资源 ID 的 `THEME_SETTINGS` binding。首次部署时 Wrangler 会自动创建并绑定 KV namespace,不需要提前运行 `wrangler kv namespace create`,也不需要把账户专属 ID 写进仓库。 + ### Cloudflare Worker 配置 1. 进入 **Settings** → **Variables and Secrets**。 @@ -102,6 +106,17 @@ CSM_CUSTOM_ADMIN_ENABLED=true 未配置或填写 `false` 时,`/admin` 与 `/admin.html` 也会重定向到原站后台。登录授权查看私有内容不受此开关影响。 +### 主题自定义 + +首页顶部齿轮现在打开右侧“主题自定义”抽屉;登录授权按钮位于最右侧。主题抽屉支持: + +- 填写 HTTPS 背景图片地址,或上传不超过 2 MB 的 JPG / PNG / WebP / GIF / AVIF。 +- 独立开启界面透明化;可选“柔和透明”(无背景模糊)或“毛玻璃”方案。 +- 分别调整透明强度(0%–80%)与毛玻璃强度(0px–30px)。 +- 添加最多 20,000 字符的自定义 CSS。 + +读取主题设置是公开的,修改、上传和删除必须先在主题中登录。Worker 会向选中的 CF-Server-Monitor 后端发送无副作用的认证探测,再写入 CSM-Next 自己的 KV;不会调用上游设置写入接口,也不会增加上游 D1 写入。自定义 CSS 禁止 `@import`、`url()`、脚本和其他外部资源加载方式。 + ## 测试与构建 ```powershell @@ -140,9 +155,10 @@ CSM-Next/ ## 使用说明 -- 首页的线路延迟和丢包来自最近一次采样,不是 24 小时平均值。 +- 首页可见节点会按需读取最近 1 小时历史,将其聚合成 24 个真实时间桶;延迟和丢包均使用 CT/CU/CM/BD 实际上报字段,后续由 WebSocket 增量更新。尚未载入历史时只显示当前四线路采样。 +- 站点标题默认跟随原 CF-Server-Monitor 的 `site_title`;主题配置中的 `title` / `CSM_SITE_TITLE` 仅作为兜底值。 - 首页和详情页默认读取公开内容;非公开站点、隐藏节点与更长历史可在主题内登录后查看。 -- “管理后台”默认打开当前 CF-Server-Monitor Worker 的原生 `/#/admin`。 +- 顶部齿轮打开主题自定义抽屉;页脚中的“管理后台”入口默认打开当前 CF-Server-Monitor Worker 的原生 `/#/admin`。 - 设置 `customAdminEnabled: true`(普通静态托管)或 `CSM_CUSTOM_ADMIN_ENABLED=true`(Cloudflare Worker)后,才启用主题自建 `admin.html`。 - CSM-Next 只保存上游签发的站点隔离 JWT,不保存管理密码或 Secret。 @@ -151,6 +167,7 @@ CSM-Next/ - 后端:[CF-Server-Monitor](https://github.com/huilang-me/CF-Server-Monitor) - UI 参考:[komari-next](https://github.com/tonyliuzj/komari-next) - 国旗图标:[flag-icons](https://github.com/lipis/flag-icons) +- 界面图标:[Lucide](https://lucide.dev/)(许可见 [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md)) CSM-Next 是独立的社区项目,与上述项目维护者没有官方隶属关系。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..e030146 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,67 @@ +# Third-party notices + +## Lucide Icons + +The inline interface icons marked with `data-lucide` are sourced from +[Lucide](https://lucide.dev/) and used under the ISC License. + +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, +arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, +arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, +arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, +cast, check, chevron-down, chevron-left, chevron-right, chevron-up, +chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, +clock, code, columns, command, compass, corner-down-left, corner-down-right, +corner-left-down, corner-left-up, corner-right-down, corner-right-up, +corner-up-left, corner-up-right, crosshair, database, divide-circle, +divide-square, dollar-sign, download, external-link, feather, frown, hash, +headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, +loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, +minus-circle, minus-square, minus, monitor, moon, more-horizontal, +more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, +percent, plus-circle, plus-square, plus, power, radio, rss, search, server, +share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, +target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, +x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a097b6d..83e2160 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,7 +3,10 @@ ## 数据流 ```text -Cloudflare Worker / 静态托管主题 +CSM-Next Cloudflare Worker / 静态托管主题 + ├─ GET /api/theme-settings → CSM-Next KV + ├─ PUT /api/theme-settings → 验证上游 JWT → CSM-Next KV + ├─ GET/PUT /api/theme-background → CSM-Next KV ├─ GET /api/config ├─ GET /api/servers ├─ GET /api/server?id=... @@ -13,24 +16,26 @@ Cloudflare Worker / 静态托管主题 CF-Server-Monitor Worker / D1 / Durable Object ``` -主题是纯静态前端,不包含代理层,也不保存管理密码。默认管理入口跳转原 CF-Server-Monitor 的 `/#/admin`;主题仅调用登录接口获取 JWT,以读取非公开站点、隐藏节点和长历史。设置 `customAdminEnabled` 后才启用实验性 `admin.html`,其管理 API 仍直接调用原 Worker,不改 D1 / Worker 核心逻辑。 +监控页面仍是静态前端,不保存管理密码。Cloudflare 部署额外提供一个很小的主题设置 API:KV 保存外观 JSON 与单张背景图片,写入前通过上游 JWT 验证身份。外观 JSON 分别保存透明化开关、柔和/毛玻璃模式、面板不透明度与模糊强度;旧版只有 `panelOpacity` 的数据会兼容迁移为原有毛玻璃效果。默认管理入口跳转原 CF-Server-Monitor 的 `/#/admin`;设置 `customAdminEnabled` 后才启用实验性 `admin.html`。主题设置不改上游 D1 / Worker 核心逻辑。 -通过 Cloudflare Workers 部署时,`worker/index.js` 只负责首页、详情页、后台页和 `config.json` 路由;CSS、JavaScript 等文件由 Workers Static Assets 直接提供。它不会代理监控 API。 +首页探针时间条只为进入视口的节点按需请求 `hours=1` 历史,同时最多加载 4 个节点。每个节点在当前页面只初始化一次,并在浏览器会话中短时缓存;随后利用已有 WebSocket 样本维护滚动一小时窗口,普通 60 秒刷新不会重复读取历史。每个时间条由 24 个真实时间桶组成,缺少采样的桶显示为空,不用当前值重复填充。 + +通过 Cloudflare Workers 部署时,`worker/index.js` 负责页面路由、`config.json`、主题设置和背景图片接口;CSS、JavaScript 等文件由 Workers Static Assets 直接提供。它不会代理监控数据 API,认证探测也不会返回上游设置或 Secret。 ## 目录职责 - `src/index.html`、`src/detail.html`:默认页面入口;`src/admin.html` 是开关控制的可选实验后台。 - `src/assets/js/`:仪表盘、详情页与管理后台入口逻辑。 - `src/assets/js/admin/`:管理后台分域模块(i18n / context / api / servers / settings)。 -- `src/assets/js/shared/`:跨页共享模块(JWT、HTTP、URL/背景图校验、DOM 转义、i18n、测点字段)。 +- `src/assets/js/shared/`:跨页共享模块(JWT、HTTP、主题设置、URL/背景图校验、DOM 转义、i18n、测点字段与探针历史聚合)。 - `src/assets/css/`:共享样式、详情页与后台样式。 -- `worker/`:Cloudflare Worker 路由和运行时前端配置。 +- `worker/`:Cloudflare Worker 路由、运行时配置与 KV 主题设置接口。 - `config/`:公开示例配置和被忽略的本地配置。 - `tests/`:无需浏览器依赖的 DOM 冒烟测试。 - `scripts/`:构建与本地静态服务。 - `docs/`:架构、开发和部署说明。 - `dist/`:构建产物,不提交 Git。 -- `wrangler.jsonc`:Workers Static Assets 与部署变量。 +- `wrangler.jsonc`:Workers Static Assets、自动配置的 KV binding 与部署变量。 ## 配置策略 @@ -42,5 +47,7 @@ CF-Server-Monitor Worker / D1 / Durable Object - 超过 1 小时的历史需要 JWT。后端只认 `Authorization: Bearer`,不读 Cookie。 - JWT 保存在浏览器 `localStorage`,按域名隔离。主题与原管理端不在同一域名时,原后台登录状态不会自动穿透。 - 首页、详情页与可选主题后台在本主题域名登录:调用原 `/admin/api` 获取 token,写入当前域名 `localStorage`(按 `apiBase` 隔离)。 +- 主题设置公开读取;写设置和上传背景必须携带 JWT。Worker 只用未知 action 验证令牌,不调用 `get_settings`,也不读取上游 Secret。 +- 自定义 CSS 通过 `textContent` 写入固定 `
@@ -22,17 +23,17 @@
@@ -134,7 +135,7 @@

登录后查看长历史

- + diff --git a/src/index.html b/src/index.html index aabf7f6..886cdb8 100644 --- a/src/index.html +++ b/src/index.html @@ -8,7 +8,8 @@ Server Monitor - + +
@@ -21,20 +22,20 @@
@@ -171,6 +172,90 @@

Dashboard

+ + +
- + diff --git a/tests/admin.test.mjs b/tests/admin.test.mjs index 812432a..4f77d7a 100644 --- a/tests/admin.test.mjs +++ b/tests/admin.test.mjs @@ -44,6 +44,40 @@ test('home and admin footers link to the upstream and theme repositories', async assert.match(admin, /class="admin-page-footer"/) }) +test('home toolbar opens theme customization and keeps authorization rightmost', async () => { + const home = await readFile(resolve(root, 'src/index.html'), 'utf8') + const settingsIndex = home.indexOf('id="themeSettingsButton"') + const authIndex = home.indexOf('id="authButton"') + assert.ok(settingsIndex > 0 && authIndex > settingsIndex) + assert.doesNotMatch(home, /id="adminLink"/) + assert.match(home, /id="themeDrawer"/) + assert.match(home, /id="themeBackgroundUpload"[^>]+type="file"/) + assert.match(home, /id="themeTransparencyEnabled"[^>]+type="checkbox"/) + assert.match(home, /id="themeTransparencySoft"[^>]+type="radio"/) + assert.match(home, /id="themeTransparencyGlass"[^>]+type="radio"/) + assert.match(home, /id="themeTransparencyIntensity"[^>]+type="range"/) + assert.match(home, /id="themePanelBlur"[^>]+type="range"/) + assert.match(home, /id="themeCustomCss"/) + assert.doesNotMatch(home, /id="themeOriginalAdminLink"/) + assert.doesNotMatch(home, /class="theme-drawer-footer"/) +}) + +test('top action bars use locally inlined Lucide icons', async () => { + const home = await readFile(resolve(root, 'src/index.html'), 'utf8') + const detail = await readFile(resolve(root, 'src/detail.html'), 'utf8') + const admin = await readFile(resolve(root, 'src/admin.html'), 'utf8') + for (const name of ['refresh-cw', 'sun-moon', 'languages', 'palette', 'circle-user-round']) { + assert.match(home, new RegExp(`data-lucide="${name}"`)) + } + for (const name of ['layout-dashboard', 'refresh-cw', 'sun-moon', 'languages', 'settings']) { + assert.match(detail, new RegExp(`data-lucide="${name}"`)) + } + for (const name of ['menu', 'sun-moon', 'languages']) { + assert.match(admin, new RegExp(`data-lucide="${name}"`)) + } + assert.doesNotMatch(home, /文<\/span>A<\/small>/) +}) + test('admin modules cover core admin API actions', async () => { const js = await readAdminSources() for (const action of [ diff --git a/tests/dashboard-history.test.mjs b/tests/dashboard-history.test.mjs new file mode 100644 index 0000000..86eddac --- /dev/null +++ b/tests/dashboard-history.test.mjs @@ -0,0 +1,136 @@ +class FakeClassList { + values = new Set() + add(...names) { names.forEach(name => this.values.add(name)) } + remove(...names) { names.forEach(name => this.values.delete(name)) } + toggle(name, force) { + const enabled = force === undefined ? !this.values.has(name) : force + if (enabled) this.values.add(name) + else this.values.delete(name) + return enabled + } +} + +class FakeElement { + constructor() { + this.classList = new FakeClassList() + this.dataset = {} + this.style = { setProperty(name, value) { this[name] = String(value) } } + this.hidden = false + this.textContent = '' + this.innerHTML = '' + this.value = '' + this.children = new Map() + this.listeners = new Map() + } + addEventListener(type, callback) { + if (!this.listeners.has(type)) this.listeners.set(type, []) + this.listeners.get(type).push(callback) + } + setAttribute(name, value) { this[name] = String(value) } + querySelector(selector) { + if (!this.children.has(selector)) this.children.set(selector, new FakeElement()) + return this.children.get(selector) + } + replaceChildren() { this.innerHTML = '' } + focus() {} +} + +const nodes = new Map() +const nodeFor = selector => { + if (!nodes.has(selector)) nodes.set(selector, new FakeElement()) + return nodes.get(selector) +} + +const card = new FakeElement() +card.dataset.serverKey = '0:node-1' +globalThis.document = { + documentElement: new FakeElement(), + body: new FakeElement(), + head: new FakeElement(), + title: '', + querySelector: nodeFor, + querySelectorAll: selector => { + if (selector === '.server-card[data-server-key]' && nodeFor('#cardGroups').innerHTML.includes('data-server-key="0:node-1"')) return [card] + return [] + }, + createElement: () => new FakeElement(), + addEventListener: () => {} +} + +globalThis.window = globalThis +globalThis.window.addEventListener = () => {} +globalThis.requestAnimationFrame = callback => callback() +Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { language: 'zh-CN' } }) +Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { origin: 'https://theme.example', href: 'https://theme.example/', search: '' } +}) + +function storage() { + const values = new Map() + return { + getItem: key => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: key => values.delete(key) + } +} +globalThis.localStorage = storage() +globalThis.sessionStorage = storage() +globalThis.setInterval = () => 1 +globalThis.clearInterval = () => {} + +globalThis.IntersectionObserver = class { + constructor(callback) { this.callback = callback } + observe(target) { this.callback([{ isIntersecting: true, target }]) } + unobserve() {} + disconnect() {} +} + +globalThis.WebSocket = class { + addEventListener() {} + send() {} + close() {} +} + +const now = Date.now() +let historyFetches = 0 +const json = value => new Response(JSON.stringify(value), { + headers: { 'Content-Type': 'application/json' } +}) +globalThis.fetch = async input => { + const url = String(input) + if (url === './config.json') return json({ apiBase: ['https://upstream.example'], title: 'History Test', refreshInterval: 60000 }) + if (url === './api/theme-settings') return json({ success: true, settings: { backgroundImage: '', panelOpacity: 1, customCss: '' } }) + if (url === 'https://upstream.example/api/config') return json({ turnstile_enabled: false, site_title: 'History Test' }) + if (url === 'https://upstream.example/api/servers') return json({ + servers: [{ + id: 'node-1', name: 'Node One', region: 'HK', server_group: 'Default', os: 'Debian', arch: 'amd64', + cpu: 1, ram_total: 1024, ram_used: 128, disk_total: 10240, disk_used: 1024, + ping_ct: 45, ping_cu: 55, ping_cm: 65, ping_bd: 75, + loss_ct: 0, loss_cu: 0, loss_cm: 0, loss_bd: 0, + last_updated: now, report_timestamp: now + }], + sysConfig: {} + }) + if (url === 'https://upstream.example/api/history/all?id=node-1&hours=1') { + historyFetches += 1 + return json({ rows: [ + { timestamp: now - 50 * 60_000, ping_ct: 40, ping_cu: 50, ping_cm: 60, ping_bd: 70, loss_ct: 0, loss_cu: 0, loss_cm: 5, loss_bd: 0 }, + { timestamp: now - 25 * 60_000, ping_ct: 80, ping_cu: 90, ping_cm: 100, ping_bd: 110, loss_ct: 10, loss_cu: 0, loss_cm: 0, loss_bd: 0 }, + { timestamp: now - 5 * 60_000, ping_ct: 120, ping_cu: 130, ping_cm: 140, ping_bd: 150, loss_ct: 0, loss_cu: 20, loss_cm: 0, loss_bd: 0 } + ] }) + } + throw new Error(`Unexpected fetch: ${url}`) +} + +const realSetTimeout = globalThis.setTimeout +await import(`${new URL('../src/assets/js/dashboard.js', import.meta.url).href}?history=${Date.now()}`) +await new Promise(resolve => realSetTimeout(resolve, 240)) + +const cards = nodeFor('#cardGroups').innerHTML +if (historyFetches !== 1) throw new Error(`Expected one lazy history request, received ${historyFetches}`) +if (!cards.includes('data-probe-source="history"')) throw new Error('History response did not replace current probe bars') +if ((cards.match(/data-probe-bucket=/g) || []).length !== 48) throw new Error('Expected 24 latency and 24 loss time buckets') +if (!cards.includes('最近 1 小时')) throw new Error('One-hour window label did not render') + +console.log('Dashboard history smoke test passed: visible card loaded one real one-hour timeline.') diff --git a/tests/dashboard.test.mjs b/tests/dashboard.test.mjs index b4e3ad0..16fa916 100644 --- a/tests/dashboard.test.mjs +++ b/tests/dashboard.test.mjs @@ -14,7 +14,7 @@ class FakeElement { constructor() { this.classList = new FakeClassList() this.dataset = {} - this.style = {} + this.style = { setProperty(name, value) { this[name] = String(value) } } this.hidden = false this.textContent = '' this.innerHTML = '' @@ -96,6 +96,10 @@ if (!cards.includes('lipis/flag-icons@7.3.2/flags/4x3/jp.svg')) { if (!cards.includes('gauge-progress') || cards.includes('conic-gradient')) { throw new Error('SVG gauge did not render') } +if (!cards.includes('data-probe-source="history"') || (cards.match(/data-probe-bucket=/g) || []).length < 48) { + throw new Error('Real one-hour probe history bars did not render') +} +if (cards.includes('实时丢包')) throw new Error('Legacy latest-sample loss label is still rendered') if (String(total) !== '8' || String(online) !== '7') { throw new Error(`Unexpected preview stats: total=${total}, online=${online}`) } @@ -112,10 +116,31 @@ search.dispatch('input') nodeFor('#themeButton').dispatch('click') if (document.documentElement.dataset.theme !== 'dark') throw new Error('Theme toggle failed') +nodeFor('#themeSettingsButton').dispatch('click') +if (!nodeFor('#themeDrawer').classList.values.has('is-open')) throw new Error('Theme settings drawer did not open') +nodeFor('#themeTransparencyEnabled').checked = true +nodeFor('#themeTransparencyEnabled').dispatch('change') +if (nodeFor('#themeTransparencyOptions').hidden) throw new Error('Transparency options did not open') +nodeFor('#themeTransparencySoft').checked = false +nodeFor('#themeTransparencyGlass').checked = true +nodeFor('#themeTransparencyGlass').dispatch('change') +nodeFor('#themeTransparencyIntensity').value = '40' +nodeFor('#themeTransparencyIntensity').dispatch('input') +if (nodeFor('#themeTransparencyOutput').textContent !== '40%') throw new Error('Theme transparency output did not update') +nodeFor('#themePanelBlur').value = '22' +nodeFor('#themePanelBlur').dispatch('input') +if (nodeFor('#themeBlurOutput').textContent !== '22px') throw new Error('Theme blur output did not update') +nodeFor('#themeCustomCss').value = '.brand-title { letter-spacing: .08em; }' +nodeFor('#themeSettingsForm').dispatch('submit') +await new Promise(resolvePromise => realSetTimeout(resolvePromise, 20)) +if (document.documentElement.style['--panel-opacity'] !== '60%') throw new Error('Theme opacity was not applied') +if (document.documentElement.style['--panel-blur'] !== '22px') throw new Error('Theme glass blur was not applied') +if (!nodeFor('#themeCustomStyle').textContent.includes('letter-spacing')) throw new Error('Custom CSS was not applied safely') + nodeFor('.view-switch').dispatch('click', { target: { closest: () => ({ dataset: { view: 'table' } }) } }) if (nodeFor('#tableView').hidden || !nodeFor('#gridView').hidden) throw new Error('Table view toggle failed') nodeFor('#cardGroups').dispatch('click', { target: { closest: () => ({ dataset: { serverKey: '0:preview-1' } }) } }) if (!location.href.includes('detail.html') || !location.href.includes('id=preview-1')) throw new Error('Card did not route to themed detail page') -console.log('Smoke test passed: render, controls and themed detail routing are working.') +console.log('Smoke test passed: render, theme drawer, controls and themed detail routing are working.') diff --git a/tests/probe-history.test.mjs b/tests/probe-history.test.mjs new file mode 100644 index 0000000..9e8a084 --- /dev/null +++ b/tests/probe-history.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + mergeProbeHistory, normalizeProbeHistory, summarizeProbeHistory +} from '../src/assets/js/shared/probe-history.js' + +const now = 1_700_000_000_000 +const minute = 60_000 + +test('probe history keeps only valid reported latency and loss metrics', () => { + const rows = normalizeProbeHistory({ rows: [{ + timestamp: now - minute, + ping_ct: '42', ping_cu: '', ping_cm: -1, + loss_ct: '2.5', loss_cu: 0, loss_cm: 120 + }] }) + + assert.deepEqual(rows, [{ + timestamp: now - minute, + ping_ct: 42, + loss_ct: 2.5, + loss_cu: 0 + }]) +}) + +test('probe history summarizes the last hour into chronological real buckets', () => { + const rows = [ + { timestamp: now - 55 * minute, ping_ct: 40, ping_cu: 60, loss_ct: 0, loss_cu: 10 }, + { timestamp: now - 40 * minute, ping_ct: 80, ping_cu: 100, loss_ct: 20, loss_cu: 10 }, + { timestamp: now - 20 * minute, ping_ct: 120, ping_cu: 140, loss_ct: 0, loss_cu: 0 }, + { timestamp: now - 5 * minute, ping_ct: 160, ping_cu: 180, loss_ct: 50, loss_cu: 0 } + ] + const summary = summarizeProbeHistory(rows, { now, bucketCount: 4 }) + + assert.equal(summary.rowCount, 4) + assert.equal(summary.latency.average, 110) + assert.equal(summary.loss.average, 11.25) + assert.deepEqual(summary.latency.buckets.map(bucket => bucket.value), [50, 90, 130, 170]) + assert.deepEqual(summary.loss.buckets.map(bucket => bucket.value), [5, 15, 0, 25]) + assert.equal(summary.loss.buckets[0].probes.CT, 0) + assert.equal(summary.loss.buckets[0].probes.CU, 10) +}) + +test('probe history merge deduplicates timestamps and drops samples outside the window', () => { + const rows = mergeProbeHistory([ + { timestamp: now - 70 * minute, ping_ct: 1 }, + { timestamp: now - 10 * minute, ping_ct: 20, loss_ct: 0 } + ], [ + { timestamp: now - 10 * minute, ping_ct: 24, loss_cu: 2 }, + { timestamp: now - minute, ping_ct: 30 } + ], { now }) + + assert.deepEqual(rows, [ + { timestamp: now - 10 * minute, ping_ct: 24, loss_ct: 0, loss_cu: 2 }, + { timestamp: now - minute, ping_ct: 30 } + ]) +}) diff --git a/tests/theme-settings.test.mjs b/tests/theme-settings.test.mjs new file mode 100644 index 0000000..f1c42ea --- /dev/null +++ b/tests/theme-settings.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + normalizeThemeSettings, safeCustomCss, validateThemeSettings +} from '../src/assets/js/shared/theme-settings.js' +import { applyThemeAppearance } from '../src/assets/js/shared/theme.js' + +test('theme settings normalize safe values and clamp stored opacity', () => { + assert.deepEqual(normalizeThemeSettings({ + backgroundImage: 'https://cdn.example.com/bg.webp', + transparencyEnabled: true, + transparencyMode: 'soft', + panelOpacity: 0.72, + panelBlur: 12, + customCss: '.brand-title { letter-spacing: .1em; }' + }), { + backgroundImage: 'https://cdn.example.com/bg.webp', + transparencyEnabled: true, + transparencyMode: 'soft', + panelOpacity: 0.72, + panelBlur: 12, + customCss: '.brand-title { letter-spacing: .1em; }' + }) + assert.equal(normalizeThemeSettings({ panelOpacity: 9 }).panelOpacity, 1) +}) + +test('legacy opacity settings migrate to the existing glass appearance', () => { + const settings = normalizeThemeSettings({ panelOpacity: 0.72, customCss: '' }) + assert.equal(settings.transparencyEnabled, true) + assert.equal(settings.transparencyMode, 'glass') + assert.equal(settings.panelBlur, 18) +}) + +test('theme appearance separates transparency from backdrop blur', () => { + const properties = {} + const root = { style: { setProperty: (name, value) => { properties[name] = value } } } + const body = { style: {}, classList: { add() {}, remove() {} } } + const customStyle = { textContent: '' } + + applyThemeAppearance({ + backgroundImage: '', transparencyEnabled: false, transparencyMode: 'glass', + panelOpacity: 0.6, panelBlur: 24, customCss: '' + }, { root, body, customStyle }) + assert.equal(properties['--panel-opacity'], '100%') + assert.equal(properties['--panel-blur'], '0px') + + applyThemeAppearance({ + backgroundImage: '', transparencyEnabled: true, transparencyMode: 'soft', + panelOpacity: 0.6, panelBlur: 24, customCss: '' + }, { root, body, customStyle }) + assert.equal(properties['--panel-opacity'], '60%') + assert.equal(properties['--panel-blur'], '0px') + + applyThemeAppearance({ + backgroundImage: '', transparencyEnabled: true, transparencyMode: 'glass', + panelOpacity: 0.6, panelBlur: 24, customCss: '' + }, { root, body, customStyle }) + assert.equal(properties['--panel-opacity'], '60%') + assert.equal(properties['--panel-blur'], '24px') + assert.equal(properties['--panel-blur-strong'], '30px') + assert.equal(properties['--background-blur'], '11px') +}) + +test('custom CSS cannot load resources or inject style markup', () => { + assert.equal(safeCustomCss('@import "https://evil.example/x.css";'), '') + assert.equal(safeCustomCss('.x { background: url(https://evil.example/x); }'), '') + assert.equal(safeCustomCss('.x { background: u\\72l(https://evil.example/x); }'), '') + assert.equal(safeCustomCss(''), '') + assert.throws(() => validateThemeSettings({ + backgroundImage: '', panelOpacity: 1, customCss: '@import "https://evil.example/x.css";' + }), error => error.code === 'unsafe_custom_css') +}) + +test('theme settings require HTTPS backgrounds and bounded opacity', () => { + assert.throws(() => validateThemeSettings({ + backgroundImage: 'http://example.com/bg.jpg', panelOpacity: 1, customCss: '' + }), error => error.code === 'invalid_background_image') + assert.throws(() => validateThemeSettings({ + backgroundImage: '', panelOpacity: 0.1, customCss: '' + }), error => error.code === 'invalid_panel_opacity') + assert.throws(() => validateThemeSettings({ + backgroundImage: '', panelOpacity: 0.7, panelBlur: 31, customCss: '' + }), error => error.code === 'invalid_panel_blur') + assert.throws(() => validateThemeSettings({ + backgroundImage: '', panelOpacity: 0.7, transparencyMode: 'mist', customCss: '' + }), error => error.code === 'invalid_transparency_mode') +}) diff --git a/tests/title.test.mjs b/tests/title.test.mjs new file mode 100644 index 0000000..632de56 --- /dev/null +++ b/tests/title.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveSiteTitle } from '../src/assets/js/shared/title.js' + +test('upstream site title is authoritative over theme fallback', () => { + assert.equal( + resolveSiteTitle({ title: 'Theme fallback' }, { site_title: 'Original backend title' }), + 'Original backend title' + ) +}) + +test('site title falls back cleanly when upstream has no value', () => { + assert.equal(resolveSiteTitle({ title: 'Theme fallback' }, { site_title: ' ' }), 'Theme fallback') + assert.equal(resolveSiteTitle({}, {}), 'CF-Server-Monitor') +}) + +test('first available upstream title wins for multi-site dashboards', () => { + assert.equal( + resolveSiteTitle({ title: 'Fallback' }, [{ site_title: '' }, { site_title: 'Site B' }]), + 'Site B' + ) +}) diff --git a/tests/worker.test.mjs b/tests/worker.test.mjs index 537903b..2f9ebb6 100644 --- a/tests/worker.test.mjs +++ b/tests/worker.test.mjs @@ -3,11 +3,32 @@ import test from 'node:test' import worker from '../worker/index.js' +class MemoryKV { + constructor() { this.values = new Map() } + async get(key, type) { + const record = this.values.get(key) + if (record == null) return null + return type === 'json' ? JSON.parse(record.value) : record.value + } + async getWithMetadata(key) { + const record = this.values.get(key) + return record ? { value: record.value, metadata: record.metadata || null } : { value: null, metadata: null } + } + async put(key, value, options = {}) { + this.values.set(key, { + value: value instanceof ArrayBuffer ? value.slice(0) : String(value), + metadata: options.metadata || null + }) + } + async delete(key) { this.values.delete(key) } +} + const env = { CSM_API_BASE: 'https://one.example.workers.dev, https://two.example.workers.dev/', CSM_SITE_TITLE: 'Test Monitor', CSM_BACKGROUND_IMAGE: 'https://example.com/background.jpg', CSM_REFRESH_INTERVAL: '30000', + THEME_SETTINGS: new MemoryKV(), ASSETS: { fetch: async request => new Response(new URL(request.url).pathname, { status: 200 }) } @@ -20,6 +41,8 @@ test('Worker maps public pages and sends admin to upstream by default', async () const directAdmin = await worker.fetch(new Request('https://theme.example/admin.html?site=1'), env) assert.equal(await index.text(), '/index.html') + assert.match(index.headers.get('content-security-policy'), /script-src 'self' https:\/\/challenges\.cloudflare\.com/) + assert.doesNotMatch(index.headers.get('content-security-policy'), /script-src[^;]*'unsafe-inline'/) assert.equal(await detail.text(), '/detail.html') assert.equal(admin.status, 302) assert.equal(admin.headers.get('location'), 'https://two.example.workers.dev/#/admin') @@ -68,6 +91,132 @@ test('Worker exposes runtime frontend configuration', async () => { assert.equal((await enabledResponse.json()).customAdminEnabled, true) }) +test('Worker exposes public theme settings with environment fallbacks', async () => { + const response = await worker.fetch(new Request('https://theme.example/api/theme-settings'), env) + const data = await response.json() + + assert.equal(response.status, 200) + assert.equal(data.success, true) + assert.equal(data.settings.backgroundImage, 'https://example.com/background.jpg') + assert.equal(data.settings.transparencyEnabled, false) + assert.equal(data.settings.transparencyMode, 'soft') + assert.equal(data.settings.panelOpacity, 1) + assert.equal(data.settings.panelBlur, 18) + assert.equal(data.settings.customCss, '') + assert.equal(data.storage, 'kv') +}) + +test('Worker requires a valid upstream login before writing theme settings', async () => { + const unauthorized = await worker.fetch(new Request('https://theme.example/api/theme-settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ panelOpacity: 0.75 }) + }), env) + assert.equal(unauthorized.status, 401) + + const originalFetch = globalThis.fetch + globalThis.fetch = async () => new Response(JSON.stringify({ error: 'malformedRequest' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + const unrelatedBadRequest = await worker.fetch(new Request('https://theme.example/api/theme-settings', { + method: 'PUT', + headers: { + Authorization: 'Bearer rejected-token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ panelOpacity: 0.75 }) + }), env) + assert.equal(unrelatedBadRequest.status, 401) + + let verificationRequest + globalThis.fetch = async (url, options) => { + verificationRequest = { url, options } + return new Response(JSON.stringify({ error: 'unknownAction' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + } + try { + const saved = await worker.fetch(new Request('https://theme.example/api/theme-settings?site=1', { + method: 'PUT', + headers: { + Authorization: 'Bearer site-two-token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + backgroundImage: 'https://cdn.example.com/theme.webp', + panelOpacity: 0.7, + customCss: '.brand-title { letter-spacing: .08em; }' + }) + }), env) + const body = await saved.json() + + assert.equal(saved.status, 200) + assert.equal(body.settings.panelOpacity, 0.7) + assert.equal(body.settings.transparencyEnabled, true) + assert.equal(body.settings.transparencyMode, 'glass') + assert.equal(body.settings.panelBlur, 18) + assert.equal(verificationRequest.url, 'https://two.example.workers.dev/admin/api') + assert.equal(verificationRequest.options.headers.Authorization, 'Bearer site-two-token') + assert.deepEqual(JSON.parse(verificationRequest.options.body), { action: '__csm_theme_verify__' }) + + const loaded = await worker.fetch(new Request('https://theme.example/api/theme-settings'), env) + const loadedBody = await loaded.json() + assert.equal(loadedBody.settings.backgroundImage, 'https://cdn.example.com/theme.webp') + assert.equal(loadedBody.settings.transparencyEnabled, true) + assert.equal(loadedBody.settings.transparencyMode, 'glass') + assert.equal(loadedBody.settings.customCss, '.brand-title { letter-spacing: .08em; }') + + const unsafe = await worker.fetch(new Request('https://theme.example/api/theme-settings', { + method: 'PUT', + headers: { + Authorization: 'Bearer site-one-token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ customCss: '@import "https://evil.example/style.css";' }) + }), env) + assert.equal(unsafe.status, 400) + assert.equal((await unsafe.json()).code, 'unsafe_custom_css') + } finally { + globalThis.fetch = originalFetch + } +}) + +test('Worker stores a small verified background image in the same KV namespace', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = async () => new Response(JSON.stringify({ error: 'unknownAction' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + try { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]) + const uploaded = await worker.fetch(new Request('https://theme.example/api/theme-background', { + method: 'PUT', + headers: { Authorization: 'Bearer admin-token', 'Content-Type': 'image/png' }, + body: png + }), env) + const uploadedBody = await uploaded.json() + assert.equal(uploaded.status, 200) + assert.match(uploadedBody.url, /^https:\/\/theme\.example\/api\/theme-background\?v=\d+$/) + + const image = await worker.fetch(new Request(uploadedBody.url), env) + assert.equal(image.status, 200) + assert.equal(image.headers.get('content-type'), 'image/png') + assert.deepEqual(new Uint8Array(await image.arrayBuffer()), png) + + const invalid = await worker.fetch(new Request('https://theme.example/api/theme-background', { + method: 'PUT', + headers: { Authorization: 'Bearer admin-token', 'Content-Type': 'image/png' }, + body: new Uint8Array([1, 2, 3, 4]) + }), env) + assert.equal(invalid.status, 400) + assert.equal((await invalid.json()).code, 'invalid_background_file') + } finally { + globalThis.fetch = originalFetch + } +}) + test('Worker passes static assets through and rejects writes', async () => { const asset = await worker.fetch(new Request('https://theme.example/assets/css/main.css'), env) const rejected = await worker.fetch(new Request('https://theme.example/', { method: 'POST' }), env) diff --git a/worker/index.js b/worker/index.js index 2ad6046..3788347 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,3 +1,11 @@ +import { + normalizeThemeSettings, validateThemeSettings +} from '../src/assets/js/shared/theme-settings.js' + +const THEME_SETTINGS_KEY = 'theme-settings:v1' +const THEME_BACKGROUND_KEY = 'theme-assets/background' +const MAX_BACKGROUND_BYTES = 2 * 1024 * 1024 + const legacyRoutes = new Map([ ['/detail', '/detail.html'], ['/admin', '/admin.html'], @@ -64,16 +72,225 @@ function publicConfig(env) { } } -function jsonResponse(request, value) { +function jsonResponse(request, value, status = 200, extraHeaders = {}) { const body = request.method === 'HEAD' ? null : `${JSON.stringify(value, null, 2)}\n` return new Response(body, { + status, headers: { 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store' + 'cache-control': 'no-store', + ...extraHeaders } }) } +function themeFallback(env) { + return normalizeThemeSettings({}, { + backgroundImage: env.CSM_BACKGROUND_IMAGE || '', + panelOpacity: 1 + }) +} + +async function readThemeSettings(env) { + const fallback = themeFallback(env) + if (!env.THEME_SETTINGS?.get) return { settings: fallback, updatedAt: '', storage: 'defaults' } + try { + const stored = await env.THEME_SETTINGS.get(THEME_SETTINGS_KEY, 'json') + if (!stored) return { settings: fallback, updatedAt: '', storage: 'kv' } + return { + settings: normalizeThemeSettings(stored, fallback), + updatedAt: typeof stored.updatedAt === 'string' ? stored.updatedAt : '', + storage: 'kv' + } + } catch (error) { + console.error('[theme-settings] KV read failed', error) + return { settings: fallback, updatedAt: '', storage: 'unavailable' } + } +} + +function bearerToken(request) { + const match = request.headers.get('Authorization')?.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() || '' +} + +async function readRequestBuffer(request, maxBytes) { + const declaredLength = Number.parseInt(request.headers.get('Content-Length') || '0', 10) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + const error = new Error('Request body is too large') + error.code = 'body_too_large' + throw error + } + if (!request.body) return new ArrayBuffer(0) + const reader = request.body.getReader() + const chunks = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => {}) + const error = new Error('Request body is too large') + error.code = 'body_too_large' + throw error + } + chunks.push(value) + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes.buffer +} + +async function verifyThemeSettingsAuth(request, env) { + const token = bearerToken(request) + const base = selectedApiBase(request, env) + if (!token || !base) return false + try { + const response = await fetch(`${base}/admin/api`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ action: '__csm_theme_verify__' }) + }) + // Current upstream authenticates before rejecting unknown actions. + if (response.status !== 400) return false + const result = await response.json().catch(() => null) + return result?.error === 'unknownAction' || result?.message === 'unknownAction' + } catch (error) { + console.error('[theme-settings] upstream auth verification failed', error) + return false + } +} + +async function handleThemeSettings(request, env) { + if (request.method === 'GET' || request.method === 'HEAD') { + const value = await readThemeSettings(env) + return jsonResponse(request, { success: true, ...value }) + } + if (request.method !== 'PUT') { + return jsonResponse(request, { success: false, error: 'Method Not Allowed' }, 405, { allow: 'GET, HEAD, PUT' }) + } + if (!env.THEME_SETTINGS?.put) { + return jsonResponse(request, { success: false, error: 'Theme settings storage is unavailable' }, 503) + } + if (!await verifyThemeSettingsAuth(request, env)) { + return jsonResponse(request, { success: false, error: 'Unauthorized' }, 401) + } + + let input + try { + const body = await readRequestBuffer(request, 25000) + input = JSON.parse(new TextDecoder().decode(body)) + } catch (error) { + if (error?.code === 'body_too_large') { + return jsonResponse(request, { success: false, error: error.message }, 413) + } + return jsonResponse(request, { success: false, error: 'Invalid JSON' }, 400) + } + + try { + const current = (await readThemeSettings(env)).settings + const merged = { ...current, ...input } + if (input && typeof input === 'object' && !Array.isArray(input) && + Object.hasOwn(input, 'panelOpacity') && !Object.hasOwn(input, 'transparencyEnabled')) { + merged.transparencyEnabled = Number(input.panelOpacity) < 1 + if (merged.transparencyEnabled && !Object.hasOwn(input, 'transparencyMode')) merged.transparencyMode = 'glass' + } + const settings = validateThemeSettings(merged) + const updatedAt = new Date().toISOString() + await env.THEME_SETTINGS.put(THEME_SETTINGS_KEY, JSON.stringify({ ...settings, updatedAt })) + return jsonResponse(request, { success: true, settings, updatedAt, storage: 'kv' }) + } catch (error) { + if (error?.code) { + return jsonResponse(request, { success: false, error: error.message, code: error.code }, 400) + } + console.error('[theme-settings] KV write failed', error) + return jsonResponse(request, { success: false, error: 'Unable to save theme settings' }, 500) + } +} + +function bytesEqual(bytes, offset, expected) { + return expected.every((value, index) => bytes[offset + index] === value) +} + +function validImageBytes(buffer, contentType) { + const bytes = new Uint8Array(buffer) + if (contentType === 'image/jpeg') return bytes.length >= 3 && bytesEqual(bytes, 0, [0xff, 0xd8, 0xff]) + if (contentType === 'image/png') return bytes.length >= 8 && bytesEqual(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + if (contentType === 'image/gif') { + return bytes.length >= 6 && (bytesEqual(bytes, 0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || bytesEqual(bytes, 0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) + } + if (contentType === 'image/webp') { + return bytes.length >= 12 && bytesEqual(bytes, 0, [0x52, 0x49, 0x46, 0x46]) && bytesEqual(bytes, 8, [0x57, 0x45, 0x42, 0x50]) + } + if (contentType === 'image/avif') { + return bytes.length >= 12 && bytesEqual(bytes, 4, [0x66, 0x74, 0x79, 0x70]) + && (bytesEqual(bytes, 8, [0x61, 0x76, 0x69, 0x66]) || bytesEqual(bytes, 8, [0x61, 0x76, 0x69, 0x73])) + } + return false +} + +async function handleThemeBackground(request, env) { + if (request.method === 'GET' || request.method === 'HEAD') { + if (!env.THEME_SETTINGS?.getWithMetadata) return new Response('Not Found', { status: 404 }) + const object = await env.THEME_SETTINGS.getWithMetadata(THEME_BACKGROUND_KEY, 'arrayBuffer') + if (!object?.value) return new Response('Not Found', { status: 404 }) + return new Response(request.method === 'HEAD' ? null : object.value, { + headers: { + 'Content-Type': object.metadata?.contentType || 'application/octet-stream', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff' + } + }) + } + + if (request.method !== 'PUT' && request.method !== 'DELETE') { + return jsonResponse(request, { success: false, error: 'Method Not Allowed' }, 405, { allow: 'GET, HEAD, PUT, DELETE' }) + } + if (!env.THEME_SETTINGS?.put || !env.THEME_SETTINGS?.delete) { + return jsonResponse(request, { success: false, error: 'Theme asset storage is unavailable' }, 503) + } + if (!await verifyThemeSettingsAuth(request, env)) { + return jsonResponse(request, { success: false, error: 'Unauthorized' }, 401) + } + + if (request.method === 'DELETE') { + await env.THEME_SETTINGS.delete(THEME_BACKGROUND_KEY) + return jsonResponse(request, { success: true }) + } + + const contentType = (request.headers.get('Content-Type') || '').split(';', 1)[0].trim().toLowerCase() + if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif'].includes(contentType)) { + return jsonResponse(request, { success: false, error: 'Unsupported image type', code: 'invalid_background_file' }, 415) + } + let buffer + try { + buffer = await readRequestBuffer(request, MAX_BACKGROUND_BYTES) + } catch (error) { + if (error?.code !== 'body_too_large') throw error + return jsonResponse(request, { success: false, error: 'Background image is too large', code: 'background_file_too_large' }, 413) + } + if (!buffer.byteLength) { + return jsonResponse(request, { success: false, error: 'Background image is too large', code: 'background_file_too_large' }, 413) + } + if (!validImageBytes(buffer, contentType)) { + return jsonResponse(request, { success: false, error: 'File content does not match its image type', code: 'invalid_background_file' }, 400) + } + + await env.THEME_SETTINGS.put(THEME_BACKGROUND_KEY, buffer, { + metadata: { contentType } + }) + const url = new URL('/api/theme-background', request.url) + url.searchParams.set('v', String(Date.now())) + return jsonResponse(request, { success: true, url: url.href }) +} + function assetRequest(request, pathname) { const url = new URL(request.url) url.pathname = pathname @@ -87,13 +304,59 @@ function redirectRequest(request, pathname) { return Response.redirect(url, 308) } +function contentSecurityPolicy(env) { + const connect = new Set(["'self'", 'https://challenges.cloudflare.com']) + for (const base of apiBases(env.CSM_API_BASE)) { + try { + const url = new URL(base) + connect.add(url.origin) + const socket = new URL(url.origin) + socket.protocol = socket.protocol === 'https:' ? 'wss:' : 'ws:' + connect.add(socket.origin) + } catch { /* invalid API bases are handled by the frontend */ } + } + return [ + "default-src 'self'", + "script-src 'self' https://challenges.cloudflare.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + `connect-src ${[...connect].join(' ')}`, + 'frame-src https://challenges.cloudflare.com', + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + "form-action 'self'" + ].join('; ') +} + +function withDocumentSecurity(response, env) { + const headers = new Headers(response.headers) + headers.set('Content-Security-Policy', contentSecurityPolicy(env)) + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin') + headers.set('X-Content-Type-Options', 'nosniff') + headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }) +} + +async function documentAsset(env, request, pathname) { + return withDocumentSecurity(await env.ASSETS.fetch(assetRequest(request, pathname)), env) +} + export default { async fetch(request, env) { + const { pathname } = new URL(request.url) + if (pathname === '/api/theme-settings') return handleThemeSettings(request, env) + if (pathname === '/api/theme-background') return handleThemeBackground(request, env) + if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET, HEAD' } }) } - const { pathname } = new URL(request.url) if (pathname === '/config.json') return jsonResponse(request, publicConfig(env)) const preview = new URL(request.url).searchParams.get('preview') === '1' @@ -104,7 +367,11 @@ export default { const redirect = legacyRoutes.get(pathname) if (redirect) return redirectRequest(request, redirect) - if (pathname === '/') return env.ASSETS.fetch(assetRequest(request, '/index.html')) + if (pathname === '/') return documentAsset(env, request, '/index.html') + + if (pathname === '/index.html' || pathname === '/detail.html' || pathname === '/admin.html') { + return withDocumentSecurity(await env.ASSETS.fetch(request), env) + } return env.ASSETS.fetch(request) } diff --git a/wrangler.jsonc b/wrangler.jsonc index 475b052..a00769d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -4,14 +4,23 @@ "keep_vars": true, "compatibility_date": "2026-07-15", "main": "./worker/index.js", + "kv_namespaces": [ + { + "binding": "THEME_SETTINGS" + } + ], "assets": { "directory": "./src", "binding": "ASSETS", "html_handling": "none", "run_worker_first": [ "/", + "/index.html", "/config.json", + "/api/theme-settings", + "/api/theme-background", "/detail", + "/detail.html", "/admin", "/admin/*", "/admin.html",