diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a7783..f504438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ ## [Unreleased] +## [1.2.2] - 2026-08-30 + +### 修复 + +- 双域名:设置页保存时误清空管理域(`site_base_url`);仅改图片域时保护已有网站域配置 +- 双域名:设置 API 配置值与 request origin fallback 混淆,导致「当前生效」显示错误 +- 双域名:未配置的第三 Host(`pages.dev`、裸 IP 等)可绕过 Host 隔离访问后台 + +### 改进 + +- 设置 API 拆分 `siteBaseUrl` / `imageBaseUrl`(配置值)、`effective*`(直链)、`runtime`(当前访问检测) +- `PATCH /api/settings` 增加 `domainSeparation` 字段与保存校验 +- 设置页:检测地址展示、关闭双域名确认、反代 Host 不一致警告;首次设置不再自动填入 origin +- Host 中间件:双域名开启时对未知 Host 返回 404(开发 `localhost` 例外) +- 文档站:新增 [更新日志](https://o96u.github.io/PicHost/guide/changelog);重写 Cloudflare 部署说明;同步 FAQ / 双域名文档 + ## [1.2.1] - 2026-08-29 ### 新增 diff --git a/app/pages/logs.vue b/app/pages/logs.vue index 706e684..4c557f1 100644 --- a/app/pages/logs.vue +++ b/app/pages/logs.vue @@ -100,9 +100,12 @@ function storageIcon(storage: ActivityLogStorage | null) { } function publicPathFromKey(key: string) { - if (!hideFolderInUrl.value) return key - const slash = key.lastIndexOf('/') - return slash >= 0 ? key.slice(slash + 1) : key + if (hideFolderInUrl.value) { + const slash = key.lastIndexOf('/') + return slash >= 0 ? key.slice(slash + 1) : key + } + const prefix = 'images/' + return key.startsWith(prefix) ? key.slice(prefix.length) : key } function imageUrl(key: string) { @@ -115,10 +118,10 @@ function imageUrl(key: string) { async function loadImageLinkBase() { try { if (isAdmin.value) { - const data = await $fetch<{ imageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', { + const data = await $fetch<{ effectiveImageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', { credentials: 'include' }) - imageLinkBase.value = data.imageBaseUrl + imageLinkBase.value = data.effectiveImageBaseUrl hideFolderInUrl.value = data.hideFolderInUrl return } diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index a841b10..bcffc18 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -14,10 +14,17 @@ interface SettingsResponse { imageBaseUrl: string imageBaseUrlConfigured: string imageBaseUrlSource: SettingSource + effectiveImageBaseUrl: string siteBaseUrl: string siteBaseUrlConfigured: string siteBaseUrlSource: SettingSource + effectiveSiteBaseUrl: string domainSeparation: boolean + runtime: { + currentOrigin: string + currentHost: string + hostRole: 'site' | 'image' | 'unknown' | 'single' + } hideFolderInUrl: boolean hideFolderInUrlSource: SettingSource storageUseDatePath: boolean @@ -46,6 +53,8 @@ const hideFolderInUrlDraft = ref(false) const storageUseDatePathDraft = ref(true) const allowRegistrationDraft = ref(false) const domainSeparationDraft = ref(false) +const disableDomainSeparationOpen = ref(false) +const pendingDomainSeparationDisable = ref(false) interface ReleaseCheckResponse { currentVersion: string @@ -60,8 +69,8 @@ const hasServerChanges = computed(() => { if (!settings.value) return false return refererDraft.value !== settings.value.allowedRefererHosts || domainSeparationDraft.value !== settings.value.domainSeparation - || siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '') - || imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured + || siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '') + || imageBaseUrlDraft.value !== settings.value.imageBaseUrl || hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl || storageUseDatePathDraft.value !== settings.value.storageUseDatePath || allowRegistrationDraft.value !== settings.value.allowRegistration @@ -101,8 +110,8 @@ function errorStatus(error: unknown): number { function applySettings(data: SettingsResponse) { settings.value = data refererDraft.value = data.allowedRefererHosts - siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : '' - imageBaseUrlDraft.value = data.imageBaseUrlConfigured + siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrl : '' + imageBaseUrlDraft.value = data.imageBaseUrl hideFolderInUrlDraft.value = data.hideFolderInUrl storageUseDatePathDraft.value = data.storageUseDatePath allowRegistrationDraft.value = data.allowRegistration @@ -130,25 +139,49 @@ function handlePatchError(error: unknown, fallback: string) { toast.add({ title: fallback, color: 'error' }) } +function fillDetectedSiteOrigin() { + if (settings.value?.runtime.currentOrigin) { + siteBaseUrlDraft.value = settings.value.runtime.currentOrigin + } +} + function onDomainSeparationDraftChange(next: boolean | 'indeterminate') { const enabled = next === true - domainSeparationDraft.value = enabled - if (!enabled) { - siteBaseUrlDraft.value = '' + if (!enabled && domainSeparationDraft.value) { + pendingDomainSeparationDisable.value = true + disableDomainSeparationOpen.value = true return } - if (!siteBaseUrlDraft.value && import.meta.client) { - siteBaseUrlDraft.value = window.location.origin - } + domainSeparationDraft.value = enabled +} + +function confirmDisableDomainSeparation() { + domainSeparationDraft.value = false + siteBaseUrlDraft.value = '' + disableDomainSeparationOpen.value = false + pendingDomainSeparationDisable.value = false +} + +function cancelDisableDomainSeparation() { + disableDomainSeparationOpen.value = false + pendingDomainSeparationDisable.value = false } async function saveServerSettings() { if (!isAuthenticated.value || !hasServerChanges.value) return + if (domainSeparationDraft.value) { + if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) { + toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' }) + return + } + } + savingServer.value = true try { await patchSettings({ allowedRefererHosts: refererDraft.value, + domainSeparation: domainSeparationDraft.value, siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '', imageBaseUrl: imageBaseUrlDraft.value, hideFolderInUrl: hideFolderInUrlDraft.value, @@ -375,6 +408,20 @@ watch(isAuthenticated, async (authed, prev) => {

+

+ {{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }} +

+ +

+ {{ t('settings.hostRoleUnknown') }} +

