2 Commits
Author SHA1 Message Date
O96u b05d572221 release: v1.2.8 — 双域名保存确认与 clear-domains 恢复
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
启用双域名时以确认框提示锁死风险与恢复方式,不再强制校验 Host。
新增 clear-domains 运维 CLI 与相关文档。
2026-09-04 14:46:37 +08:00
O96u 4789d19d06 docs: 补充 v1.2.7 登录验证(滑块 / Turnstile / Cap)文档 2026-09-03 13:57:20 +08:00
31 changed files with 418 additions and 28 deletions

No files matched your search

+3
View File
@@ -35,3 +35,6 @@
# 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启) # 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启)
# DEV_BYPASS_ACCESS=false # DEV_BYPASS_ACCESS=false
# 登录人机验证(滑块 / Turnstile / Cap)在后台「设置 → 访问控制」配置,无环境变量。
# 配置错误无法登录时:docker exec pichost slider(或 npm run slider)恢复为本地滑块。
+14
View File
@@ -4,6 +4,20 @@
## [Unreleased] ## [Unreleased]
## [1.2.8] - 2026-09-04
### 改进
- 双域名:保存或初始化时弹出风险确认(说明可能锁死及 `clear-domains` 恢复方式),不再强制校验当前 Host
### 新增
- 运维 CLI `clear-domains`:清除数据库中的网站域 / 图片域配置(`docker exec pichost clear-domains`)
### 文档
- FAQ、双域名、反向代理文档补充锁死原因与恢复步骤
## [1.2.7] - 2026-09-03 ## [1.2.7] - 2026-09-03
### 新增 ### 新增
+1
View File
@@ -26,6 +26,7 @@ COPY --from=build /app/.output ./.output
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs
COPY server/cli/slider.mjs ./server/cli/slider.mjs COPY server/cli/slider.mjs ./server/cli/slider.mjs
COPY server/cli/clear-domains.mjs ./server/cli/clear-domains.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
+1 -1
View File
@@ -42,7 +42,7 @@
## Features ## Features
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
- **Multi-user** — login with slider captcha, optional registration; users see only their images - **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images
- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs - **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs
- **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown - **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible - **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
+1 -1
View File
@@ -42,7 +42,7 @@
## 特性 ## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片 - **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public` - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布 - **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容 - **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
+64 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SettingsTab } from '~/types/settings' import type { SettingsTab } from '~/types/settings'
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings' import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
import { willActivateDomainSeparation } from '~/utils/domain-separation'
type SettingSource = 'env' | 'db' | 'none' type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default' type WebpQualitySource = 'env' | 'db' | 'default'
@@ -112,6 +113,7 @@ const capSecretDraft = ref('')
const domainSeparationDraft = ref(false) const domainSeparationDraft = ref(false)
const disableDomainSeparationOpen = ref(false) const disableDomainSeparationOpen = ref(false)
const pendingDomainSeparationDisable = ref(false) const pendingDomainSeparationDisable = ref(false)
const domainSeparationSaveConfirmOpen = ref(false)
interface ReleaseCheckResponse { interface ReleaseCheckResponse {
currentVersion: string currentVersion: string
@@ -138,6 +140,25 @@ const hasServerChanges = computed(() => {
|| capSecretDraft.value !== settings.value.capSecret || capSecretDraft.value !== settings.value.capSecret
}) })
const domainSeparationWouldActivate = computed(() =>
willActivateDomainSeparation(
domainSeparationDraft.value,
siteBaseUrlDraft.value,
imageBaseUrlDraft.value
)
)
const domainSeparationFieldsChanged = computed(() => {
if (!settings.value) return false
return domainSeparationDraft.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrl
})
const needsDomainSeparationSaveConfirm = computed(() =>
domainSeparationWouldActivate.value && domainSeparationFieldsChanged.value
)
function sourceBadge(source: SettingSource | WebpQualitySource) { function sourceBadge(source: SettingSource | WebpQualitySource) {
switch (source) { switch (source) {
case 'env': case 'env':
@@ -233,6 +254,18 @@ async function saveServerSettings() {
} }
} }
if (needsDomainSeparationSaveConfirm.value) {
domainSeparationSaveConfirmOpen.value = true
return
}
await performSaveServerSettings()
}
async function performSaveServerSettings() {
domainSeparationSaveConfirmOpen.value = false
if (!isAuthenticated.value || !hasServerChanges.value) return
savingServer.value = true savingServer.value = true
try { try {
await patchSettings({ await patchSettings({
@@ -496,12 +529,14 @@ watch(isAdmin, () => {
</a> </a>
</p> </p>
<p <UAlert
v-if="domainSeparationDraft && settings.runtime.hostRole === 'unknown'" v-if="domainSeparationDraft && settings.runtime.hostRole === 'unknown'"
class="rounded-xl border border-error/25 bg-error/5 px-4 py-3 text-xs leading-relaxed text-error" color="warning"
> variant="subtle"
{{ t('settings.hostRoleUnknown') }} icon="i-lucide-triangle-alert"
</p> :title="t('settings.hostRoleUnknown')"
class="mb-4"
/>
<SettingsSection <SettingsSection
:title="t('settings.pathAndLink')" :title="t('settings.pathAndLink')"
@@ -876,6 +911,30 @@ watch(isAdmin, () => {
</div> </div>
</template> </template>
</UModal> </UModal>
<UModal
:open="domainSeparationSaveConfirmOpen"
:title="t('domainSeparationConfirm.title')"
:description="t('domainSeparationConfirm.description')"
@update:open="(v) => { if (!v) domainSeparationSaveConfirmOpen = false }"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="() => { domainSeparationSaveConfirmOpen = false }"
/>
<UButton
:label="t('domainSeparationConfirm.confirm')"
color="warning"
:loading="savingServer"
@click="performSaveServerSettings"
/>
</div>
</template>
</UModal>
</div> </div>
</template> </template>
+47 -1
View File
@@ -2,6 +2,7 @@
import logoLight from '~/assets/image/logo-light.png' import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png' import logoDark from '~/assets/image/logo-dark.png'
import { isPasswordValid } from '~/utils/password-strength' import { isPasswordValid } from '~/utils/password-strength'
import { willActivateDomainSeparation } from '~/utils/domain-separation'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -26,6 +27,15 @@ const domainSeparation = ref(false)
const siteBaseUrl = ref('') const siteBaseUrl = ref('')
const imageBaseUrl = ref('') const imageBaseUrl = ref('')
const loading = ref(false) const loading = ref(false)
const domainSeparationConfirmOpen = ref(false)
const domainSeparationWouldActivate = computed(() =>
willActivateDomainSeparation(
domainSeparation.value,
siteBaseUrl.value,
imageBaseUrl.value
)
)
const canSubmit = computed(() => { const canSubmit = computed(() => {
if (!username.value || !password.value || !confirmPassword.value) return false if (!username.value || !password.value || !confirmPassword.value) return false
@@ -33,7 +43,7 @@ const canSubmit = computed(() => {
if (!isPasswordValid(password.value)) return false if (!isPasswordValid(password.value)) return false
if (!AUTH_USERNAME_PATTERN.test(username.value.trim())) return false if (!AUTH_USERNAME_PATTERN.test(username.value.trim())) return false
if (domainSeparation.value) { if (domainSeparation.value) {
return Boolean(siteBaseUrl.value.trim() && imageBaseUrl.value.trim()) if (!siteBaseUrl.value.trim() || !imageBaseUrl.value.trim()) return false
} }
return true return true
}) })
@@ -63,6 +73,18 @@ async function submit() {
return return
} }
if (domainSeparationWouldActivate.value) {
domainSeparationConfirmOpen.value = true
return
}
await performSubmit()
}
async function performSubmit() {
domainSeparationConfirmOpen.value = false
if (!canSubmit.value || loading.value) return
loading.value = true loading.value = true
try { try {
const payload = { const payload = {
@@ -248,5 +270,29 @@ async function submit() {
/> />
</form> </form>
</div> </div>
<UModal
:open="domainSeparationConfirmOpen"
:title="t('domainSeparationConfirm.title')"
:description="t('domainSeparationConfirm.description')"
@update:open="(v) => { if (!v) domainSeparationConfirmOpen = false }"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="() => { domainSeparationConfirmOpen = false }"
/>
<UButton
:label="t('domainSeparationConfirm.confirm')"
color="warning"
:loading="loading"
@click="performSubmit"
/>
</div>
</template>
</UModal>
</div> </div>
</template> </template>
+24
View File
@@ -0,0 +1,24 @@
export function hostnameFromBaseUrl(value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
return new URL(trimmed).hostname.toLowerCase()
} catch {
return null
}
}
/** 勾选分离且网站域、图片域主机名不同 → 保存后将启用双域名中间件隔离 */
export function willActivateDomainSeparation(
enabled: boolean,
siteBaseUrl: string,
imageBaseUrl: string
): boolean {
if (!enabled) return false
const site = siteBaseUrl.trim()
const image = imageBaseUrl.trim()
if (!site || !image) return false
const siteHost = hostnameFromBaseUrl(site)
const imageHost = hostnameFromBaseUrl(image)
return Boolean(siteHost && imageHost && siteHost !== imageHost)
}
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- "6892:6892" - "6892:6892"
# 首次访问 Web 引导创建管理员,无需预先配置环境变量 # 首次访问 Web 引导创建管理员,无需预先配置环境变量
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等 # 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
# 运维:docker exec pichost reset-password | migrate [--apply] | slider # 运维:docker exec pichost reset-password | migrate [--apply] | slider | clear-domains [--site]
volumes: volumes:
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data) # 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
- ./data:/data - ./data:/data
+4
View File
@@ -13,4 +13,8 @@ if [ "$1" = "slider" ]; then
exec node /app/server/cli/slider.mjs "${@:2}" exec node /app/server/cli/slider.mjs "${@:2}"
fi fi
if [ "$1" = "clear-domains" ]; then
exec node /app/server/cli/clear-domains.mjs "${@:2}"
fi
exec node /app/.output/server/index.mjs "$@" exec node /app/.output/server/index.mjs "$@"
+14
View File
@@ -4,6 +4,20 @@ Version history and notable changes. The full log is also in the repo root [CHAN
--- ---
## [1.2.8] — 2026-09-04
### Improved
- **Dual-domain**: confirm dialog on save/setup with lockout risks and `clear-domains` recovery; no longer blocks save based on current Host
### Added
- **Ops CLI**: `clear-domains` clears site/image URL settings from DB (`docker exec pichost clear-domains`)
### Docs
- FAQ and dual-domain / reverse-proxy guides updated with recovery steps
## [1.2.7] — 2026-09-03 ## [1.2.7] — 2026-09-03
### Added ### Added
+14
View File
@@ -13,6 +13,7 @@ Configure PicHost via environment variables (Docker / `.env`) or the in-app **Se
| Short image links | SQLite → env → default false | No | | Short image links | SQLite → env → default false | No |
| Date-based paths | SQLite → env → default true | No | | Date-based paths | SQLite → env → default true | No |
| Global auto-delete days | SQLite → env → default 0 | No | | Global auto-delete days | SQLite → env → default 0 | No |
| Login verification | SQLite (Settings → Access control) | No |
## Variables ## Variables
@@ -64,6 +65,18 @@ Delete images older than N days globally; `0` disables. Affects **new uploads af
Local dev only: `true` bypasses login. Never use in production. Local dev only: `true` bypasses login. Never use in production.
### Login verification (Settings only)
Configure under **Settings → Access control** (stored in SQLite; no env vars):
| Method | Notes |
| ------ | ----- |
| **Local slider** (default) | No third-party service |
| **Cloudflare Turnstile** | Site key + secret key |
| **Cap** | API endpoint + secret |
Login and registration share the same method. If misconfigured: `docker exec pichost slider` (or `npm run slider`) resets to the slider.
### Storage backends (optional) ### Storage backends (optional)
Admins usually add backends in **Storage** UI; env vars such as `STORAGE_BACKEND=s3` and `S3_*` are also supported. See [Storage](./storage.md). Admins usually add backends in **Storage** UI; env vars such as `STORAGE_BACKEND=s3` and `S3_*` are also supported. See [Storage](./storage.md).
@@ -99,5 +112,6 @@ Restart the container after changing env vars.
| Date-based paths | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` | | Date-based paths | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP quality | `WEBP_QUALITY` | | WebP quality | `WEBP_QUALITY` |
| Auto-delete | `AUTO_DELETE_DAYS` | | Auto-delete | `AUTO_DELETE_DAYS` |
| Login verification | (Settings → Access control only) |
Template: [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example) in the repo root. Template: [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example) in the repo root.
+15 -4
View File
@@ -69,10 +69,21 @@ This is independent of hyphenated or long domain names. Older versions did not b
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) 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 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 3. **Before saving**, read the risk confirmation in Settings / setup; saving via IP, LAN, or a proxy that does not forward Host correctly may lock you out — see **Recovery when locked out** below
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) 4. Sign in only on the **site hostname** from Settings; do not mix `localhost`, IP, or unlisted domains
5. If you must proxy at the edge, preserve the client **`Host` or `X-Forwarded-Host`** or host isolation will fail 5. 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)
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 6. If you must proxy at the edge, preserve the client **`Host` or `X-Forwarded-Host`** (Lucky/NPM “force hostname” must match PicHost settings) or isolation fails or you get locked out after save
7. 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
### Recovery when locked out
If IP / LAN access returns 404 after a bad save:
```bash
docker exec pichost clear-domains
```
Local: `npm run clear-domains`. Then reconfigure using the site hostname and verify proxy `Host` headers.
### Cloudflare orange cloud (incl. preferred edge IPs) ### Cloudflare orange cloud (incl. preferred edge IPs)
+18
View File
@@ -19,6 +19,24 @@ Local: `npm run reset-password`. See [Quick start](./getting-started.md#forgot-p
Check the **Referer whitelist** (`ALLOWED_REFERER_HOSTS` or Settings). Referring sites must be listed; PicHost’s own hosts and dual-domain pair are allowed automatically. Check the **Referer whitelist** (`ALLOWED_REFERER_HOSTS` or Settings). Referring sites must be listed; PicHost’s own hosts and dual-domain pair are allowed automatically.
## Admin 404 / locked out after dual-domain setup?
**Cause:** With dual-domain enabled, PicHost returns **404 for every request** whose Host is not the configured site or image hostname. Common triggers:
1. Saving dual-domain URLs while browsing via IP or an internal address
2. Reverse proxy “force hostname” or wrong `Host` header so PicHost sees a different host than configured
**Recovery (when locked out):**
```bash
docker exec pichost clear-domains
# Site URL only: docker exec pichost clear-domains --site
```
Local dev: `npm run clear-domains`. Then reopen admin via your previous IP/internal URL and reconfigure using the [dual-domain guide](./domain-separation.md#middleware-isolation-and-third-hosts).
**Prevention:** Read the risk confirmation when saving dual-domain settings; proxy must send `Host $host` and `X-Forwarded-Proto`. See [Dual-domain separation](./domain-separation.md#middleware-isolation-and-third-hosts).
## Why can admin load on pages.dev / workers.dev / IP with dual-domain? ## Why can admin load on pages.dev / workers.dev / IP with dual-domain?
**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). **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).
+24 -2
View File
@@ -39,11 +39,23 @@ See `docker-compose.yml` in the repository.
## Sign in ## Sign in
Management pages (upload, gallery, settings, etc.) require login. Enter username and password, **drag the slider to align with the gap**, then click **Sign in**. Management pages (upload, gallery, settings, etc.) require login. Enter username and password, complete the verification challenge, then click **Sign in**.
The default is a **local slider** (align the thumb with the gap). Admins can switch to **Cloudflare Turnstile** or **Cap** under **Settings → Access control**, with the matching site key / secret or API endpoint.
![Login](/screenshots/login.png) ![Login](/screenshots/login.png)
If registration is enabled, create an account from the register page. Legacy deployments may still use a secret key (see the on-screen hint). If registration is enabled, create an account from the register page (same verification). Legacy deployments may still use a secret key (see the on-screen hint).
### Locked out of verification
If Turnstile / Cap is misconfigured and you cannot sign in, reset to the local slider on the server:
```bash
docker exec pichost slider
```
Local dev: `npm run slider`
## Forgot password ## Forgot password
@@ -58,6 +70,16 @@ A random password is printed. Change it after login.
Local dev: `npm run reset-password` or `npm run reset-password -- <username>` Local dev: `npm run reset-password` or `npm run reset-password -- <username>`
## Dual-domain lockout
If dual-domain is enabled and IP / LAN access returns 404:
```bash
docker exec pichost clear-domains
```
Local: `npm run clear-domains`. See [FAQ — Admin 404 after dual-domain setup](./faq.md#admin-404--locked-out-after-dual-domain-setup).
## Upgrading to v1.2.0 ## Upgrading to v1.2.0
If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first. If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first.
+2
View File
@@ -28,6 +28,8 @@ Open `http://localhost:3000/setup` to create the admin account. Data directory d
| `npm run typecheck` | Type check | | `npm run typecheck` | Type check |
| `npm test` | Unit tests | | `npm test` | Unit tests |
| `npm run reset-password` | Reset password (optional username) | | `npm run reset-password` | Reset password (optional username) |
| `npm run slider` | Reset login verification to local slider |
| `npm run clear-domains` | Clear site/image URL settings from DB (recover from dual-domain lockout) |
| `npm run migrate` | Preview legacy dir migration (`-- --apply` to run) | | `npm run migrate` | Preview legacy dir migration (`-- --apply` to run) |
## Dev-only variables ## Dev-only variables
+3 -1
View File
@@ -57,12 +57,14 @@ See [Cloudflare deployment](./cloudflare-deployment.md).
## Origin hardening (dual-domain) ## Origin hardening (dual-domain)
PicHost middleware does **not** reject unlisted Host values. Complement with: Complement PicHost middleware with:
1. **`server_name` allowlist** — site + image hosts only; `default_server` blocks bare IP and unknown Host 1. **`server_name` allowlist** — site + image hosts only; `default_server` blocks bare IP and unknown Host
2. **Do not expose 6892 on the public Internet** — only local reverse proxy; public 80/443 only 2. **Do not expose 6892 on the public Internet** — only local reverse proxy; public 80/443 only
3. **Orange cloud** — origin firewall limited to Cloudflare IP ranges 3. **Orange cloud** — origin firewall limited to Cloudflare IP ranges
**From v1.2.2**, when dual-domain is active PicHost also returns **404 at the app layer** for Host values other than the configured site and image names (complementing, not replacing, a default server block). If misconfiguration locks you out, run `docker exec pichost clear-domains` — see [FAQ](./faq.md#admin-404--locked-out-after-dual-domain-setup).
## Caddy ## Caddy
```caddy ```caddy
+3 -1
View File
@@ -12,6 +12,7 @@ PicHost uses role-based access control (RBAC): **admin** and **user**. Enforceme
| Storage management (`/storage`) | ✓ | — | | Storage management (`/storage`) | ✓ | — |
| System settings (`/settings`) | ✓ | — | | System settings (`/settings`) | ✓ | — |
| Allow registration | ✓ | — | | Allow registration | ✓ | — |
| Login verification (slider / Turnstile / Cap) | ✓ | — |
| Global API token | ✓ | — | | Global API token | ✓ | — |
| Personal API token | ✓ | ✓ | | Personal API token | ✓ | ✓ |
| Gallery stats overview (incl. user count) | ✓ | partial | | Gallery stats overview (incl. user count) | ✓ | partial |
@@ -34,7 +35,8 @@ Regular users see **API**, **Gallery** in the nav; user menu: **Change password*
- By default only admins create users; **allow registration** is an admin setting - By default only admins create users; **allow registration** is an admin setting
- Passwords hashed with scrypt - Passwords hashed with scrypt
- Web login requires a slider captcha (align the thumb with the gap) - Web login/register requires verification: local slider by default; admins can switch to Turnstile or Cap under **Settings → Access control**
- If verification is misconfigured: `docker exec pichost slider` resets to the local slider
- Login rate limiting - Login rate limiting
## Upload preferences & auto-delete ## Upload preferences & auto-delete
+14
View File
@@ -4,6 +4,20 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
--- ---
## [1.2.8] — 2026-09-04
### 改进
- **双域名**:保存 / 初始化时弹出风险确认(含锁死说明与 `clear-domains` 恢复方式),不再强制校验当前 Host
### 新增
- **运维 CLI**:`clear-domains` 清除数据库域名配置(`docker exec pichost clear-domains`)
### 文档
- FAQ 与双域名、反代文档补充恢复步骤
## [1.2.7] — 2026-09-03 ## [1.2.7] — 2026-09-03
### 新增 ### 新增
+14
View File
@@ -11,6 +11,7 @@ PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配
| 简短图片链接 | SQLite → 环境变量 → 默认 false | 否 | | 简短图片链接 | SQLite → 环境变量 → 默认 false | 否 |
| 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 | | 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 |
| 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 | | 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 |
| 登录人机验证 | SQLite(设置 → 访问控制) | 否 |
## 变量说明 ## 变量说明
@@ -62,6 +63,18 @@ PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配
仅本地开发:`true` 绕过登录验证。生产环境勿用。 仅本地开发:`true` 绕过登录验证。生产环境勿用。
### 登录人机验证(仅后台设置)
在 **设置 → 访问控制** 配置,存入 SQLite,无对应环境变量:
| 方式 | 说明 |
| ---- | ---- |
| **本地滑块**(默认) | 无需第三方服务 |
| **Cloudflare Turnstile** | 填写 Site Key 与 Secret Key |
| **Cap** | 填写 API Endpoint 与 Secret |
登录与注册共用同一验证方式。若第三方配置错误导致无法登录,执行 `docker exec pichost slider`(或 `npm run slider`)恢复为本地滑块。
### 存储后端(可选) ### 存储后端(可选)
管理员通常在 **存储** 页添加后端;也可用环境变量引导(如 `STORAGE_BACKEND=s3` 与 `S3_*`)。详见 [存储](./storage.md)。 管理员通常在 **存储** 页添加后端;也可用环境变量引导(如 `STORAGE_BACKEND=s3` 与 `S3_*`)。详见 [存储](./storage.md)。
@@ -97,5 +110,6 @@ services:
| 按年/月分组 | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` | | 按年/月分组 | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP 质量 | `WEBP_QUALITY` | | WebP 质量 | `WEBP_QUALITY` |
| 自动删除 | `AUTO_DELETE_DAYS` | | 自动删除 | `AUTO_DELETE_DAYS` |
| 登录验证 | (仅后台 **设置 → 访问控制**) |
完整模板见仓库根目录 [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example)。 完整模板见仓库根目录 [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example)。
+15 -4
View File
@@ -72,10 +72,21 @@ PicHost 根据请求 **Host**(反代会转发为 `X-Forwarded-Host`,应用
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 直连源站 2. 使用 Cloudflare **橙云** 时,源站防火墙 **仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连源站
3. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用 3. **保存前**阅读设置 / 初始化页的风险确认;若通过 IP、内网或未正确传递 Host 的反代保存,可能锁死后台;恢复见下方「已锁死时的恢复」
4. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口;`IMAGE_BASE_URL` 可填 CDN 公网地址,但应用本体应 Docker/VPS 部署) 4. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用
5. 若必须在边缘做反代,须保留客户端 **`Host` 或 `X-Forwarded-Host`**,否则 Host 隔离会失效 5. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口;`IMAGE_BASE_URL` 可填 CDN 公网地址,但应用本体应 Docker/VPS 部署)
6. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可 6. 若必须在边缘做反代,须保留客户端 **`Host` 或 `X-Forwarded-Host`**(Lucky/NPM「强制域名」须与 PicHost 配置一致),否则 Host 隔离会失效或保存后被锁死
7. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可
### 已锁死时的恢复
若已误保存导致 IP / 内网地址全站 404:
```bash
docker exec pichost clear-domains
```
本地:`npm run clear-domains`。清除后请用网站域名重新配置,并确认反代 `Host` 头正确。
### Cloudflare 橙云(含 DNS 优选) ### Cloudflare 橙云(含 DNS 优选)
+18
View File
@@ -20,6 +20,24 @@ docker exec pichost reset-password 用户名
检查 **防盗链白名单**(`ALLOWED_REFERER_HOSTS` 或设置页)。引用图片的站点域名需在白名单中;PicHost 自身域名与双域名场景下的网站域、图片域会自动放行。 检查 **防盗链白名单**(`ALLOWED_REFERER_HOSTS` 或设置页)。引用图片的站点域名需在白名单中;PicHost 自身域名与双域名场景下的网站域、图片域会自动放行。
## 双域名配置后后台 404 / 进不去?
**原因:** 启用双域名后,PicHost 会对**未配置的网站域、图片域之外**的 Host 全站返回 404。常见触发方式:
1. 通过 IP 或内网地址打开设置,保存了公网网站域 / 图片域
2. 反代「强制域名」或 Host 头配置错误,PicHost 收到的 Host 与设置中的网站域不一致
**恢复(已锁死时):**
```bash
docker exec pichost clear-domains
# 仅清除网站域:docker exec pichost clear-domains --site
```
本地开发:`npm run clear-domains`。执行后用原来的 IP / 内网地址刷新即可重新进入后台,再按文档正确配置双域名。
**预防:** 保存双域名时注意确认框中的风险提示;反代须 `proxy_set_header Host $host;` 并保留 `X-Forwarded-Proto`。详见 [双域名分离](./domain-separation.md#中间件隔离与第三-host)。
## 双域名下为什么用 pages.dev / workers.dev / IP 也能进后台? ## 双域名下为什么用 pages.dev / workers.dev / IP 也能进后台?
**v1.2.2 起:** 双域名开启时,PicHost 中间件会对**未配置的网站域、图片域之外**的 Host 返回 **404**(开发环境 `localhost` / `127.0.0.1` 例外)。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。 **v1.2.2 起:** 双域名开启时,PicHost 中间件会对**未配置的网站域、图片域之外**的 Host 返回 **404**(开发环境 `localhost` / `127.0.0.1` 例外)。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
+24 -2
View File
@@ -39,11 +39,23 @@ docker compose up -d
## 登录 ## 登录
访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。 访问上传、图库、设置等管理页面前需登录。在登录卡片中填写用户名与密码,完成人机验证后点击 **登录**。
默认使用**本地滑块**(拖动滑块对齐缺口)。管理员可在 **设置 → 访问控制** 切换为 **Cloudflare Turnstile** 或 **Cap**,并填写对应 Site Key / Secret 或 API 端点。
![登录页](/screenshots/login.png) ![登录页](/screenshots/login.png)
若管理员开启了开放注册,可前往注册页创建账号。遗留部署仍可能使用密钥登录(见界面提示)。 若管理员开启了开放注册,可前往注册页创建账号(同样需完成人机验证)。遗留部署仍可能使用密钥登录(见界面提示)。
### 验证方式被锁死时
若 Turnstile / Cap 配置错误导致无法登录,可在服务器上重置为本地滑块:
```bash
docker exec pichost slider
```
本地开发:`npm run slider`
## 忘记密码 ## 忘记密码
@@ -61,6 +73,16 @@ docker exec pichost reset-password 用户名
本地开发:`npm run reset-password` 或 `npm run reset-password -- 用户名` 本地开发:`npm run reset-password` 或 `npm run reset-password -- 用户名`
## 双域名配置锁死
若启用双域名后通过 IP / 内网地址无法进入后台:
```bash
docker exec pichost clear-domains
```
本地:`npm run clear-domains`。详见 [常见问题 — 双域名配置后后台 404](./faq.md#双域名配置后后台-404-进不去)。
## 升级到 v1.2.0 ## 升级到 v1.2.0
若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。 若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。
+2
View File
@@ -28,6 +28,8 @@ npm run dev
| `npm run typecheck` | 类型检查 | | `npm run typecheck` | 类型检查 |
| `npm test` | 单元测试 | | `npm test` | 单元测试 |
| `npm run reset-password` | 重置密码(可选用户名) | | `npm run reset-password` | 重置密码(可选用户名) |
| `npm run slider` | 将登录验证重置为本地滑块 |
| `npm run clear-domains` | 清除数据库中的网站域 / 图片域配置(双域名锁死时恢复) |
| `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) | | `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) |
## 开发专用环境变量 ## 开发专用环境变量
+1 -1
View File
@@ -63,7 +63,7 @@ IMAGE_BASE_URL=https://pic.example.com
2. **禁止公网暴露 6892** — 仅本机或内网反代访问;公网只开 80/443 2. **禁止公网暴露 6892** — 仅本机或内网反代访问;公网只开 80/443
3. **橙云时限制源站来源 IP** — 仅 Cloudflare 回源 IP 段 3. **橙云时限制源站来源 IP** — 仅 Cloudflare 回源 IP 段
双域名场景下 PicHost **不会** 拒绝未配置的 Host;上述措施由运维层补齐。 **v1.2.2 起**,双域名开启时 PicHost 应用层也会对**未配置的网站域、图片域之外**的 Host 全站返回 404(与反代 default server 互补,而非替代)。误配置导致锁死时执行 `docker exec pichost clear-domains` 恢复,见 [常见问题](./faq.md#双域名配置后后台-404-进不去)。
## Caddy 示例 ## Caddy 示例
+3 -1
View File
@@ -12,6 +12,7 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
| 存储后端管理(`/storage`) | ✓ | — | | 存储后端管理(`/storage`) | ✓ | — |
| 系统设置(`/settings`) | ✓ | — | | 系统设置(`/settings`) | ✓ | — |
| 开放注册开关 | ✓ | — | | 开放注册开关 | ✓ | — |
| 登录人机验证方式(滑块 / Turnstile / Cap) | ✓ | — |
| 全局 API Token 管理 | ✓ | — | | 全局 API Token 管理 | ✓ | — |
| 个人 API Token | ✓ | ✓ | | 个人 API Token | ✓ | ✓ |
| 图库统计概览(含注册用户数) | ✓ | 部分 | | 图库统计概览(含注册用户数) | ✓ | 部分 |
@@ -34,7 +35,8 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
- 默认仅管理员可创建用户;管理员可在设置中 **开放注册** - 默认仅管理员可创建用户;管理员可在设置中 **开放注册**
- 密码使用 scrypt 哈希存储 - 密码使用 scrypt 哈希存储
- Web 登录需先完成滑动拼图验证(拖动滑块对齐缺口) - Web 登录/注册需完成人机验证:默认本地滑块;管理员可在 **设置 → 访问控制** 切换 Turnstile 或 Cap
- 验证配置错误无法登录时:`docker exec pichost slider` 重置为本地滑块
- 登录失败有速率限制 - 登录失败有速率限制
## 上传偏好与自动删除 ## 上传偏好与自动删除
+5
View File
@@ -84,6 +84,11 @@
"invalidPassword": "Password must be at least 8 characters" "invalidPassword": "Password must be at least 8 characters"
} }
}, },
"domainSeparationConfirm": {
"title": "Save dual-domain settings?",
"description": "After saving, only the site URL can serve the admin UI and API, and only the image URL can serve public image links. Access via IP, an unlisted Host, or a reverse proxy that does not forward Host correctly may lock you out of the admin UI.\n\nIf locked out, run docker exec pichost clear-domains (local dev: npm run clear-domains), or disable domain separation in Settings when you can access it again.\n\nSave anyway?",
"confirm": "Save anyway"
},
"setup": { "setup": {
"migrateTitle": "Migrate to username/password", "migrateTitle": "Migrate to username/password",
"initTitle": "First run — create admin account", "initTitle": "First run — create admin account",
+5
View File
@@ -84,6 +84,11 @@
"invalidPassword": "密码至少 8 位" "invalidPassword": "密码至少 8 位"
} }
}, },
"domainSeparationConfirm": {
"title": "确认保存双域名配置?",
"description": "保存后,仅网站域名可访问管理后台与 API,仅图片域名可提供图片直链。若通过 IP、未配置的 Host,或反代未正确传递 Host 访问,可能无法打开后台。\n\n若发生锁死,可执行 docker exec pichost clear-domains(本地开发:npm run clear-domains)清除域名配置,或在能访问时于设置中关闭「域名分离」。\n\n确定仍要保存吗?",
"confirm": "确认保存"
},
"setup": { "setup": {
"migrateTitle": "迁移到账号密码登录", "migrateTitle": "迁移到账号密码登录",
"initTitle": "首次使用,创建管理员账号", "initTitle": "首次使用,创建管理员账号",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.2.7", "version": "1.2.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pichost", "name": "pichost",
"version": "1.2.7", "version": "1.2.8",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.2.7", "version": "1.2.8",
"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.", "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", "homepage": "https://github.com/O96u/PicHost#readme",
"repository": { "repository": {
@@ -36,6 +36,7 @@
"reset-password": "node server/cli/reset-password.mjs", "reset-password": "node server/cli/reset-password.mjs",
"migrate": "node server/cli/migrate-to-single-images.mjs", "migrate": "node server/cli/migrate-to-single-images.mjs",
"slider": "node server/cli/slider.mjs", "slider": "node server/cli/slider.mjs",
"clear-domains": "node server/cli/clear-domains.mjs",
"docs:prepare": "node docs-site/copy-screenshots.mjs", "docs:prepare": "node docs-site/copy-screenshots.mjs",
"docs:dev": "npm run docs:prepare && vitepress dev docs-site", "docs:dev": "npm run docs:prepare && vitepress dev docs-site",
"docs:build": "npm run docs:prepare && vitepress build docs-site", "docs:build": "npm run docs:prepare && vitepress build docs-site",
+60
View File
@@ -0,0 +1,60 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
const SITE_KEY = 'site_base_url'
const IMAGE_KEY = 'image_base_url'
const args = process.argv.slice(2)
const siteOnly = args.includes('--site')
const imageOnly = args.includes('--image')
if (args.some(arg => arg.startsWith('--') && arg !== '--site' && arg !== '--image')) {
console.error('Usage: clear-domains [--site] [--image]')
console.error(' (no flags) Clear both site and image URLs from settings')
console.error(' --site Clear site URL only')
console.error(' --image Clear image URL only')
process.exit(1)
}
if (siteOnly && imageOnly) {
console.error('Usage: specify at most one of --site or --image')
process.exit(1)
}
const dataDir = process.env.DATA_DIR || join(process.cwd(), 'data')
mkdirSync(dataDir, { recursive: true })
const db = new DatabaseSync(join(dataDir, 'pichost.db'))
function readSetting(key) {
const row = db.prepare(`
SELECT value
FROM settings
WHERE key = ?
`).get(key)
return row?.value ?? null
}
const beforeSite = readSetting(SITE_KEY)
const beforeImage = readSetting(IMAGE_KEY)
const keysToClear = siteOnly
? [SITE_KEY]
: imageOnly
? [IMAGE_KEY]
: [SITE_KEY, IMAGE_KEY]
for (const key of keysToClear) {
db.prepare('DELETE FROM settings WHERE key = ?').run(key)
}
db.close()
console.log('[PicHost] Domain settings cleared from database.')
if (keysToClear.includes(SITE_KEY)) {
console.log(` site_base_url: ${beforeSite ?? '(unset)'} → (removed)`)
}
if (keysToClear.includes(IMAGE_KEY)) {
console.log(` image_base_url: ${beforeImage ?? '(unset)'} → (removed)`)
}
console.log('[PicHost] Restart is not required. Refresh the admin page or use your previous access URL.')