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) => {
+
+ { if (!v) cancelDisableDomainSeparation() }"
+ >
+
+
+
+
+
+
+
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
}