+
{

{{ t('settings.siteBaseUrlHint') }}

- +
+ + +

- {{ t('settings.siteBaseUrlActive', { url: settings.siteBaseUrl }) }} + {{ t('settings.siteBaseUrlActive', { url: settings.effectiveSiteBaseUrl }) }}

@@ -427,11 +485,11 @@ watch(isAuthenticated, async (authed, prev) => { class="w-full font-mono text-sm" />

- {{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }} + {{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}

@@ -459,11 +517,11 @@ watch(isAuthenticated, async (authed, prev) => { class="w-full font-mono text-sm" />

- {{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }} + {{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}

@@ -570,6 +628,29 @@ watch(isAuthenticated, async (authed, prev) => { + + + + diff --git a/app/pages/setup.vue b/app/pages/setup.vue index c1f7893..41f6d20 100644 --- a/app/pages/setup.vue +++ b/app/pages/setup.vue @@ -49,11 +49,12 @@ onMounted(async () => { await router.replace('/') return } - if (import.meta.client) { - siteBaseUrl.value = window.location.origin - } }) +const detectedOriginPlaceholder = computed(() => + import.meta.client ? window.location.origin : '' +) + async function submit() { if (!canSubmit.value || loading.value) return @@ -217,7 +218,7 @@ async function submit() {

diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 0920183..49c7e8a 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitepress' +import { withMermaid } from 'vitepress-plugin-mermaid' const docsBase = 'https://o96u.github.io/PicHost' @@ -16,7 +17,8 @@ const zhSidebar = [ items: [ { text: '环境变量', link: '/guide/configuration' }, { text: '反向代理', link: '/guide/reverse-proxy' }, - { text: '双域名分离', link: '/guide/domain-separation' } + { text: '双域名分离', link: '/guide/domain-separation' }, + { text: 'Cloudflare / CF 优选', link: '/guide/cloudflare-deployment' } ] }, { @@ -31,6 +33,7 @@ const zhSidebar = [ { text: '升级', items: [ + { text: '更新日志', link: '/guide/changelog' }, { text: 'v1.2 迁移', link: '/guide/migration' }, { text: '常见问题', link: '/guide/faq' } ] @@ -51,7 +54,8 @@ const enSidebar = [ items: [ { text: 'Environment variables', link: '/en/guide/configuration' }, { text: 'Reverse proxy', link: '/en/guide/reverse-proxy' }, - { text: 'Dual-domain separation', link: '/en/guide/domain-separation' } + { text: 'Dual-domain separation', link: '/en/guide/domain-separation' }, + { text: 'Cloudflare deployment', link: '/en/guide/cloudflare-deployment' } ] }, { @@ -66,17 +70,25 @@ const enSidebar = [ { text: 'Upgrade', items: [ + { text: 'Changelog', link: '/en/guide/changelog' }, { text: 'v1.2 migration', link: '/en/guide/migration' }, { text: 'FAQ', link: '/en/guide/faq' } ] } ] -export default defineConfig({ +export default withMermaid( + defineConfig({ base: '/PicHost/', title: 'PicHost', description: '自托管图床文档 · Self-hosted image hosting docs', head: [['link', { rel: 'icon', href: '/PicHost/favicon.ico' }]], + vite: { + optimizeDeps: { + // mermaid → fastdom 为 CJS;dev 模式须预构建,否则整站白屏 + include: ['mermaid', 'fastdom'] + } + }, locales: { root: { label: '简体中文', @@ -126,4 +138,5 @@ export default defineConfig({ text: '在 GitHub 上编辑此页' } } -}) + }) +) diff --git a/docs-site/en/guide/changelog.md b/docs-site/en/guide/changelog.md new file mode 100644 index 0000000..04a22ac --- /dev/null +++ b/docs-site/en/guide/changelog.md @@ -0,0 +1,113 @@ +# Changelog + +Version history and notable changes. The full log is also in the repo root [CHANGELOG.md](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md). + +--- + +## [1.2.2] — 2026-08-30 + +> **Restart the service** after upgrading so middleware and API updates take effect. + +### Dual-domain settings & Host isolation + +#### Fixes + +| Issue | Description | +| ----- | ----------- | +| Site URL cleared by mistake | Saving Settings could write an empty `site_base_url` when disabling dual-domain or editing only the image URL | +| Config vs effective URL mixed | `GET /api/settings` used to blend DB values with `request.origin`, confusing the Settings UI | +| Third-host bypass | Unlisted hosts (`pages.dev`, bare IP, old image domains) were not blocked by middleware | + +#### Improvements + +**Settings API (`GET /api/settings`)** + +| Field | Meaning | +| ----- | ------- | +| `siteBaseUrl` / `imageBaseUrl` | **Configured** values (DB → env), no request fallback | +| `effectiveSiteBaseUrl` / `effectiveImageBaseUrl` | **Link generation**, falls back to request origin when unset | +| `runtime.currentOrigin` | Detected access URL (display only, not saved) | +| `runtime.hostRole` | `site` / `image` / `unknown` / `single` | + +**Save API (`PATCH /api/settings`)** + +- New body field `domainSeparation` (`true` / `false`) +- `domainSeparation: true` requires both URLs with different hostnames +- `domainSeparation: false` allows clearing the site URL +- Without `domainSeparation`, cannot clear `siteBaseUrl` while dual-domain is active + +**Settings / setup UI** + +- Form uses configured values; “Active” line shows `effective*` +- Shows detected access URL; **no longer** auto-fills `window.location.origin` +- “Use detected URL” button (opt-in) +- Confirmation modal when disabling dual-domain +- Warning when `hostRole === 'unknown'` with dual-domain on + +**Host middleware** + +- When dual-domain is on, unlisted hosts get **404** at the app layer (all methods) +- `localhost` / `127.0.0.1` exempt in development +- Complements Nginx `default_server` — use both layers in production + +#### Docs + +- Rewrote [Cloudflare deployment](./cloudflare-deployment.md) +- Updated [Dual-domain separation](./domain-separation.md) for third-host blocking +- Added this changelog page + +#### Upgrade notes + +- **No** database migration or CLI step +- If the site URL was cleared earlier: re-enter it in **Settings**, or set `SITE_BASE_URL` in env and restart +- Production: match proxy `server_name` to Settings; keep a default server block + +--- + +## [1.2.1] — 2026-08-29 + +### Added + +- Slide puzzle login captcha +- VitePress docs site on GitHub Pages +- `npm run docs:dev` / `docs:build` / `docs:preview` + +### Fixed + +- Gallery not refreshing after delete on stats page +- Pagination jump with `type="number"` input +- Copy-link format preference not persisting off home route +- Captcha UI alignment + +### Improved + +- README trimmed; details moved to docs +- Dual-domain docs: unknown Host risk, Cloudflare guide + +--- + +## [1.2.0] — 2026-08-28 + +### Changed + +- All images under `data/images/`; legacy `blog/` / `twikoo/` need migration + +### Added + +- CLI: `docker exec pichost migrate` / `migrate --apply` +- Startup index sync (disk scan, legacy key normalization, orphan cleanup) + +See [v1.2 migration](./migration.md). + +--- + +## [1.1.x] summary + +| Version | Highlights | +| ------- | ---------- | +| 1.1.5 | API upload preview fix; file existence check before serve | +| 1.1.4 | Date-based storage paths; site host blocks image URLs | +| 1.1.3 | Dual-domain (`SITE_BASE_URL` / `IMAGE_BASE_URL`); Host middleware | +| 1.1.0 | Multi storage backends (S3 / R2) | + +Older entries: [GitHub CHANGELOG](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md). diff --git a/docs-site/en/guide/cloudflare-deployment.md b/docs-site/en/guide/cloudflare-deployment.md new file mode 100644 index 0000000..e5806e5 --- /dev/null +++ b/docs-site/en/guide/cloudflare-deployment.md @@ -0,0 +1,236 @@ +# Cloudflare & preferred-edge deployment + +In dual-domain mode, PicHost distinguishes **site** vs **image** traffic from the request **Host** (and `X-Forwarded-Host` from a trusted reverse proxy). Cloudflare orange cloud, preferred-edge IPs, Workers, and Pages are all fine — as long as the hostname identity reaching PicHost stays correct. + +> **One-line rule:** The network path may change; the hostname identity must not — `admin → admin`, `img → img`, never `img → admin`. + +See [Dual-domain separation](./domain-separation.md) for middleware behavior and [Reverse proxy](./reverse-proxy.md) for general proxy requirements. + +--- + +## Quick checklist + +| # | Check | Expected | +| - | ----- | -------- | +| 1 | Browser hostname | `admin.*` or `img.*`; do not mix IP / `pages.dev` | +| 2 | Pages / Worker in the middle | Image host must **not** `fetch(admin…)` | +| 3 | Reverse-proxy upstream | Both hostnames → `http://127.0.0.1:6892` | +| 4 | Nginx `proxy_set_header Host` | `$host` (preserve client hostname) | +| 5 | PicHost settings | `SITE_BASE_URL` / `IMAGE_BASE_URL` match `server_name` | +| 6 | Port `6892` | Reachable only from localhost or internal proxy | + +--- + +## Recommended layout + +Both hostnames go through Cloudflare (proxied) and **independently** reverse-proxy to one PicHost instance: + +```mermaid +flowchart TB + internet[Internet] + cf[Cloudflare proxied] + admin[admin.example.com] + img[img.example.com] + panel[1Panel / Nginx] + app[PicHost :6892] + + internet --> cf + cf --> admin + cf --> img + admin --> panel + img --> panel + panel --> app +``` + +```env +SITE_BASE_URL=https://admin.example.com +IMAGE_BASE_URL=https://img.example.com +``` + +```text +admin.example.com → http://127.0.0.1:6892 +img.example.com → http://127.0.0.1:6892 +``` + +You do **not** need separate ports for “frontend / backend / images” — one PicHost instance on port `6892` is enough. + +### Site hostname ≠ frontend domain + +| Hostname | Serves | +| -------- | ------ | +| `admin.example.com` | Admin UI, login, settings, gallery, API, upload | +| `img.example.com` | Image delivery, hotlinks, CDN | + +Use **site / image hostname**, not “frontend / backend”. PicHost does **not** require `frontend :3000 + backend :6892` in production. + +--- + +## Orange cloud & preferred edge + +**DNS Proxied (orange cloud)** is normal and recommended: + +```text +admin.example.com, img.example.com + A/CNAME → origin → Proxied +``` + +Opening `admin.example.com` should yield `Host: admin.example.com` at PicHost; `img.example.com` should yield `Host: img.example.com`. + +**Preferred-edge** setups (alternate IPs, entry hostnames) are also fine. Preferred routing may change the path to Cloudflare’s edge, but must **not** rewrite the image hostname’s origin identity to the site hostname: + +```text +admin → CF → admin.example.com → origin → PicHost +img → CF → img.example.com → origin → PicHost +``` + +--- + +## 6. Typical bad topologies + +These patterns break Host isolation even when the browser still shows the image hostname: + +### ❌ Bad 1: Image host fetches site host + +```mermaid +flowchart LR + user[User on img.example.com] + worker[Pages / Worker] + admin[fetch admin.example.com] + app[PicHost] + + user --> worker --> admin --> app +``` + +The last hop has `Host: admin.example.com`, so PicHost treats it as site traffic — `https://img.example.com/` may show the admin UI. This is **Host rewrite**, not a cache bug. + +### ❌ Bad 2: Preferred proxy upstreams to site host + +```text +img.example.com → preferred hostname → admin.example.com → origin +``` + +Even for routing only, using the site hostname as upstream breaks image isolation. + +### ❌ Bad 3: Public exposure of port 6892 + +```text +any host / IP:6892 → PicHost +``` + +Users can bypass Cloudflare and Nginx `server_name` rules. Port `6892` should be reachable only from `127.0.0.1` or an internal reverse proxy. + +### ✅ Correct pattern + +```text +admin → admin → PicHost +img → img → PicHost +``` + +Do **not** use Pages / Workers as a **full-site reverse proxy** for PicHost (extra `pages.dev` / `workers.dev` entry points). PicHost needs `node-server`, SQLite, `sharp`, and local `data/` — it **cannot** run as a Node app on Pages/Workers. Keep Docker/VPS for the app; use orange-cloud DNS and optional R2 on CF. + +--- + +## Workers / Pages guidance + +If you only need headers, auth, Referer rules, logging, or cache — and PicHost remains your origin: + +| Prefer | Avoid | +| ------ | ----- | +| `img.example.com` → **Worker Route** → existing `img` origin | `img` → Worker → `fetch(admin.example.com)` | + +Cloudflare recommends [Routes](https://developers.cloudflare.com/workers/configuration/routing/routes/) when an external origin already exists, rather than [Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) used to chain cross-host `fetch()`. + +Code like this on the image hostname is a smoking gun: + +```js +fetch(`https://admin.example.com${url.pathname}`) +``` + +--- + +## Nginx / 1Panel layout + +Two `server` blocks, same upstream. Critical line: `proxy_set_header Host $host;` + +```nginx +server { + server_name admin.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + client_max_body_size 12m; + } +} + +server { + server_name img.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} + +# Reject bare IP and unknown hostnames (strongly recommended) +server { + listen 80 default_server; + listen 443 ssl default_server; + server_name _; + return 444; +} +``` + +With orange cloud, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** only. + +--- + +## Two-layer Host protection + +Use reverse proxy and application layers **together**: + +| Layer | Role | +| ----- | ---- | +| **Nginx default server** | Reject bare IP, `pages.dev`, and other undeclared hostnames | +| **PicHost middleware (v1.2.x+)** | When dual-domain is on, unconfigured hosts get 404 at the app (`localhost` / `127.0.0.1` exempt in development) | + +Even with app-layer blocking, keep a default server at the proxy to reduce junk traffic to PicHost. + +--- + +## X-Forwarded-Host + +PicHost may consult `X-Forwarded-Host` behind a reverse proxy, but that header is **forgeable** by clients. Therefore: + +- Do not expose port `6892` publicly +- Only trusted proxies should reach the app +- Prefer keeping `Host` correct from the first hop + +--- + +## Troubleshooting: admin UI on image hostname? + +**Inspect the proxy chain first — do not purge CF cache first.** + +Cache issues usually mean stale JS/CSS/images or occasional 404s. If `/`, `/login`, and `/settings` work **reliably** on the image host, the Host was almost certainly rewritten. + +```text +① Browser hostname? +② Pages / Worker / preferred proxy in the middle? +③ What URL does the proxy fetch? +④ Does it fetch admin.example.com? +⑤ Which Host does 1Panel / PicHost see? +``` + +--- + +## Related + +- [Dual-domain separation](./domain-separation.md) +- [Reverse proxy](./reverse-proxy.md) +- [Environment variables](./configuration.md) +- [FAQ](./faq.md) diff --git a/docs-site/en/guide/configuration.md b/docs-site/en/guide/configuration.md index f9824d7..3cd5daa 100644 --- a/docs-site/en/guide/configuration.md +++ b/docs-site/en/guide/configuration.md @@ -9,8 +9,8 @@ Configure PicHost via environment variables (Docker / `.env`) or the in-app **Se | API upload token | Env → SQLite | **Yes** (cannot regenerate in UI) | | WebP quality | SQLite → env → default 80 | No | | Referer whitelist | SQLite → env | No | -| Site / image base URL | SQLite → env → request origin | No | -| Hide `images/` prefix | SQLite → env → default false | No | +| Site / image base URL | SQLite → env (configured in API); links use `effective*` or request origin | No | +| Short image links | SQLite → env → default false | No | | Date-based paths | SQLite → env → default true | No | | Global auto-delete days | SQLite → env → default 0 | No | @@ -33,11 +33,13 @@ Comma-separated Referer whitelist. Own hostnames are allowed automatically. Opti - **Site URL**: admin UI, API, Twikoo upload - **Image URL**: copied links and public image URLs -Single-domain: set `IMAGE_BASE_URL` only or leave empty (request origin). Dual-domain: hostnames must differ — see [Dual-domain separation](./domain-separation.md). Values must match proxy `server_name`; do not use `localhost`, IP, or unlisted hostnames for production admin access. +Single-domain: set `IMAGE_BASE_URL` only or leave empty (request origin). Dual-domain: hostnames must differ — see [Dual-domain separation](./domain-separation.md) and [Cloudflare deployment](./cloudflare-deployment.md). Values must match proxy `server_name`; do not use `localhost`, IP, or unlisted hostnames for production admin access. + +In **Settings**, enable dual-domain explicitly and fill both URLs; disabling clears the site URL (with confirmation). Configured values vs effective link URLs are separate in the API — see [Changelog](./changelog.md#1-2-2-2026-08-30). ### `HIDE_FOLDER_IN_URL` -`true` hides the `images/` prefix in public URLs. +`true`: short links (domain + filename only), e.g. `img.example.com/a8K3xP.webp`. `false`: keep date or custom folders, e.g. `img.example.com/2026/08/a8K3xP.webp`. Public URLs never expose the internal `images/` prefix; legacy `/images/...` paths still work. ### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` diff --git a/docs-site/en/guide/domain-separation.md b/docs-site/en/guide/domain-separation.md index a36aa26..8400df6 100644 --- a/docs-site/en/guide/domain-separation.md +++ b/docs-site/en/guide/domain-separation.md @@ -16,6 +16,8 @@ Set during `/setup` or in **Settings**: - **Site URL**: `https://admin.example.com` - **Image URL**: `https://pic.example.com` +Setup and Settings **do not** auto-fill the site URL from `window.location.origin`; use manual input or “Use detected URL”. Disabling dual-domain requires confirmation and clears the site URL. Saves include `domainSeparation: true/false`. See [Changelog](./changelog.md#1-2-2-2026-08-30). + ## Nginx example ```nginx @@ -53,36 +55,42 @@ server { ## Security notes -### Middleware only splits the two configured hostnames +### Middleware isolation and third hosts PicHost uses the request **Host** (and `X-Forwarded-Host` when proxied) to decide site vs image domain: - **Image host**: non-image paths (e.g. `/`, `/settings`) → 404 - **Site host**: image URL paths (e.g. `/images/...`) → 404 +- **Unconfigured third hosts** (e.g. `*.pages.dev`, bare IP, old image domains): **from v1.2.x the app returns 404 for all methods** (`localhost` / `127.0.0.1` are exempt in development for `npm run dev`) -If the Host is **neither** the configured site nor image hostname (e.g. `localhost`, the server **public IP**, Cloudflare **Pages** `*.pages.dev`, **Workers** `*.workers.dev`, or any other unlisted name), **the app does not block the request today** — admin and API may still be reachable. This is not about hyphenated or long domain names; it is an extra entry point outside your dual-domain pair. +This is independent of hyphenated or long domain names. Older versions did not block third hosts; rely on a reverse-proxy default server as a complement in production. **Production recommendations:** -1. Configure `server_name` only for the site and image hosts; add a **default server** to reject other Host values and bare IP access (see Nginx example above) +1. Configure `server_name` only for the site and image hosts; add a **default server** to reject other Host values and bare IP access (see Nginx example above; complements app-layer blocking) 2. With Cloudflare **orange-cloud** DNS, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** so traffic cannot bypass the CDN 3. Sign in only on the **site hostname** from Settings; do not mix `localhost`, IP, or unlisted domains -4. Do **not** put Cloudflare Pages / Workers in front of PicHost as a **full-site reverse proxy** (extra `pages.dev` / `workers.dev` entry points worsen this issue) -5. PicHost **cannot** run as-is on Pages/Workers (needs `node-server`, SQLite, `sharp`, local `data/`). Keep Docker/VPS; use orange-cloud DNS and optional R2 storage on CF +4. Do **not** put Cloudflare Pages / Workers in front of PicHost as a **full-site reverse proxy** (`IMAGE_BASE_URL` may point at a CDN URL, but the app itself should run on Docker/VPS) +5. If you must proxy at the edge, preserve the client **`Host` or `X-Forwarded-Host`** or host isolation will fail +6. PicHost **cannot** run as-is on Pages/Workers (needs `node-server`, SQLite, `sharp`, local `data/`). Keep Docker/VPS; use orange-cloud DNS and optional R2 storage on CF ### Cloudflare orange cloud (incl. preferred edge IPs) -**Recommended:** both `admin.example.com` and `pic.example.com` **DNS Proxied** → origin Nginx / 1Panel → port `6892`, SSL **Full (strict)** (origin cert or Cloudflare Origin Certificate). +Full guide: [Cloudflare deployment](./cloudflare-deployment.md). + +**Recommended:** both `admin.example.com` and `pic.example.com` **DNS Proxied** → origin Nginx / 1Panel → port `6892`, SSL **Full (strict)**. | Feature | Notes | | ------- | ----- | | Orange-cloud CDN | Compatible with dual-domain; preserve `Host` and `X-Forwarded-Proto` on origin | -| DNS “preferred IP” setups | Only changes which CF edge IP clients hit; Host stays your real domain | +| DNS “preferred IP” setups | Only changes which CF edge IP clients hit; origin identity must stay **admin → admin**, **img → img** | | R2 storage | Add an R2 backend under **Storage**; independent of orange cloud | | `cloudflare` Git branch | R2-focused deployment line; still Docker, **not** Workers hosting | **Do not confuse** “Create Worker” in the CF dashboard (Git + `wrangler deploy`) with deploying PicHost — the repo uses `nitro.preset: 'node-server'` and is not Workers-ready. +**Do not** chain the image hostname to `fetch(admin…)` via Pages/Workers — that rewrites Host and breaks isolation ([bad topologies](./cloudflare-deployment.md#6-typical-bad-topologies)). + See [Reverse proxy](./reverse-proxy.md#cloudflare-orange-cloud) for origin hardening. ## FN NAS · Lucky reverse proxy @@ -152,7 +160,7 @@ Always open admin on the **site hostname**, not `localhost` (cookies and Host mu | `http://admin.pichost.test:3000/images/...` or `/2026/08/xxx.webp` | **404** (use image host) | | `http://pic.pichost.test:3000/` | **404** (image host has no admin) | | `http://pic.pichost.test:3000/images/...` | Image served | -| `http://localhost:3000/` or `http://127.0.0.1:3000/` | **Admin still loads** (third Host — isolation skipped; OK for dev only; block at proxy in production) | +| `http://localhost:3000/` or `http://127.0.0.1:3000/` | **Admin still loads** (development exception; block unconfigured hosts at proxy in production) | After upload, copied links should use `pic.pichost.test`; gallery thumbnails should load. diff --git a/docs-site/en/guide/faq.md b/docs-site/en/guide/faq.md index a965ac1..33c1d3c 100644 --- a/docs-site/en/guide/faq.md +++ b/docs-site/en/guide/faq.md @@ -21,11 +21,15 @@ Check the **Referer whitelist** (`ALLOWED_REFERER_HOSTS` or Settings). Referring ## Why can admin load on pages.dev / workers.dev / IP with dual-domain? -Dual-domain isolation only splits paths on the **configured site and image hostnames**. Other Host values (`localhost`, public IP, Cloudflare **Pages** `*.pages.dev`, **Workers** `*.workers.dev`, etc.) are **not blocked today**, so admin may appear on unexpected URLs. +**From v1.2.2:** when dual-domain is on, PicHost returns **404** for Host values **other than** the configured site and image hostnames (`localhost` / `127.0.0.1` exempt in development). See [Changelog](./changelog.md#1-2-2-2026-08-30). -Common cause: **full-site reverse proxy** via Pages/Workers on top of orange-cloud DNS, or an origin that accepts any Host / bare IP. +If admin still loads: -Fix: use only the two configured domains; add a default `server_name` block; with orange cloud, allow only CF IPs on the origin. See [Security notes](./domain-separation.md#security-notes). +1. **Dual-domain not active** (image URL set but site URL empty) — neither path split nor third-host blocking applies +2. **Proxy rewrote Host to site hostname** — e.g. Pages/Worker `fetch(admin…)` on the image hostname +3. **Port `6892` exposed publicly** — bypasses Cloudflare and Nginx `server_name` + +Fix: configure both URLs in Settings; add a default server block; with orange cloud, allow only CF IPs on origin; do not full-proxy through Pages/Workers. See [Dual-domain separation](./domain-separation.md#middleware-isolation-and-third-hosts), [Cloudflare deployment](./cloudflare-deployment.md), and [Changelog](./changelog.md#1-2-2-2026-08-30). ## Can I deploy PicHost on Cloudflare Workers / Pages? @@ -63,8 +67,6 @@ Use `data/mapping.json` from `migrate` preview. Keys are **storage paths**, not This **VitePress site** is the full guide; README is a short overview. Online: -If you get 404, GitHub Pages may not be deployed yet: set **Settings → Pages → Source** to **GitHub Actions** and ensure the `docs` workflow `deploy` job succeeded. See [Local dev · Publish to GitHub Pages](./local-dev.md#publish-to-github-pages). - ## What should the GitHub repo About say? For **main**: diff --git a/docs-site/en/guide/index.md b/docs-site/en/guide/index.md index 560d1b5..b6f587b 100644 --- a/docs-site/en/guide/index.md +++ b/docs-site/en/guide/index.md @@ -6,8 +6,9 @@ Welcome to the PicHost user guide. This site is the single source of truth; the 1. [Quick start](./getting-started.md) — Docker and first-time setup 2. [Environment variables](./configuration.md) — Production config vs in-app settings -3. [Storage](./storage.md) — Backends and URL modes -4. [API](./api.md) — REST endpoints and tokens +3. [Changelog](./changelog.md) — Version history and upgrade notes +4. [Storage](./storage.md) — Backends and URL modes +5. [API](./api.md) — REST endpoints and tokens ## Screenshots diff --git a/docs-site/en/guide/local-dev.md b/docs-site/en/guide/local-dev.md index 67dbf37..1ea0dc4 100644 --- a/docs-site/en/guide/local-dev.md +++ b/docs-site/en/guide/local-dev.md @@ -51,16 +51,4 @@ npm run docs:build npm run docs:preview ``` -Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`), copied to `docs-site/public/screenshots/` before build. - -## Publish to GitHub Pages - -Live docs: - -**First-time setup** (if deploy fails or the site 404s): - -1. Repo **Settings → Pages** -2. **Build and deployment → Source** → **GitHub Actions** (not “Deploy from a branch”) -3. Push to `main` or run the `docs` workflow manually from Actions - -Allow 1–2 minutes after a successful deploy. If the `deploy` job fails immediately, Pages is usually not enabled yet. +Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`), copied to `docs-site/public/screenshots/` before build. Live docs: diff --git a/docs-site/en/guide/reverse-proxy.md b/docs-site/en/guide/reverse-proxy.md index 4576ced..1a64ede 100644 --- a/docs-site/en/guide/reverse-proxy.md +++ b/docs-site/en/guide/reverse-proxy.md @@ -41,18 +41,18 @@ See [Dual-domain separation](./domain-separation.md). ## Cloudflare orange cloud -Run PicHost on **your server / Docker** (port `6892`). On Cloudflare: +See [Cloudflare deployment](./cloudflare-deployment.md). | Practice | Notes | | -------- | ----- | | **Proxied DNS** | Both admin and image hostnames → origin proxy to `6892`, SSL **Full (strict)** | -| Preferred edge IPs | Compatible; Host remains your real domain | +| Preferred edge IPs | Compatible; keep **admin → admin**, **img → img** on origin | | **R2** | Object storage backend in **Storage**; not the same as hosting the app on Pages | | Origin firewall | Allow only [Cloudflare IPs](https://www.cloudflare.com/ips/); do not expose `6892` publicly | **Avoid:** -- **Cloudflare Pages** (`*.pages.dev`) or **Workers** (`*.workers.dev`) as a **full-site reverse proxy** to PicHost — extra hostnames can bypass dual-domain isolation ([Security notes](./domain-separation.md#security-notes)) +- **Cloudflare Pages** (`*.pages.dev`) or **Workers** (`*.workers.dev`) as a **full-site reverse proxy**, or chaining the image host to `fetch(admin…)` — rewrites Host and bypasses dual-domain isolation ([guide](./cloudflare-deployment.md)) - “Create Worker” from Git on this repo — `node-server` preset is not Workers-ready ## Origin hardening (dual-domain) diff --git a/docs-site/guide/changelog.md b/docs-site/guide/changelog.md new file mode 100644 index 0000000..b2a90a3 --- /dev/null +++ b/docs-site/guide/changelog.md @@ -0,0 +1,113 @@ +# 更新日志 + +PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [CHANGELOG.md](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md)。 + +--- + +## [1.2.2] — 2026-08-30 + +> 升级后请**重启服务**使中间件与 API 生效。 + +### 双域名配置与 Host 隔离 + +#### 修复 + +| 问题 | 说明 | +| ---- | ---- | +| 管理域被误清空 | 设置页保存时,关闭双域名或仅改图片域可能把 `site_base_url` 写成空字符串,导致 UI 切回单域、管理域「消失」 | +| 配置与生效 URL 混淆 | `GET /api/settings` 的 `siteBaseUrl` / `imageBaseUrl` 曾混入当前请求的 `origin`,设置页「当前生效」与数据库配置难以区分 | +| 第三 Host 绕过隔离 | `pages.dev`、裸 IP、旧图片域等未纳入配置的 Host 此前不被中间件拦截,后台/API 可能仍可访问 | + +#### 改进 + +**设置 API(`GET /api/settings`)** + +| 字段 | 含义 | +| ---- | ---- | +| `siteBaseUrl` / `imageBaseUrl` | **配置值**(DB → 环境变量),不含 request fallback | +| `effectiveSiteBaseUrl` / `effectiveImageBaseUrl` | **直链生成用**,未配置时回退到当前请求 origin | +| `runtime.currentOrigin` | 检测到的当前访问地址(仅展示,不写入配置) | +| `runtime.hostRole` | `site` / `image` / `unknown` / `single` | + +**保存 API(`PATCH /api/settings`)** + +- 新增 body 字段 `domainSeparation`(`true` / `false`),显式表达是否启用双域名 +- `domainSeparation: true` 时网站域与图片域**均必填**,且主机名不能相同 +- `domainSeparation: false` 时允许清空网站域 +- 未传 `domainSeparation` 且双域名已配置时,**禁止**单独把 `siteBaseUrl` 置空 + +**设置页 / 首次设置** + +- 表单回填配置值;「当前生效」显示 `effective*` 字段 +- 展示「检测到当前访问」地址,**不再**自动写入 `window.location.origin` +- 提供「填入检测地址」按钮(用户主动点击才填) +- 关闭双域名需确认弹窗 +- 双域名开启且 `hostRole === 'unknown'` 时显示反代绕过警告 + +**Host 中间件** + +- 双域名开启时,非 site/image 的 Host **应用层一律 404**(所有 HTTP 方法) +- 开发环境 `localhost` / `127.0.0.1` 例外,便于 `npm run dev` +- 与 Nginx `default_server` **互补**,建议两层都配置 + +#### 文档 + +- 重写 [Cloudflare / CF 优选部署](./cloudflare-deployment.md)(精简结构、检查清单、双层防护) +- 更新 [双域名分离](./domain-separation.md) 第三 Host 与应用层拦截说明 +- 新增本页 **更新日志** + +#### 升级说明 + +- **无需**数据库迁移或 CLI 操作 +- 若管理域此前已被误清空:在 **设置** 中重新填写网站域,或设置环境变量 `SITE_BASE_URL` 后重启 +- 生产双域名部署:确认反代 `server_name` 与设置一致;建议保留 Nginx default server + +--- + +## [1.2.1] — 2026-08-29 + +### 新增 + +- 登录滑动拼图验证(缺口对齐);验证与登录分两步 +- VitePress 文档站(`docs-site/`),部署至 GitHub Pages +- `npm run docs:dev` / `docs:build` / `docs:preview` + +### 修复 + +- 统计页删除图片后图库不刷新 +- 分页跳转输入框在 `type="number"` 下无法跳转 +- 非首页路由下复制链接格式偏好不持久化 +- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致 + +### 改进 + +- README 精简,详细说明迁入文档站 +- 双域名文档补充未知 Host 风险与 Cloudflare 部署说明 + +--- + +## [1.2.0] — 2026-08-28 + +### 变更 + +- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移 + +### 新增 + +- CLI:`docker exec pichost migrate` / `migrate --apply` +- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录) + +详见 [v1.2 迁移](./migration.md)。 + +--- + +## [1.1.x] 摘要 + +| 版本 | 要点 | +| ---- | ---- | +| 1.1.5 | API 上传后预览偶发失败修复;出图前校验文件存在 | +| 1.1.4 | 按年/月分组存储;网站域禁止直链出图 | +| 1.1.3 | 双域名分离(`SITE_BASE_URL` / `IMAGE_BASE_URL`);Host 中间件 | +| 1.1.0 | 多存储后端(S3 / R2 等) | + +更早版本见 [GitHub CHANGELOG](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md)。 diff --git a/docs-site/guide/cloudflare-deployment.md b/docs-site/guide/cloudflare-deployment.md new file mode 100644 index 0000000..98142ff --- /dev/null +++ b/docs-site/guide/cloudflare-deployment.md @@ -0,0 +1,240 @@ +# Cloudflare / CF 优选部署说明 + +PicHost 双域名模式下,应用根据请求 **Host**(及可信反代转发的 `X-Forwarded-Host`)区分**管理域**与**图片域**。使用 Cloudflare 橙云、优选线路、Workers 或 Pages 时,只要最终到达 PicHost 的 Host 身份不变,即可正常工作。 + +> **一句话原则:** 网络线路可以改变,请求的域名身份不能改变 — `admin → admin`,`img → img`,不要 `img → admin`。 + +基础概念见 [双域名分离](./domain-separation.md);反代通用要求见 [反向代理](./reverse-proxy.md)。 + +--- + +## 快速检查清单 + +部署或排错时,按顺序确认: + +| # | 检查项 | 期望 | +| - | ------ | ---- | +| 1 | 浏览器地址栏域名 | `admin.*` 或 `img.*`,不要用 IP / `pages.dev` 混用 | +| 2 | 中间是否经过 Pages / Worker | 图片域**不得** `fetch(admin…)` | +| 3 | 反代 upstream | 两个域名均指向 `http://127.0.0.1:6892` | +| 4 | Nginx `proxy_set_header Host` | `$host`(保持客户端域名) | +| 5 | PicHost 设置 | `SITE_BASE_URL` / `IMAGE_BASE_URL` 与 `server_name` 一致 | +| 6 | 端口 `6892` | 仅本机或内网反代可达,不对公网裸露 | + +--- + +## 推荐拓扑 + +两个域名经 Cloudflare(橙云)后,**各自独立**反代到同一 PicHost 实例: + +```mermaid +flowchart TB + internet[Internet] + cf[Cloudflare 橙云] + admin[admin.example.com] + img[img.example.com] + panel[1Panel / Nginx] + app[PicHost :6892] + + internet --> cf + cf --> admin + cf --> img + admin --> panel + img --> panel + panel --> app +``` + +```env +SITE_BASE_URL=https://admin.example.com +IMAGE_BASE_URL=https://img.example.com +``` + +```text +admin.example.com → http://127.0.0.1:6892 +img.example.com → http://127.0.0.1:6892 +``` + +不需要为「前端 / 后端 / 图片」开不同端口;一个 PicHost 实例、一个内部端口(默认 `6892`)即可。 + +### 管理域 ≠ 前端域名 + +| 域名 | 用途 | +| ---- | ---- | +| `admin.example.com` | 后台、登录、设置、图库、API、上传 | +| `img.example.com` | 图片直链、外链、CDN 分发 | + +更准确的说法是**管理域 / 图片域**,而非「前端 / 后端」。PicHost 生产环境也**不需要** `前端 :3000 + 后端 :6892` 这种拆分。 + +--- + +## Cloudflare 橙云与 CF 优选 + +**橙云(DNS Proxied)** 是正常且推荐的方式: + +```text +admin.example.com、img.example.com + A / CNAME → 源站 → Proxied +``` + +用户访问 `admin.example.com` 时,PicHost 应收到 `Host: admin.example.com`;访问 `img.example.com` 时应收到 `Host: img.example.com`。 + +**CF 优选**(优选 IP、优选域名、边缘入口)同样可用。优选可以改变用户到 Cloudflare 边缘的路径,但**不能**把图片域的回源身份变成管理域。两个域名应保持平行、独立的回源链: + +```text +admin → CF → admin.example.com → 源站 → PicHost +img → CF → img.example.com → 源站 → PicHost +``` + +--- + +## 六、典型错误拓扑 + +以下方案会破坏 Host 隔离,即使浏览器地址栏仍显示图片域: + +### ❌ 错误 1:图片域 fetch 到管理域 + +```mermaid +flowchart LR + user[用户 img.example.com] + worker[Pages / Worker] + admin[fetch admin.example.com] + app[PicHost] + + user --> worker --> admin --> app +``` + +最后一跳 Host 变成 `admin.example.com`,PicHost 会认为这是管理域 — `https://img.example.com/` 可能看到后台。这是 **Host 被改写**,不是缓存问题。 + +### ❌ 错误 2:优选回源到管理域 + +```text +img.example.com → 优选域名 → admin.example.com → 源站 +``` + +即使只为改善线路,只要 upstream 使用管理域,图片域隔离即失效。 + +### ❌ 错误 3:公网直接暴露 6892 + +```text +任意域名 / IP:6892 → PicHost +``` + +用户可绕开 Cloudflare 与 Nginx 的 `server_name` 限制。`6892` 应仅允许 `127.0.0.1` 或内网反代访问。 + +### ✅ 正确做法 + +```text +admin → admin → PicHost +img → img → PicHost +``` + +**不要** Pages / Workers **整站反代** PicHost(会多出 `pages.dev` / `workers.dev` 等第三入口)。PicHost 依赖 `node-server`、SQLite、`sharp` 与本地 `data/`,**不能**作为 Node 应用直接部署到 Pages/Workers;应用本体继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 即可。 + +--- + +## Workers / Pages 使用建议 + +若只需改响应头、鉴权、防盗链、日志或缓存,且源站仍是自己的 PicHost: + +| 推荐 | 不推荐 | +| ---- | ------ | +| `img.example.com` → **Worker Route** → 原 `img` 源站 | `img` → Worker → `fetch(admin.example.com)` | + +Cloudflare 对已有外部源站的场景更推荐 [Routes](https://developers.cloudflare.com/workers/configuration/routing/routes/)(在现有 proxied 域名与源站之前运行),而非用 [Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) 把 Worker 当作跨域 fetch 的中转。 + +Worker 中若出现: + +```js +fetch(`https://admin.example.com${url.pathname}`) +``` + +即基本可确定隔离被破坏。 + +--- + +## Nginx / 1Panel 配置 + +两个 `server` 块,upstream 相同,**关键行**是 `proxy_set_header Host $host;`: + +```nginx +server { + server_name admin.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + client_max_body_size 12m; + } +} + +server { + server_name img.example.com; + location / { + proxy_pass http://127.0.0.1:6892; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} + +# 拒绝裸 IP 与未配置域名(强烈建议) +server { + listen 80 default_server; + listen 443 ssl default_server; + server_name _; + return 444; +} +``` + +橙云部署时,源站防火墙建议**仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连。 + +--- + +## 双层 Host 防护 + +生产环境建议反代层与应用层**互补**,而非二选一: + +| 层级 | 作用 | +| ---- | ---- | +| **Nginx default server** | 拒绝裸 IP、`pages.dev` 等未在 `server_name` 中声明的 Host | +| **PicHost 中间件(v1.2.x+)** | 双域名开启时,非 site/image 的 Host 应用层返回 404(开发环境 `localhost` / `127.0.0.1` 例外) | + +即使应用层已拦截第三 Host,仍建议在反代配置 default server — 减少无效流量到达 PicHost。 + +--- + +## X-Forwarded-Host 说明 + +PicHost 在反代场景会参考 `X-Forwarded-Host` 识别请求域名,但**不能无条件信任**该 Header(客户端可伪造)。因此: + +- 不要让 `6892` 对公网裸露 +- 确保只有可信反代能到达应用 +- 首选方案仍是让 `Host` 从第一跳起就保持正确 + +--- + +## 排查:图片域能看到后台? + +**先查反代链,不要先清 CF 缓存。** + +缓存问题通常表现为旧 JS/CSS/图片或偶发 404;若图片域能**稳定**访问 `/`、`/login`、`/settings`,几乎都是 Host 被改写。 + +```text +① 浏览器实际域名? +② 是否经过 Pages / Worker / 优选反代? +③ 代理最终 fetch 的 URL? +④ 是否 fetch 到 admin.example.com? +⑤ 1Panel / PicHost 最终收到的 Host? +``` + +--- + +## 相关 + +- [双域名分离](./domain-separation.md) — 中间件行为与本地测试 +- [反向代理](./reverse-proxy.md) — 单域名与源站加固 +- [环境变量](./configuration.md) — `SITE_BASE_URL`、`IMAGE_BASE_URL` +- [常见问题](./faq.md) — Pages / Workers 与第三 Host diff --git a/docs-site/guide/configuration.md b/docs-site/guide/configuration.md index 0a25623..001e382 100644 --- a/docs-site/guide/configuration.md +++ b/docs-site/guide/configuration.md @@ -9,8 +9,8 @@ PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配 | API 上传 Token | 环境变量 → SQLite | **是**(设了则后台无法重新生成) | | WebP 质量 | SQLite → 环境变量 → 默认 80 | 否(未在 DB 存时读 env) | | 防盗链白名单 | SQLite → 环境变量 | 否 | -| 网站 / 图片域名 | SQLite → 环境变量 → 当前请求 | 否 | -| 隐藏 `images/` 前缀 | SQLite → 环境变量 → 默认 false | 否 | +| 网站 / 图片域名 | SQLite → 环境变量(设置 API 配置值);直链生成另用 `effective*` 或请求 origin | 否 | +| 简短图片链接 | SQLite → 环境变量 → 默认 false | 否 | | 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 | | 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 | @@ -33,11 +33,13 @@ PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配 - **网站域名**:管理后台、API、Twikoo 上传入口 - **图片域名**:复制链接与直链使用的公网地址 -双域名分离需两者主机名不同,详见 [双域名分离](./domain-separation.md)。填写后须与反代 `server_name` 一致;**勿**通过 `localhost`、IP 或未配置的域名访问生产后台。 +双域名分离需两者主机名不同,详见 [双域名分离](./domain-separation.md) 与 [Cloudflare / CF 优选部署](./cloudflare-deployment.md)。填写后须与反代 `server_name` 一致;**勿**通过 `localhost`、IP 或未配置的域名访问生产后台。 + +在后台 **设置** 中保存时须显式勾选「启用后台与图片域名分离」,并同时填写两个域名;关闭双域名会清除网站域配置(需确认)。设置 API 返回的配置值与「当前生效」直链地址已分离,详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。 ### `HIDE_FOLDER_IN_URL` -`true` 时直链隐藏 `images/` 前缀(如 `images/2026/08/a.webp` → `/2026/08/a.webp` 或仅文件名,取决于路径模式)。 +`true` 时使用简短图片链接(仅域名 + 文件名,如 `img.example.com/a8K3xP.webp`);`false` 时保留日期或自定义目录(如 `img.example.com/2026/08/a8K3xP.webp`)。对外 URL **不会** 暴露内部 `images/` 前缀;旧版含 `images/` 的直链仍兼容。 ### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` diff --git a/docs-site/guide/domain-separation.md b/docs-site/guide/domain-separation.md index bf0f37f..9a0005d 100644 --- a/docs-site/guide/domain-separation.md +++ b/docs-site/guide/domain-separation.md @@ -19,6 +19,8 @@ - **网站域名**:`https://admin.example.com` - **图片域名**:`https://pic.example.com` +首次设置与系统设置**不会**自动把当前浏览器地址写入网站域;可手动填写或点击「填入检测地址」。关闭双域名需确认,且会清除网站域配置。保存时 API 会收到 `domainSeparation: true/false` 字段。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。 + ## Nginx 示例 ```nginx @@ -56,36 +58,42 @@ server { ## 安全与注意事项 -### 中间件只隔离「已配置的两个域名」 +### 中间件隔离与第三 Host PicHost 根据请求 **Host**(反代会转发为 `X-Forwarded-Host`,应用会一并识别)判断当前是网站域还是图片域: - **图片域**:非图片路径(如 `/`、`/settings`)→ 404 - **网站域**:图片直链路径(如 `/images/...`)→ 404 +- **未配置的第三 Host**(如 `*.pages.dev`、裸 IP、旧图片域):**v1.2.x 起应用层一律 404**(开发环境 `localhost` / `127.0.0.1` 例外,便于 `npm run dev`) -若 Host **既不是网站域也不是图片域**(例如 `localhost`、服务器 **公网 IP**、Cloudflare **Pages** 的 `*.pages.dev`、**Workers** 的 `*.workers.dev`、或其它未在设置里填写的域名),**当前版本不会拦截**,后台与 API 仍可能被访问。这与域名是否带 `-`、长短无关,而是多了一条未纳入双域名配置的入口。 +这与域名是否带 `-`、长短无关;此前版本对第三 Host 不拦截,需依赖反代 default server 封堵。 **生产环境建议:** -1. 反代层仅为网站域、图片域配置 `server_name`,并增加 **default server** 拒绝其它 Host 与裸 IP(见上方 Nginx 示例) +1. 反代层仅为网站域、图片域配置 `server_name`,并增加 **default server** 拒绝其它 Host 与裸 IP(见上方 Nginx 示例,与应用层拦截互补) 2. 使用 Cloudflare **橙云** 时,源站防火墙 **仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连源站 3. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用 -4. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口,且易出现上述绕过) -5. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可 +4. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口;`IMAGE_BASE_URL` 可填 CDN 公网地址,但应用本体应 Docker/VPS 部署) +5. 若必须在边缘做反代,须保留客户端 **`Host` 或 `X-Forwarded-Host`**,否则 Host 隔离会失效 +6. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可 ### Cloudflare 橙云(含 DNS 优选) -**推荐架构:** `admin.example.com` 与 `pic.example.com` 均 **DNS Proxied(橙云)** → 源站 Nginx / 1Panel 反代 `6892`,SSL 模式 **Full (strict)**(源站可用 Cloudflare Origin Certificate)。 +完整说明见专章:[Cloudflare / CF 优选部署](./cloudflare-deployment.md)。 + +**推荐:** `admin.example.com` 与 `pic.example.com` 均 **DNS Proxied(橙云)** → 源站 Nginx / 1Panel 反代 `6892`,SSL 模式 **Full (strict)**。 | 能力 | 说明 | | ---- | ---- | | 橙云 CDN | 与 PicHost 双域名兼容;回源须保留 `Host`、`X-Forwarded-Proto` | -| DNS 优选 | 仅改变用户连到 CF 边缘的 IP,Host 仍为正式域名,无额外配置 | +| DNS 优选 | 仅改变用户连到 CF 边缘的 IP;**admin → admin、img → img** 的回源身份不能变 | | R2 对象存储 | 在 **存储** 页添加 R2 后端即可,与是否橙云无关 | | `cloudflare` Git 分支 | R2 专用部署线,仍是 Docker 运行,**不是** Workers 托管 | **勿混淆:** CF 控制台「创建 Worker」并连接本仓库 **无法** 直接部署 PicHost;`npm run build` + `wrangler deploy` 与当前 `node-server` 预设不兼容。 +**勿用 Pages/Worker 把图片域 `fetch` 到管理域** — 会破坏 Host 隔离(详见 [Cloudflare 部署说明 · 典型错误拓扑](./cloudflare-deployment.md#六典型错误拓扑))。 + 反代与源站加固详见 [反向代理](./reverse-proxy.md#cloudflare-橙云)。 ## 飞牛 NAS · Lucky 反向代理 @@ -157,7 +165,7 @@ npm run dev | `http://admin.pichost.test:3000/images/...` 或 `/2026/08/xxx.webp` | **404**(请用图片域直链) | | `http://pic.pichost.test:3000/` | **404**(正常,图片域不提供后台) | | `http://pic.pichost.test:3000/images/...` | 能出图 | -| `http://localhost:3000/` 或 `http://127.0.0.1:3000/` | **能打开后台**(第三 Host,隔离不生效;仅开发可接受,生产须用反代封禁) | +| `http://localhost:3000/` 或 `http://127.0.0.1:3000/` | **能打开后台**(开发环境例外;生产须用反代封禁未配置 Host) | 上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。 @@ -169,4 +177,4 @@ npm run dev - 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名 - 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名 -单域名反代要点见 [反向代理](./reverse-proxy.md)。英文版:[Dual-domain separation](/en/guide/domain-separation) +单域名反代要点见 [反向代理](./reverse-proxy.md)。 diff --git a/docs-site/guide/faq.md b/docs-site/guide/faq.md index 1c00fb0..799a367 100644 --- a/docs-site/guide/faq.md +++ b/docs-site/guide/faq.md @@ -22,11 +22,15 @@ docker exec pichost reset-password 用户名 ## 双域名下为什么用 pages.dev / workers.dev / IP 也能进后台? -PicHost 双域名隔离**只对已配置的网站域、图片域做路径分流**;其它 Host(`localhost`、公网 IP、Cloudflare Pages 的 `*.pages.dev`、Workers 的 `*.workers.dev` 等)**当前不会拦截**,因此可能出现「奇怪地址也能开后台」。 +**v1.2.2 起:** 双域名开启时,PicHost 中间件会对**未配置的网站域、图片域之外**的 Host 返回 **404**(开发环境 `localhost` / `127.0.0.1` 例外)。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。 -常见原因:在橙云之外又用 **Pages/Workers 整站反代** 到源站,或源站未限制 `server_name` / 允许裸 IP 访问。 +若仍能访问,常见原因: -处理:仅用正式两个域名访问;反代加 default server;橙云时源站只放行 CF IP。详见 [双域名分离 · 安全与注意事项](./domain-separation.md#安全与注意事项)。 +1. **未启用双域名**(仅配置了图片域、网站域为空)— 隔离与第三 Host 拦截均不生效 +2. **反代把 Host 改成管理域** — 例如 Pages/Worker 对图片域 `fetch(admin…)`,PicHost 看到的是管理域 +3. **源站裸暴露 `6892`** — 绕过 Cloudflare 与 Nginx `server_name` + +处理:在设置中正确配置双域名;反代加 default server;橙云时源站只放行 CF IP;勿用 Pages/Workers 整站反代。详见 [双域名分离](./domain-separation.md#中间件隔离与第三-host)、[Cloudflare 部署](./cloudflare-deployment.md) 与 [更新日志](./changelog.md#1-2-2-2026-08-30)。 ## 能把 PicHost 部署到 Cloudflare Workers / Pages 吗? @@ -64,8 +68,6 @@ PicHost 双域名隔离**只对已配置的网站域、图片域做路径分流* **本 VitePress 文档站** 为完整用户指南;README 保留摘要与快速开始。在线地址: -若访问 404,说明 GitHub Pages 尚未部署成功:请在仓库 **Settings → Pages** 将 Source 设为 **GitHub Actions**,并确认 `docs` workflow 的 deploy 步骤已通过。详见 [本地开发 · 发布到 GitHub Pages](./local-dev.md#发布到-github-pages)。 - ## GitHub 仓库 About 怎么写? **main** 分支建议: diff --git a/docs-site/guide/index.md b/docs-site/guide/index.md index 87b3b3f..6ef6611 100644 --- a/docs-site/guide/index.md +++ b/docs-site/guide/index.md @@ -6,8 +6,9 @@ 1. [快速开始](./getting-started.md) — Docker 部署与首次设置 2. [环境变量](./configuration.md) — 生产环境配置与后台设置对照 -3. [存储](./storage.md) — 多后端与直链模式 -4. [API](./api.md) — REST 接口与 Token +3. [更新日志](./changelog.md) — 版本变更与升级说明 +4. [存储](./storage.md) — 多后端与直链模式 +5. [API](./api.md) — REST 接口与 Token ## 截图预览 diff --git a/docs-site/guide/local-dev.md b/docs-site/guide/local-dev.md index 289cb1a..21c37d6 100644 --- a/docs-site/guide/local-dev.md +++ b/docs-site/guide/local-dev.md @@ -51,16 +51,4 @@ npm run docs:build # 构建静态站点 npm run docs:preview # 预览构建结果 ``` -文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png` 登录页),构建前会复制到 `docs-site/public/screenshots/`。 - -## 发布到 GitHub Pages - -在线文档地址: - -**首次启用**(部署 job 失败、访问 404 时检查): - -1. 仓库 **Settings → Pages** -2. **Build and deployment → Source** 选择 **GitHub Actions**(不要选 Deploy from a branch) -3. 推送 `main` 触发 `docs` workflow,或在 Actions 页手动 **Run workflow** - -构建成功后约 1–2 分钟可访问。`docs` workflow 的 `deploy` 步骤失败且日志提示 Pages 未启用时,多为未完成上述设置。 +文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png` 登录页),构建前会复制到 `docs-site/public/screenshots/`。在线文档: diff --git a/docs-site/guide/migration.md b/docs-site/guide/migration.md index 5583982..ec8c732 100644 --- a/docs-site/guide/migration.md +++ b/docs-site/guide/migration.md @@ -174,5 +174,3 @@ CLI 只负责**搬文件**;索引对齐、遗留 key、孤儿记录由启动 | `docker exec pichost migrate` | 预览磁盘迁移 | | `docker exec pichost migrate --apply` | 执行磁盘迁移 | | `docker exec pichost reset-password` | 重置密码(与迁移无关) | - -英文版:[v1.2 migration guide](/en/guide/migration) diff --git a/docs-site/guide/reverse-proxy.md b/docs-site/guide/reverse-proxy.md index ed05ec0..339261d 100644 --- a/docs-site/guide/reverse-proxy.md +++ b/docs-site/guide/reverse-proxy.md @@ -41,18 +41,18 @@ IMAGE_BASE_URL=https://pic.example.com ## Cloudflare 橙云 -PicHost 应用运行在 **自有服务器 / Docker**(端口 `6892`),Cloudflare 侧推荐: +详见专章:[Cloudflare / CF 优选部署](./cloudflare-deployment.md)。 | 做法 | 说明 | | ---- | ---- | -| **DNS 橙云** | `admin` / `pic` 两个子域 Proxied → 源站反代 `6892`,SSL **Full (strict)** | -| **DNS 优选** | 与 PicHost 兼容;Host 仍为正式域名 | +| **DNS 橙云** | `admin` / `img` 两个子域 Proxied → 源站反代 `6892`,SSL **Full (strict)** | +| **DNS 优选** | 与 PicHost 兼容;须保持 **admin → admin、img → img** 回源 | | **R2** | 在存储页作对象后端,非 Pages 托管应用 | | **源站防火墙** | 仅放行 [Cloudflare IP](https://www.cloudflare.com/ips/),禁止公网直连 `6892` | **不建议:** -- 用 **Cloudflare Pages**(`*.pages.dev`)或 **Workers**(`*.workers.dev`)对 PicHost **整站反代** — 会多出第三 Host,且可能绕过双域名隔离(见 [双域名分离 · 安全与注意事项](./domain-separation.md#安全与注意事项)) +- 用 **Cloudflare Pages**(`*.pages.dev`)或 **Workers**(`*.workers.dev`)对 PicHost **整站反代**,或让图片域 **fetch 到管理域** — 会改写 Host、绕过双域名隔离(见 [Cloudflare 部署说明](./cloudflare-deployment.md)) - 在 CF 控制台「从 Git 创建 Worker」部署本仓库 — 当前为 `node-server` 预设,无法直接 `wrangler deploy` ## 源站安全(双域名推荐) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 9c99a0c..ccafef8 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -198,8 +198,15 @@ "imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL", "imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlActive": "Active: {url}", - "hideFolderInUrl": "Hide folder in image URLs", - "hideFolderInUrlHint": "Links become /file.webp without folder or date paths; legacy full paths still work. Identical filenames under different subpaths may collide on short URLs", + "runtimeDetected": "Detected current access: {url} (will not be saved automatically)", + "fillDetectedOrigin": "Use detected URL", + "hostRoleUnknown": "Current Host does not match configured domains; reverse-proxy bypass may be possible", + "disableDomainSeparationTitle": "Disable domain separation", + "disableDomainSeparationDesc": "This clears the site URL and keeps only the image URL. Continue?", + "disableDomainSeparationConfirm": "Disable", + "domainSeparationRequired": "Both site and image URLs are required when domain separation is enabled", + "hideFolderInUrl": "Use short image links", + "hideFolderInUrlHint": "When on, links are only domain + filename, e.g. img.example.com/abc.webp. When off, date or custom folders are kept, e.g. img.example.com/2026/08/abc.webp", "storageUseDatePath": "Group uploads by year/month", "storageUseDatePathHint": "When enabled, files are stored as images/2026/08/file.webp; when disabled, as images/file.webp. Applies only to new uploads after enabling", "badgeEnv": ".env", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 71be5bf..92d4a5f 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -198,8 +198,15 @@ "imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名", "imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlActive": "当前生效:{url}", - "hideFolderInUrl": "隐藏直链目录前缀", - "hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧完整路径仍可用。子路径下若存在同名文件,短链可能冲突", + "runtimeDetected": "检测到当前访问:{url}(不会自动写入配置)", + "fillDetectedOrigin": "填入检测地址", + "hostRoleUnknown": "当前访问 Host 与配置不一致,可能存在反代绕过风险", + "disableDomainSeparationTitle": "关闭域名分离", + "disableDomainSeparationDesc": "将清除管理域名配置,仅保留图片域名。确定继续?", + "disableDomainSeparationConfirm": "确认关闭", + "domainSeparationRequired": "启用域名分离时需同时填写网站域名与图片域名", + "hideFolderInUrl": "使用简短图片链接", + "hideFolderInUrlHint": "开启后链接仅含域名与文件名,例如 img.example.com/abc.webp;关闭后保留日期或自定义目录,例如 img.example.com/2026/08/abc.webp", "storageUseDatePath": "上传目录按年/月分组", "storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传", "badgeEnv": ".env", diff --git a/package-lock.json b/package-lock.json index 5535e56..2ce25a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pichost", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pichost", - "version": "1.2.1", + "version": "1.2.2", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { @@ -22,8 +22,10 @@ "@nuxt/eslint": "^1.16.0", "@types/node": "^22.15.0", "eslint": "^10.6.0", + "mermaid": "^11.17.2", "typescript": "^6.0.3", "vitepress": "^1.6.4", + "vitepress-plugin-mermaid": "^2.0.17", "vitest": "^3.2.4", "vue-tsc": "^3.3.5" } @@ -1035,6 +1037,13 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "dev": true, + "license": "MIT" + }, "node_modules/@capsizecss/unpack": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/@capsizecss/unpack/-/unpack-4.0.1.tgz", @@ -1047,6 +1056,13 @@ "node": ">=18" } }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@clack/core": { "version": "1.4.3", "resolved": "https://registry.npmmirror.com/@clack/core/-/core-1.4.3.tgz", @@ -3500,6 +3516,41 @@ "node": ">=18" } }, + "node_modules/@mermaid-js/mermaid-mindmap": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz", + "integrity": "sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@braintree/sanitize-url": "^6.0.0", + "cytoscape": "^3.23.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.1.0", + "d3": "^7.0.0", + "khroma": "^2.0.0", + "non-layered-tidy-tree-layout": "^2.0.2" + } + }, + "node_modules/@mermaid-js/mermaid-mindmap/node_modules/@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, "node_modules/@miyaneee/rollup-plugin-json5": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@miyaneee/rollup-plugin-json5/-/rollup-plugin-json5-1.2.0.tgz", @@ -8337,6 +8388,290 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -8356,6 +8691,13 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.5.tgz", @@ -8429,6 +8771,14 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", @@ -9004,6 +9354,17 @@ "win32" ] }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@valibot/to-json-schema": { "version": "1.7.1", "resolved": "https://registry.npmmirror.com/@valibot/to-json-schema/-/to-json-schema-1.7.1.tgz", @@ -10633,6 +10994,16 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", @@ -10872,6 +11243,564 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.2", + "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.34.2.tgz", + "integrity": "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "dev": true, + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/db0": { "version": "0.3.4", "resolved": "https://registry.npmmirror.com/db0/-/db0-0.3.4.tgz", @@ -10994,6 +11923,16 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", @@ -11185,6 +12124,16 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", @@ -11415,6 +12364,19 @@ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", @@ -12265,6 +13227,16 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", @@ -12770,6 +13742,13 @@ "uncrypto": "^0.1.3" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "dev": true, + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", @@ -12918,6 +13897,19 @@ "node": ">=16.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", @@ -13013,6 +14005,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ioredis": { "version": "5.11.1", "resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-5.11.1.tgz", @@ -13414,6 +14416,33 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", @@ -13423,6 +14452,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", + "dev": true + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", @@ -13457,6 +14492,13 @@ "shell-quote": "^1.8.4" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "dev": true, + "license": "MIT" + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz", @@ -13870,6 +14912,13 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", @@ -14022,6 +15071,50 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.17.2", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -15026,6 +16119,14 @@ "node": ">=18" } }, + "node_modules/non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmmirror.com/nopt/-/nopt-8.1.0.tgz", @@ -15568,6 +16669,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", @@ -15678,6 +16786,24 @@ "node": ">=4" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.19", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.19.tgz", @@ -16712,6 +17838,13 @@ "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", @@ -16798,6 +17931,19 @@ "integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==", "license": "MIT" }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", @@ -16833,6 +17979,13 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -16853,6 +18006,13 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz", @@ -17301,6 +18461,13 @@ "text-decoder": "^1.1.0" } }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", @@ -17486,6 +18653,13 @@ "postcss": "^8.5.15" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "dev": true, + "license": "MIT" + }, "node_modules/superjson": { "version": "2.2.6", "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", @@ -17861,6 +19035,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -18628,6 +19812,20 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/valibot": { "version": "1.4.2", "resolved": "https://registry.npmmirror.com/valibot/-/valibot-1.4.2.tgz", @@ -19111,6 +20309,20 @@ } } }, + "node_modules/vitepress-plugin-mermaid": { + "version": "2.0.17", + "resolved": "https://registry.npmmirror.com/vitepress-plugin-mermaid/-/vitepress-plugin-mermaid-2.0.17.tgz", + "integrity": "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@mermaid-js/mermaid-mindmap": "^9.3.0" + }, + "peerDependencies": { + "mermaid": "10 || 11", + "vitepress": "^1.0.0 || ^1.0.0-alpha" + } + }, "node_modules/vitepress/node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", diff --git a/package.json b/package.json index 0c993bd..351db72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pichost", - "version": "1.2.1", + "version": "1.2.2", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "homepage": "https://github.com/O96u/PicHost#readme", "repository": { @@ -53,8 +53,10 @@ "@nuxt/eslint": "^1.16.0", "@types/node": "^22.15.0", "eslint": "^10.6.0", + "mermaid": "^11.17.2", "typescript": "^6.0.3", "vitepress": "^1.6.4", + "vitepress-plugin-mermaid": "^2.0.17", "vitest": "^3.2.4", "vue-tsc": "^3.3.5" } diff --git a/server/api/settings/index.patch.ts b/server/api/settings/index.patch.ts index 5185014..0047d9e 100644 --- a/server/api/settings/index.patch.ts +++ b/server/api/settings/index.patch.ts @@ -23,7 +23,7 @@ import { parseAutoDeleteDays, parseWebpQuality, setGlobalAutoDeletePolicy, - validateDomainSeparationPair + validateSettingsDomainPatch } from '../../utils/env' interface SettingsPatchBody { @@ -31,6 +31,7 @@ interface SettingsPatchBody { allowedRefererHosts?: unknown siteBaseUrl?: unknown imageBaseUrl?: unknown + domainSeparation?: unknown hideFolderInUrl?: unknown storageUseDatePath?: unknown autoDeleteDays?: unknown @@ -71,6 +72,11 @@ export default defineEventHandler(async (event) => { setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized) } + const existingSite = normalizeSiteBaseUrl(getSetting(SETTINGS_SITE_BASE_URL) ?? '') + const existingImage = normalizeImageBaseUrl(getSetting(SETTINGS_IMAGE_BASE_URL) ?? '') + let nextSite = existingSite + let nextImage = existingImage + if (body.siteBaseUrl !== undefined) { const configured = normalizeSiteBaseUrl( typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : '' @@ -79,11 +85,11 @@ export default defineEventHandler(async (event) => { createApiError( event, 'INVALID_REQUEST', - 'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名', + 'SITE_BASE_URL 需为 http(s) 地址,且末尾不加 /', 400 ) } - setSetting(SETTINGS_SITE_BASE_URL, configured) + nextSite = configured } if (body.imageBaseUrl !== undefined) { @@ -98,22 +104,36 @@ export default defineEventHandler(async (event) => { 400 ) } - setSetting(SETTINGS_IMAGE_BASE_URL, configured) + nextImage = configured } - if (body.siteBaseUrl !== undefined || body.imageBaseUrl !== undefined) { - const siteConfigured = normalizeSiteBaseUrl( - getSetting(SETTINGS_SITE_BASE_URL) ?? '' - ) - const imageConfigured = normalizeImageBaseUrl( - getSetting(SETTINGS_IMAGE_BASE_URL) ?? '' - ) - if (siteConfigured && imageConfigured) { - const pairError = validateDomainSeparationPair(siteConfigured, imageConfigured) - if (pairError) { - createApiError(event, 'INVALID_REQUEST', pairError, 400) - } - } + const domainSeparationProvided = body.domainSeparation !== undefined + const wantSeparation = domainSeparationProvided + ? Boolean(body.domainSeparation) + : null + + const patchError = validateSettingsDomainPatch({ + existingSite, + existingImage, + nextSite, + nextImage, + wantSeparation, + siteBaseUrlProvided: body.siteBaseUrl !== undefined + }) + if (patchError) { + createApiError(event, 'INVALID_REQUEST', patchError, 400) + } + + if (wantSeparation === false) { + nextSite = '' + } + + if (body.siteBaseUrl !== undefined || body.domainSeparation !== undefined) { + setSetting(SETTINGS_SITE_BASE_URL, nextSite) + } + + if (body.imageBaseUrl !== undefined) { + setSetting(SETTINGS_IMAGE_BASE_URL, nextImage) } if (body.autoDeleteDays !== undefined) { diff --git a/server/middleware/00-host-isolation.ts b/server/middleware/00-host-isolation.ts index aaa1bde..56c0f6f 100644 --- a/server/middleware/00-host-isolation.ts +++ b/server/middleware/00-host-isolation.ts @@ -1,9 +1,17 @@ -import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation' +import { + shouldBlockImageHostRequest, + shouldBlockSiteHostImageRequest, + shouldBlockUnknownHostRequest +} from '../utils/host-isolation' const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0' export default defineEventHandler((event) => { - if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) { + if ( + shouldBlockUnknownHostRequest(event) + || shouldBlockImageHostRequest(event) + || shouldBlockSiteHostImageRequest(event) + ) { setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL) throw createError({ statusCode: 404, statusMessage: 'Not Found' }) } diff --git a/server/utils/env.test.ts b/server/utils/env.test.ts index 27b0683..e463fa5 100644 --- a/server/utils/env.test.ts +++ b/server/utils/env.test.ts @@ -8,6 +8,7 @@ import { getAutoDeleteDays, getAutoDeleteDaysSource, getRefererSource, + getSettingsPayload, getSiteBaseUrl, getSiteBaseUrlConfigured, getSiteBaseUrlSource, @@ -17,13 +18,15 @@ import { isAutoDeleteDaysEnvConfigured, isRefererEnvConfigured, isWebpQualityEnvConfigured, - validateDomainSeparationPair + validateDomainSeparationPair, + validateSettingsDomainPatch } from './env' import { getSetting, SETTINGS_ALLOWED_REFERER_HOSTS, SETTINGS_API_UPLOAD_TOKEN, SETTINGS_AUTO_DELETE_DAYS, + SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL, SETTINGS_WEBP_QUALITY, generateApiUploadToken @@ -255,3 +258,65 @@ describe('validateDomainSeparationPair', () => { )).toBeNull() }) }) + +describe('validateSettingsDomainPatch', () => { + const dualExisting = { + existingSite: 'https://admin.example.com', + existingImage: 'https://pic.example.com' + } + + it('requires both URLs when enabling domain separation', () => { + expect(validateSettingsDomainPatch({ + ...dualExisting, + nextSite: '', + nextImage: 'https://pic.example.com', + wantSeparation: true, + siteBaseUrlProvided: true + })).toContain('同时填写') + }) + + it('blocks clearing site without explicit disable when dual domain is active', () => { + expect(validateSettingsDomainPatch({ + ...dualExisting, + nextSite: '', + nextImage: 'https://pic.example.com', + wantSeparation: null, + siteBaseUrlProvided: true + })).toContain('不能单独清空管理域名') + }) + + it('allows clearing site when explicitly disabling domain separation', () => { + expect(validateSettingsDomainPatch({ + ...dualExisting, + nextSite: '', + nextImage: 'https://pic.example.com', + wantSeparation: false, + siteBaseUrlProvided: true + })).toBeNull() + }) +}) + +describe('getSettingsPayload configured vs effective', () => { + afterEach(() => { + vi.clearAllMocks() + delete process.env.SITE_BASE_URL + delete process.env.IMAGE_BASE_URL + }) + + it('returns configured site URL without request fallback', () => { + vi.mocked(getSetting).mockImplementation((key) => { + if (key === SETTINGS_SITE_BASE_URL) return 'https://admin.example.com' + if (key === SETTINGS_IMAGE_BASE_URL) return 'https://pic.example.com' + return null + }) + vi.stubGlobal('getRequestURL', () => new URL('https://pages.dev')) + + const event = mockEvent() + const payload = getSettingsPayload(event) + + expect(payload.siteBaseUrl).toBe('https://admin.example.com') + expect(payload.effectiveSiteBaseUrl).toBe('https://admin.example.com') + expect(payload.runtime.currentHost).toBe('pages.dev') + expect(payload.runtime.hostRole).toBe('unknown') + }) +}) diff --git a/server/utils/env.ts b/server/utils/env.ts index 09e596b..5617205 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -465,12 +465,102 @@ export function validateDomainSeparationPair( return null } +export type RequestHostRole = 'site' | 'image' | 'unknown' | 'single' + +export function getIsolationRequestHost(event: H3Event): string { + const requestUrl = getRequestURL(event, { + xForwardedHost: true, + xForwardedProto: true + }) + return requestUrl.hostname.toLowerCase() +} + +export function validateSettingsDomainPatch(input: { + existingSite: string + existingImage: string + nextSite: string + nextImage: string + wantSeparation: boolean | null + siteBaseUrlProvided: boolean +}): string | null { + const { + existingSite, + existingImage, + nextSite, + nextImage, + wantSeparation, + siteBaseUrlProvided + } = input + + const hadDualDomain = (() => { + const siteHost = hostnameFromBaseUrl(existingSite) + const imageHost = hostnameFromBaseUrl(existingImage) + return Boolean(siteHost && imageHost && siteHost !== imageHost) + })() + + if (wantSeparation === true) { + if (!nextSite || !nextImage) { + return '启用域名分离时需同时填写网站域名与图片域名' + } + return validateDomainSeparationPair(nextSite, nextImage) + } + + if (wantSeparation === false) { + return null + } + + if (siteBaseUrlProvided && !nextSite && existingSite && hadDualDomain) { + return '已启用域名分离时不能单独清空管理域名,请显式关闭域名分离' + } + + if (nextSite && nextImage) { + return validateDomainSeparationPair(nextSite, nextImage) + } + + return null +} + +export function getRequestRuntime(event: H3Event) { + const requestUrl = getRequestURL(event, { + xForwardedHost: true, + xForwardedProto: true + }) + const currentHost = requestUrl.hostname.toLowerCase() + const currentOrigin = requestUrl.origin + + if (!isDomainSeparationActive(event)) { + return { + currentOrigin, + currentHost, + hostRole: 'single' as const + } + } + + const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event)) + const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event)) + + let hostRole: RequestHostRole = 'unknown' + if (currentHost === siteHost) { + hostRole = 'site' + } else if (currentHost === imageHost) { + hostRole = 'image' + } + + return { + currentOrigin, + currentHost, + hostRole + } +} + export function getSettingsPayload(event: H3Event) { const config = useRuntimeConfig(event) const webpQuality = getWebpQuality(event) const allowedRefererHosts = getAllowedRefererHostsRaw(event) - const siteBaseUrl = getSiteBaseUrl(event) - const imageBaseUrl = getImageBaseUrl(event) + const siteBaseUrlConfigured = getSiteBaseUrlConfigured(event) + const imageBaseUrlConfigured = getImageBaseUrlConfigured(event) + const effectiveSiteBaseUrl = getSiteBaseUrl(event) + const effectiveImageBaseUrl = getImageBaseUrl(event) const autoDeleteDays = getAutoDeleteDays(event) const appVersion = config.appVersion as string const domainSeparation = isDomainSeparationActive(event) @@ -488,13 +578,16 @@ export function getSettingsPayload(event: H3Event) { allowedRefererHosts, refererSource: getRefererSource(), refererEnvFallback: getRefererHostsFromEnv(), - siteBaseUrl, - siteBaseUrlConfigured: getSiteBaseUrlConfigured(event), + siteBaseUrl: siteBaseUrlConfigured, + siteBaseUrlConfigured, siteBaseUrlSource: getSiteBaseUrlSource(event), - imageBaseUrl, - imageBaseUrlConfigured: getImageBaseUrlConfigured(event), + imageBaseUrl: imageBaseUrlConfigured, + imageBaseUrlConfigured, imageBaseUrlSource: getImageBaseUrlSource(event), + effectiveSiteBaseUrl, + effectiveImageBaseUrl, domainSeparation, + runtime: getRequestRuntime(event), hideFolderInUrl, hideFolderInUrlSource, storageUseDatePath, @@ -507,8 +600,8 @@ export function getSettingsPayload(event: H3Event) { env: { webpQuality, refererConfigured: allowedRefererHosts.length > 0, - siteBaseUrl, - imageBaseUrl, + siteBaseUrl: effectiveSiteBaseUrl, + imageBaseUrl: effectiveImageBaseUrl, hideFolderInUrl, appVersion } diff --git a/server/utils/host-isolation.test.ts b/server/utils/host-isolation.test.ts index 761abec..5b6d055 100644 --- a/server/utils/host-isolation.test.ts +++ b/server/utils/host-isolation.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { H3Event } from 'h3' import { shouldBlockImageHostRequest, - shouldBlockSiteHostImageRequest + shouldBlockSiteHostImageRequest, + shouldBlockUnknownHostRequest } from './host-isolation' function mockEvent(): H3Event { @@ -96,6 +97,18 @@ describe('shouldBlockSiteHostImageRequest', () => { })).toBe(true) }) + it('blocks public image paths on site host', () => { + const event = mockEvent() + expect(shouldBlockSiteHostImageRequest(event, { + separationActive: true, + siteHost, + requestHost: siteHost, + method: 'GET', + pathname: '/2026/08/demo.webp', + hideFolder: false + })).toBe(true) + }) + it('blocks legacy date short paths on site host', () => { const event = mockEvent() expect(shouldBlockSiteHostImageRequest(event, { @@ -130,3 +143,71 @@ describe('shouldBlockSiteHostImageRequest', () => { })).toBe(false) }) }) + +describe('shouldBlockUnknownHostRequest', () => { + const imageHost = 'image.example.com' + const siteHost = 'admin.example.com' + + it('does not block when separation is inactive', () => { + const event = mockEvent() + expect(shouldBlockUnknownHostRequest(event, { + separationActive: false, + requestHost: 'pages.dev', + siteHost, + imageHost + })).toBe(false) + }) + + it('blocks unconfigured third host when separation is active', () => { + const event = mockEvent() + expect(shouldBlockUnknownHostRequest(event, { + separationActive: true, + requestHost: 'pages.dev', + siteHost, + imageHost + })).toBe(true) + }) + + it('blocks admin paths on image host when host is unconfigured', () => { + const event = mockEvent() + expect(shouldBlockUnknownHostRequest(event, { + separationActive: true, + requestHost: 'old-img.example.com', + siteHost, + imageHost + })).toBe(true) + }) + + it('allows configured site host', () => { + const event = mockEvent() + expect(shouldBlockUnknownHostRequest(event, { + separationActive: true, + requestHost: siteHost, + siteHost, + imageHost + })).toBe(false) + }) + + it('allows configured image host', () => { + const event = mockEvent() + expect(shouldBlockUnknownHostRequest(event, { + separationActive: true, + requestHost: imageHost, + siteHost, + imageHost + })).toBe(false) + }) + + it('allows localhost in development', () => { + const previous = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + const event = mockEvent() + expect(shouldBlockUnknownHostRequest(event, { + separationActive: true, + requestHost: 'localhost', + siteHost, + imageHost + })).toBe(false) + process.env.NODE_ENV = previous + }) +}) diff --git a/server/utils/host-isolation.ts b/server/utils/host-isolation.ts index 8664867..d0b5726 100644 --- a/server/utils/host-isolation.ts +++ b/server/utils/host-isolation.ts @@ -8,6 +8,20 @@ import { } from './env' import { requestPathToImageKey } from './serve-image' +const DEV_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1']) + +function getIsolationRequestHost(event: H3Event): string { + const requestUrl = getRequestURL(event, { + xForwardedHost: true, + xForwardedProto: true + }) + return requestUrl.hostname.toLowerCase() +} + +function isDevLocalHost(host: string): boolean { + return process.env.NODE_ENV === 'development' && DEV_LOCAL_HOSTS.has(host) +} + export function getConfiguredSiteHost(event: H3Event): string | null { return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event)) } @@ -16,6 +30,30 @@ export function getConfiguredImageHost(event: H3Event): string | null { return hostnameFromBaseUrl(getImageBaseUrlConfigured(event)) } +/** 双域名开启时,拒绝未配置的第三 Host(开发环境 localhost 例外) */ +export function shouldBlockUnknownHostRequest( + event: H3Event, + options?: { + requestHost?: string + separationActive?: boolean + siteHost?: string | null + imageHost?: string | null + } +): boolean { + const separationActive = options?.separationActive ?? isDomainSeparationActive(event) + if (!separationActive) return false + + const siteHost = options?.siteHost ?? getConfiguredSiteHost(event) + const imageHost = options?.imageHost ?? getConfiguredImageHost(event) + if (!siteHost || !imageHost) return false + + const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase() + if (requestHost === siteHost || requestHost === imageHost) return false + if (isDevLocalHost(requestHost)) return false + + return true +} + export function shouldBlockImageHostRequest( event: H3Event, options?: { @@ -34,19 +72,16 @@ export function shouldBlockImageHostRequest( const imageHost = options?.imageHost ?? getConfiguredImageHost(event) if (!imageHost) return false - const requestUrl = options?.requestHost - ? null - : getRequestURL(event, { - xForwardedHost: true, - xForwardedProto: true - }) - const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase() + const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase() if (requestHost !== imageHost) return false const method = (options?.method ?? getMethod(event)).toUpperCase() if (method !== 'GET' && method !== 'HEAD') return true - const pathname = options?.pathname ?? requestUrl?.pathname ?? '' + const pathname = options?.pathname ?? getRequestURL(event, { + xForwardedHost: true, + xForwardedProto: true + }).pathname const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event) return requestPathToImageKey(pathname, { hideFolder @@ -71,19 +106,16 @@ export function shouldBlockSiteHostImageRequest( const siteHost = options?.siteHost ?? getConfiguredSiteHost(event) if (!siteHost) return false - const requestUrl = options?.requestHost - ? null - : getRequestURL(event, { - xForwardedHost: true, - xForwardedProto: true - }) - const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase() + const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase() if (requestHost !== siteHost) return false const method = (options?.method ?? getMethod(event)).toUpperCase() if (method !== 'GET' && method !== 'HEAD') return false - const pathname = options?.pathname ?? requestUrl?.pathname ?? '' + const pathname = options?.pathname ?? getRequestURL(event, { + xForwardedHost: true, + xForwardedProto: true + }).pathname const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event) return requestPathToImageKey(pathname, { hideFolder diff --git a/server/utils/image-public-path.test.ts b/server/utils/image-public-path.test.ts index 26d6df4..d6de17e 100644 --- a/server/utils/image-public-path.test.ts +++ b/server/utils/image-public-path.test.ts @@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest' import { isBareImageFilename, isHiddenImageDatePath, - toPublicImagePath + storageKeyToPublicPath, + toPublicImagePath, + toStorageKeyFromPublicPath } from './image-public-path' describe('toPublicImagePath', () => { - it('returns key unchanged when hideFolder is false', () => { - expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('images/2026/08/a.webp') - expect(toPublicImagePath('images/a.webp', false)).toBe('images/a.webp') + it('strips internal images/ prefix when hideFolder is false', () => { + expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('2026/08/a.webp') + expect(toPublicImagePath('images/a.webp', false)).toBe('a.webp') + expect(toPublicImagePath('images/blog/a.webp', false)).toBe('blog/a.webp') }) it('returns basename when hideFolder is true', () => { @@ -18,6 +21,29 @@ describe('toPublicImagePath', () => { }) }) +describe('storageKeyToPublicPath', () => { + it('removes images/ prefix only', () => { + expect(storageKeyToPublicPath('images/2026/08/a.webp')).toBe('2026/08/a.webp') + }) +}) + +describe('toStorageKeyFromPublicPath', () => { + it('maps public relative paths to storage keys', () => { + expect(toStorageKeyFromPublicPath('2026/08/a.webp')).toBe('images/2026/08/a.webp') + expect(toStorageKeyFromPublicPath('blog/a.webp')).toBe('images/blog/a.webp') + expect(toStorageKeyFromPublicPath('a.webp')).toBe('images/a.webp') + }) + + it('accepts legacy full storage keys', () => { + expect(toStorageKeyFromPublicPath('images/2026/08/a.webp')).toBe('images/2026/08/a.webp') + }) + + it('rejects invalid paths', () => { + expect(toStorageKeyFromPublicPath('')).toBeNull() + expect(toStorageKeyFromPublicPath('../../../etc/passwd')).toBeNull() + }) +}) + describe('isHiddenImageDatePath', () => { it('accepts YYYY/MM/file paths', () => { expect(isHiddenImageDatePath('2026/08/demo.webp')).toBe(true) diff --git a/server/utils/image-public-path.ts b/server/utils/image-public-path.ts index 0f2dc18..180d24e 100644 --- a/server/utils/image-public-path.ts +++ b/server/utils/image-public-path.ts @@ -1,9 +1,30 @@ import { basename } from 'node:path' +import { DEFAULT_FOLDER, validateImageKey } from './image-key' -/** 对外 URL 路径:可选隐藏目录前缀(输出纯文件名) */ +/** 存储 key → 对外 URL 路径(不含域名) */ +export function storageKeyToPublicPath(key: string): string { + const prefix = `${DEFAULT_FOLDER}/` + if (key.startsWith(prefix)) return key.slice(prefix.length) + return key +} + +/** + * 对外 URL 路径: + * - hideFolder=true:仅文件名(img.com/a8K3xP.webp) + * - hideFolder=false:去掉内部 images/ 前缀(img.com/2026/08/a.webp) + */ export function toPublicImagePath(key: string, hideFolder: boolean): string { - if (!hideFolder) return key - return basename(key) + if (hideFolder) return basename(key) + return storageKeyToPublicPath(key) +} + +/** 公开相对路径 → 存储 key;legacy 完整 key(含 images/)仍接受 */ +export function toStorageKeyFromPublicPath(publicPath: string): string | null { + const raw = publicPath.replace(/^\/+/, '') + if (!raw) return null + if (validateImageKey(raw)) return raw + const candidate = `${DEFAULT_FOLDER}/${raw}` + return validateImageKey(candidate) ? candidate : null } const HIDDEN_DATE_PATH_PATTERN diff --git a/server/utils/serve-image-path.test.ts b/server/utils/serve-image-path.test.ts index b5b1a77..dccbb02 100644 --- a/server/utils/serve-image-path.test.ts +++ b/server/utils/serve-image-path.test.ts @@ -19,14 +19,16 @@ describe('requestPathToImageKey hide folder', () => { .toBe('images/demo.webp') }) - it('does not resolve short paths when hide folder is disabled', () => { + it('resolves public paths when hide folder is disabled', () => { expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false })) - .toBeNull() + .toBe('images/2026/08/demo.webp') + expect(requestPathToImageKey('/blog/demo.webp', { hideFolder: false })) + .toBe('images/blog/demo.webp') expect(requestPathToImageKey('/demo.webp', { hideFolder: false })) - .toBeNull() + .toBe('images/demo.webp') }) - it('accepts flat full key when hide folder is disabled', () => { + it('accepts legacy full key when hide folder is disabled', () => { expect(requestPathToImageKey('/images/demo.webp', { hideFolder: false })) .toBe('images/demo.webp') }) diff --git a/server/utils/serve-image.ts b/server/utils/serve-image.ts index ff3ad3a..b2dd418 100644 --- a/server/utils/serve-image.ts +++ b/server/utils/serve-image.ts @@ -8,6 +8,7 @@ import { } from './env' import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index' import { validateImageKey } from './image-key' +import { toStorageKeyFromPublicPath } from './image-public-path' import { buildPublicImageUrl } from './image-response' import { createImageStream, headImage } from './storage' import { getBackendRowForKey } from './storage/resolver' @@ -80,15 +81,20 @@ export function requestPathToImageKey( options?: { hideFolder?: boolean } ): string | null { const raw = decodeURIComponent(pathname).replace(/^\/+/, '') + if (!raw) return null + if (validateImageKey(raw)) return raw - if (options?.hideFolder) { - const byFilename = findImageKeyByFilename(raw) - if (byFilename && validateImageKey(byFilename)) return byFilename + const fromPublic = toStorageKeyFromPublicPath(raw) + if (fromPublic) return fromPublic - const byDatePath = findImageKeyByDatePath(raw) - if (byDatePath && validateImageKey(byDatePath)) return byDatePath - } + if (!options?.hideFolder) return null + + const byFilename = findImageKeyByFilename(raw) + if (byFilename && validateImageKey(byFilename)) return byFilename + + const byDatePath = findImageKeyByDatePath(raw) + if (byDatePath && validateImageKey(byDatePath)) return byDatePath return null }