5 Commits
Author SHA1 Message Date
O96u 3dee0437e6 release: v1.1.5 — gallery preview reliability fixes
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
GitHub Release / release (push) Canceled after 0s
Bump version to 1.1.5. Retry gallery thumbnails after transient failures,
verify storage before serving, and stop caching image 404/403 responses.
2026-08-28 14:25:40 +08:00
O96u f25c89855f fix: gallery preview retries and avoid caching failed image responses
Verify storage files before serving from the index, disable CDN caching
on image 404/403 and host-isolation blocks, and retry gallery thumbnails
after transient load failures.
2026-08-28 14:25:04 +08:00
O96u b0a5fbfca5 release: v1.1.4 — storage path layout and bidirectional domain isolation
Bump version to 1.1.4. Add flat vs date storage paths, basename-only
hide-folder URLs, and block image paths on the site host when domain
separation is active.
2026-08-28 09:59:13 +08:00
O96u 438c1d5529 feat: storage path layout, basename URLs, and bidirectional domain isolation
Add storage_use_date_path for flat vs date-key storage layout and emit
basename-only public URLs when hide-folder is enabled. Block image paths
on the site host when domain separation is active so only the image host
serves files. Update settings UI, i18n, tests, and deployment docs.
2026-08-28 09:56:45 +08:00
O96u Build f7488753ca Update LICENSE 2026-08-27 15:32:31 +08:00
30 changed files with 704 additions and 248 deletions

No files matched your search

+5 -1
View File
@@ -14,9 +14,13 @@
# 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置)
# SITE_BASE_URL=https://admin.example.com
# 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → 2026/08/a.webp)
# 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → a.webp)
# HIDE_FOLDER_IN_URL=false
# 上传目录按年/月分组(true/date 默认;false/flat 则保存为 images/xxx.webp)
# STORAGE_USE_DATE_PATH=true
# STORAGE_LAYOUT=date
# 图片直链域名(可选;不填则使用当前请求域名,也可在设置页配置)
# IMAGE_BASE_URL=https://pic.example.com
+25
View File
@@ -2,6 +2,31 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [1.1.5] - 2026-08-28
### 修复
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
### 改进
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
## [1.1.4] - 2026-08-28
### 新增
- 上传目录可按年/月分组或扁平保存(`storage_use_date_path` / `STORAGE_USE_DATE_PATH`),设置页可切换
- 双域名分离下网站域禁止直链出图,仅图片域可访问图片路径
### 改进
- 隐藏 `images/` 前缀时外链改为仅文件名(如 `xxx.webp`);图片域仍兼容旧版 `/2026/08/xxx.webp` 短链
- 设置页重构:功能开关、域名配置、存储路径分组展示
- 本地磁盘索引扫描支持扁平路径;文件夹列表 `images` 置顶
## [1.1.3] - 2026-08-27
### 新增
-16
View File
@@ -1,19 +1,3 @@
PicHost - Self-hosted image hosting
Copyright (C) 2026 O96u
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
================================================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
+4 -2
View File
@@ -32,7 +32,7 @@
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.3** — local disk + multi-backend storage, dual-domain separation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.5** — local disk + multi-backend storage, bidirectional dual-domain isolation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
Use **main** for the full product and multiple cloud backends.
@@ -228,7 +228,9 @@ npm test # Unit tests
| Version | Plan |
| ---------- | -------------------------------------------------------------- |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check (current `main`) |
| **v1.1.5** | Gallery preview reliability: auto-retry, storage verify, no-cache errors (current `main`) |
| **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check |
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
+4 -2
View File
@@ -32,7 +32,7 @@
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.3** 主线:本地磁盘 + 多对象存储、双域名分离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.5** 主线:本地磁盘 + 多对象存储、双域名双向隔离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。
@@ -233,7 +233,9 @@ npm test # 单元测试
| 版本 | 说明 |
| ---------- | ------------------------------------------------- |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示(当前 main) |
| **v1.1.5** | 图库预览可靠性:自动重试、出图前校验、错误响应禁缓存(当前 main) |
| **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离 |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
+43 -2
View File
@@ -26,8 +26,48 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const imageError = ref(false)
const retryCount = ref(0)
const copyFormat = ref<CopyFormat>('url')
const MAX_PREVIEW_RETRIES = 3
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
const previewSrc = computed(() => {
if (retryCount.value === 0) return props.image.url
const separator = props.image.url.includes('?') ? '&' : '?'
return `${props.image.url}${separator}retry=${retryCount.value}`
})
watch(() => props.image.key, () => {
imageError.value = false
retryCount.value = 0
})
let retryTimer: ReturnType<typeof setTimeout> | undefined
function clearRetryTimer() {
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
retryTimer = undefined
}
}
function onPreviewError() {
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
imageError.value = true
return
}
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
clearRetryTimer()
retryTimer = setTimeout(() => {
retryCount.value += 1
retryTimer = undefined
}, delay)
}
onUnmounted(clearRetryTimer)
const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
@@ -78,11 +118,12 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
<img
v-if="!imageError"
:src="image.url"
:key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName"
loading="lazy"
class="absolute inset-0 size-full object-cover object-center"
@error="imageError = true"
@error="onPreviewError"
>
<div
v-else
+2 -2
View File
@@ -104,8 +104,8 @@ function storageIcon(storage: ActivityLogStorage | null) {
function publicPathFromKey(key: string) {
if (!hideFolderInUrl.value) return key
const slash = key.indexOf('/')
return slash > 0 ? key.slice(slash + 1) : key
const slash = key.lastIndexOf('/')
return slash >= 0 ? key.slice(slash + 1) : key
}
function imageUrl(key: string) {
+247 -171
View File
@@ -20,6 +20,8 @@ interface SettingsResponse {
domainSeparation: boolean
hideFolderInUrl: boolean
hideFolderInUrlSource: SettingSource
storageUseDatePath: boolean
storageUseDatePathSource: SettingSource
allowRegistration: boolean
appVersion: string
}
@@ -37,6 +39,7 @@ const refererDraft = ref('')
const siteBaseUrlDraft = ref('')
const imageBaseUrlDraft = ref('')
const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false)
const domainSeparationDraft = ref(false)
const hideFolderConfirmOpen = ref(false)
@@ -61,6 +64,7 @@ const hasServerChanges = computed(() => {
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '')
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|| allowRegistrationDraft.value !== settings.value.allowRegistration
})
@@ -101,6 +105,7 @@ function applySettings(data: SettingsResponse) {
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : ''
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
hideFolderInUrlDraft.value = data.hideFolderInUrl
storageUseDatePathDraft.value = data.storageUseDatePath
allowRegistrationDraft.value = data.allowRegistration
domainSeparationDraft.value = data.domainSeparation
}
@@ -170,6 +175,7 @@ async function saveServerSettings() {
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
imageBaseUrl: imageBaseUrlDraft.value,
hideFolderInUrl: hideFolderInUrlDraft.value,
storageUseDatePath: storageUseDatePathDraft.value,
allowRegistration: allowRegistrationDraft.value
})
toast.add({ title: t('settings.saved'), color: 'success' })
@@ -299,192 +305,54 @@ watch(isAuthenticated, async (authed, prev) => {
class="p-5 sm:p-6"
>
<div class="space-y-5">
<div class="grid gap-5 lg:grid-cols-2">
<div class="space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="grid min-w-0 gap-5 lg:grid-cols-2 lg:items-stretch">
<div class="flex h-full min-w-0 flex-col space-y-3 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.uploadAccount') }}
{{ t('settings.featureToggles') }}
</h3>
<div class="space-y-1.5">
<label class="text-sm font-medium">{{ t('settings.defaultFolder') }}</label>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.defaultFolderHint') }}
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="w-full"
/>
</div>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
v-model="allowRegistrationDraft"
class="mt-0.5"
class="mt-0.5 shrink-0"
/>
<span>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('settings.allowRegistration') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.allowRegistrationHint') }}
</span>
</span>
</label>
</div>
<div class="flex flex-col gap-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
{{ t('settings.accessControl') }}
</h3>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
:rows="5"
class="min-h-0 w-full flex-1 font-mono text-sm"
:ui="{ root: 'flex-1' }"
/>
</div>
</div>
<div class="space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.linkGeneration') }}
</h3>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="domainSeparationDraft"
class="mt-0.5"
@update:model-value="onDomainSeparationDraftChange"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="storageUseDatePathDraft"
class="mt-0.5 shrink-0"
:disabled="settings.storageUseDatePathSource === 'env'"
@update:model-value="(v) => storageUseDatePathDraft = v === true"
/>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
{{ t('settings.storageUseDatePath') }}
<UBadge
v-if="settings.storageUseDatePathSource === 'env'"
:color="sourceBadge(settings.storageUseDatePathSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.storageUseDatePathSource).label }}
</UBadge>
</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.storageUseDatePathHint') }}
</span>
</span>
</span>
</label>
</label>
<p
v-if="domainSeparationDraft"
class="text-xs leading-relaxed text-warning"
>
{{ t('setup.domainSeparationProxyHint') }}
<a
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<div
v-if="domainSeparationDraft"
class="grid gap-4 lg:grid-cols-2"
>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<label class="text-sm font-medium">{{ t('settings.siteBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.siteBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.siteBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlHint') }}
</p>
<UInput
v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.siteBaseUrl"
class="text-xs text-muted"
>
{{ t('settings.siteBaseUrlActive', { url: settings.siteBaseUrl }) }}
</p>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
<div
v-else
class="space-y-1.5"
>
<div class="flex items-center justify-between gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlSingleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
<div class="border-t border-default/60 pt-4">
<label class="flex cursor-pointer items-start gap-2.5">
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="hideFolderInUrlDraft"
class="mt-0.5"
class="mt-0.5 shrink-0"
:disabled="settings.hideFolderInUrlSource === 'env'"
@update:model-value="onHideFolderDraftChange"
/>
@@ -506,6 +374,172 @@ watch(isAuthenticated, async (authed, prev) => {
</span>
</label>
</div>
<div class="settings-side-panel flex h-full min-w-0 flex-col rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="shrink-0 space-y-1.5">
<label class="text-sm font-medium">{{ t('settings.defaultFolder') }}</label>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.defaultFolderHint') }}
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="settings-folder-select w-40 max-w-full"
/>
</div>
<div class="settings-referer-section mt-4 space-y-1.5 border-t border-default/60 pt-4">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.accessControl') }}</label>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
:rows="4"
:autoresize="false"
class="settings-referer-field flex min-h-0 w-full max-w-xl flex-1 flex-col font-mono text-sm"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.domainSettings') }}
</h3>
<div class="mt-4 space-y-4">
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="domainSeparationDraft"
class="mt-0.5 shrink-0"
@update:model-value="onDomainSeparationDraftChange"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
</span>
</span>
</label>
<p
v-if="domainSeparationDraft"
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2.5 text-xs leading-relaxed text-warning"
>
{{ t('setup.domainSeparationProxyHint') }}
<a
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<div
v-if="domainSeparationDraft"
class="grid gap-4 sm:grid-cols-2"
>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.siteBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.siteBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.siteBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlHint') }}
</p>
<UInput
v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
/>
<p
v-if="settings.siteBaseUrl"
class="truncate text-xs text-muted"
:title="settings.siteBaseUrl"
>
{{ t('settings.siteBaseUrlActive', { url: settings.siteBaseUrl }) }}
</p>
</div>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.imageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
<div
v-else
class="space-y-1.5"
>
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlSingleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.imageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
</div>
</div>
</div>
@@ -522,14 +556,14 @@ watch(isAuthenticated, async (authed, prev) => {
<div
v-if="settings"
class="flex flex-wrap items-center justify-between gap-3 border-t border-default px-5 py-4 sm:px-6"
class="flex flex-col gap-3 border-t border-default px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6"
>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<UIcon
name="i-lucide-key-round"
class="size-3.5 shrink-0"
/>
<span>
<span class="min-w-0">
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
<a
:href="releaseCheck?.updateAvailable && releaseCheck.releaseUrl
@@ -563,7 +597,7 @@ watch(isAuthenticated, async (authed, prev) => {
</template>
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center justify-end gap-2">
<UButton
:label="t('settings.manageToken')"
icon="i-lucide-external-link"
@@ -609,3 +643,45 @@ watch(isAuthenticated, async (authed, prev) => {
</UModal>
</div>
</template>
<style scoped>
.settings-side-panel {
overflow: hidden;
}
.settings-folder-select {
min-width: 0;
}
.settings-folder-select :deep(button),
.settings-folder-select :deep([role='combobox']) {
justify-content: space-between;
}
.settings-referer-section {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.settings-referer-field {
flex: 1;
min-height: 0;
min-width: 0;
}
.settings-referer-field :deep(textarea),
.settings-referer-field :deep(> div) {
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.settings-referer-field :deep(textarea) {
height: 100%;
min-height: 5.5rem;
resize: none;
}
</style>
+6 -2
View File
@@ -1,6 +1,9 @@
# 双域名分离 · 推荐部署架构
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 在应用层隔离:图片域仅放行图片路径,其余请求返回 404。
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 双向隔离:
- **网站域**:后台、API、上传;**禁止**直链出图(图片路径返回 404)
- **图片域**:仅放行图片路径;其余请求返回 404
## 架构说明
@@ -19,7 +22,7 @@
## Nginx 示例
```nginx
# admin.example.com — 后台 + API + 出图
# admin.example.com — 后台 + API(不出图,直链请走图片域)
server {
server_name admin.example.com;
location / {
@@ -111,6 +114,7 @@ npm run dev
| 地址 | 预期 |
| ---- | ---- |
| `http://admin.pichost.test:3000/` | 正常进入后台 |
| `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/` | 能打开,但与双域名配置无关,登录态不共享 |
+7 -2
View File
@@ -180,6 +180,7 @@
"title": "System settings",
"subtitle": "Saved to database; environment variables take precedence",
"uploadAccount": "Upload & accounts",
"featureToggles": "Feature toggles",
"defaultFolder": "Default upload folder",
"defaultFolderHint": "Stored in this browser only; affects your upload target",
"allowRegistration": "Open registration",
@@ -188,6 +189,8 @@
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "Domains & links",
"domainSettings": "Domain settings",
"storagePathSettings": "Storage paths & direct links",
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
@@ -199,10 +202,12 @@
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "Active: {url}",
"hideFolderInUrl": "Hide folder in image URLs",
"hideFolderInUrlHint": "Links become /2026/08/file.webp without the images/ prefix; legacy full paths still work",
"hideFolderInUrlHint": "Links become /file.webp without folder or date paths; legacy full paths still work",
"hideFolderConfirmTitle": "Hide folder prefix in URLs?",
"hideFolderConfirmDesc": "If two folders share the same year/month/filename (e.g. images/2026/08/a.webp and blog/2026/08/a.webp), the short URL /2026/08/a.webp resolves to the images folder first—you may get the wrong file. Enable only with a single upload folder or globally unique filenames.",
"hideFolderConfirmDesc": "If two folders share the same filename (e.g. images/a.webp and blog/a.webp), the short URL /a.webp resolves to the images folder first—you may get the wrong file. Enable only with a single upload folder or globally unique filenames.",
"hideFolderConfirmEnable": "Enable anyway",
"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",
"badgeSaved": "Saved",
"badgeDefault": "Default",
+7 -2
View File
@@ -180,6 +180,7 @@
"title": "系统设置",
"subtitle": "保存后写入数据库;环境变量优先级更高",
"uploadAccount": "上传与账号",
"featureToggles": "功能开关",
"defaultFolder": "默认上传目录",
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
"allowRegistration": "开放用户注册",
@@ -188,6 +189,8 @@
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "域名与链接",
"domainSettings": "域名配置",
"storagePathSettings": "存储路径与直链",
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
@@ -199,10 +202,12 @@
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}",
"hideFolderInUrl": "隐藏直链目录前缀",
"hideFolderInUrlHint": "开启后链接为 /2026/08/xxx.webp,不含 images/ 等目录名;旧链接仍可用",
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧链接仍可用",
"hideFolderConfirmTitle": "确认隐藏目录前缀?",
"hideFolderConfirmDesc": "开启后不同目录下若存在相同年月与文件名(如 images/2026/08/a.webp 与 blog/2026/08/a.webp),短链接 /2026/08/a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。",
"hideFolderConfirmDesc": "开启后不同目录下若存在相同文件名(如 images/a.webp 与 blog/a.webp),短链接 /a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。",
"hideFolderConfirmEnable": "仍要开启",
"storageUseDatePath": "上传目录按年/月分组",
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
"badgeEnv": ".env",
"badgeSaved": "已保存",
"badgeDefault": "默认",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pichost",
"version": "1.1.3",
"version": "1.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pichost",
"version": "1.1.3",
"version": "1.1.5",
"hasInstallScript": true,
"license": "GPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pichost",
"version": "1.1.3",
"version": "1.1.5",
"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": {
+6
View File
@@ -5,6 +5,7 @@ import {
setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
@@ -31,6 +32,7 @@ interface SettingsPatchBody {
siteBaseUrl?: unknown
imageBaseUrl?: unknown
hideFolderInUrl?: unknown
storageUseDatePath?: unknown
autoDeleteDays?: unknown
allowRegistration?: unknown
}
@@ -135,5 +137,9 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false')
}
if (body.storageUseDatePath !== undefined) {
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
}
return getSettingsPayload(event)
})
+5 -2
View File
@@ -1,7 +1,10 @@
import { shouldBlockImageHostRequest } from '../utils/host-isolation'
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event)) {
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
})
+1 -1
View File
@@ -1,7 +1,7 @@
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
/**
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。
* 仅当路径像图片 key 或短链时才接管。
* 其它请求(/_nuxt、/api、页面)原样放行。
*/
export default defineEventHandler(async (event) => {
+1
View File
@@ -374,6 +374,7 @@ export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
+48
View File
@@ -7,6 +7,7 @@ import {
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_AUTO_DELETE_ENABLED_AT,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
@@ -179,6 +180,49 @@ export function isHideFolderInUrl(_event?: H3Event): boolean {
return isHideFolderInUrlFromEnv()
}
export function isStorageUseDatePathFromEnv(): boolean {
try {
const layout = process.env.STORAGE_LAYOUT?.trim().toLowerCase()
if (layout === 'flat') return false
if (layout === 'date') return true
const raw = process.env.STORAGE_USE_DATE_PATH?.trim().toLowerCase()
if (raw === 'false') return false
if (raw === 'true') return true
return true
} catch {
return true
}
}
export function isStorageUseDatePathEnvConfigured(): boolean {
try {
const layout = process.env.STORAGE_LAYOUT?.trim().toLowerCase()
if (layout === 'flat' || layout === 'date') return true
const raw = process.env.STORAGE_USE_DATE_PATH?.trim().toLowerCase()
return raw === 'true' || raw === 'false'
} catch {
return false
}
}
export function getStorageUseDatePathSource(): SettingSource {
if (getSetting(SETTINGS_STORAGE_USE_DATE_PATH) !== null) return 'db'
if (isStorageUseDatePathEnvConfigured()) return 'env'
return 'none'
}
/** 优先级:SQLite settings → 环境变量 → 默认 true(按年/月分组) */
export function isStorageUseDatePath(_event?: H3Event): boolean {
const dbRaw = getSetting(SETTINGS_STORAGE_USE_DATE_PATH)
if (dbRaw !== null) return dbRaw !== 'false'
if (isStorageUseDatePathEnvConfigured()) return isStorageUseDatePathFromEnv()
return true
}
export function getStorageLayout(event?: H3Event): 'date' | 'flat' {
return isStorageUseDatePath(event) ? 'date' : 'flat'
}
export function getApiUploadTokenFromEnv(event: H3Event): string {
return (
getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken')
@@ -432,6 +476,8 @@ export function getSettingsPayload(event: H3Event) {
const domainSeparation = isDomainSeparationActive(event)
const hideFolderInUrl = isHideFolderInUrl(event)
const hideFolderInUrlSource = getHideFolderInUrlSource()
const storageUseDatePath = isStorageUseDatePath(event)
const storageUseDatePathSource = getStorageUseDatePathSource()
return {
apiUploadToken: getApiUploadToken(event),
@@ -451,6 +497,8 @@ export function getSettingsPayload(event: H3Event) {
domainSeparation,
hideFolderInUrl,
hideFolderInUrlSource,
storageUseDatePath,
storageUseDatePathSource,
autoDeleteDays,
autoDeleteDaysSource: getAutoDeleteDaysSource(),
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
+65 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
import { shouldBlockImageHostRequest } from './host-isolation'
import {
shouldBlockImageHostRequest,
shouldBlockSiteHostImageRequest
} from './host-isolation'
function mockEvent(): H3Event {
return {
@@ -10,7 +13,7 @@ function mockEvent(): H3Event {
}
describe('shouldBlockImageHostRequest', () => {
const imageHost = 'pic.example.com'
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
@@ -67,3 +70,63 @@ describe('shouldBlockImageHostRequest', () => {
})).toBe(false)
})
})
describe('shouldBlockSiteHostImageRequest', () => {
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: false,
requestHost: siteHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
it('blocks image paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(true)
})
it('blocks legacy date short paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/2026/08/demo.webp',
hideFolder: true
})).toBe(true)
})
it('allows admin paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/'
})).toBe(false)
})
it('does not block image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: imageHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
})
+41 -1
View File
@@ -24,6 +24,8 @@ export function shouldBlockImageHostRequest(
requestHost?: string
separationActive?: boolean
imageHost?: string | null
siteHost?: string | null
hideFolder?: boolean
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
@@ -45,7 +47,45 @@ export function shouldBlockImageHostRequest(
if (method !== 'GET' && method !== 'HEAD') return true
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder: isHideFolderInUrl(event)
hideFolder
}) === null
}
/** 网站域禁止直链出图(仅图片域可 GET/HEAD 图片路径) */
export function shouldBlockSiteHostImageRequest(
event: H3Event,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
siteHost?: string | null
hideFolder?: boolean
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
if (!separationActive) return false
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()
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 hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
}) !== null
}
+1
View File
@@ -5,6 +5,7 @@ describe('image index key validation', () => {
it('accepts standard image keys used in index', () => {
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('images/abc.webp')).toBe(true)
})
it('rejects path traversal keys', () => {
+35 -10
View File
@@ -2,8 +2,8 @@ import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { getDb } from './db'
import { getDataDir } from './data-dir'
import { DEFAULT_FOLDER, isValidFolderName, validateImageKey } from './image-key'
import { isHiddenImageDatePath } from './image-public-path'
import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
import {
ensureDefaultBackends,
ensureStorageSchema,
@@ -138,6 +138,29 @@ export function findImageKeyByDatePath(datePath: string): string | null {
return defaultFolder?.key ?? rows[0]!.key
}
/** 隐藏目录 URL(纯文件名)反查完整 key;多目录同名时优先 images */
export function findImageKeyByFilename(filename: string): string | null {
if (!isBareImageFilename(filename)) return null
ensureStorageSchema()
const suffix = `/${filename}`
const rows = getDb().prepare(`
SELECT key FROM images WHERE key LIKE ?
`).all(`%${suffix}`) as Array<{ key: string }>
if (rows.length === 0) {
const flatFallback = `${DEFAULT_FOLDER}/${filename}`
return validateImageKey(flatFallback) ? flatFallback : null
}
if (rows.length === 1) return rows[0]!.key
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`)
if (exactDefault) return exactDefault.key
const defaultFolder = rows.find(row => row.key.startsWith(`${DEFAULT_FOLDER}/`))
return defaultFolder?.key ?? rows[0]!.key
}
async function readDirNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
@@ -164,8 +187,16 @@ async function listLocalImageKeys(): Promise<string[]> {
for (const prefix of prefixes) {
if (!isValidFolderName(prefix)) continue
const root = join(dataDir, prefix)
const rootFiles = await readFileNames(root)
for (const file of rootFiles) {
if (file.endsWith(META_SUFFIX)) continue
const key = `${prefix}/${file}`
if (validateImageKey(key)) keys.push(key)
}
const years = await readDirNames(root)
for (const year of years) {
if (!/^\d{4}$/.test(year)) continue
const months = await readDirNames(join(root, year))
for (const month of months) {
const files = await readFileNames(join(root, year, month))
@@ -222,10 +253,7 @@ export async function listFolders(): Promise<string[]> {
`).all() as Array<{ folder: string }>
const folders = rows.map(row => row.folder).filter(isValidFolderName)
if (!folders.includes(DEFAULT_FOLDER)) {
folders.push(DEFAULT_FOLDER)
}
return folders.sort((a, b) => a.localeCompare(b))
return sortFolderNames(folders)
}
export async function listFoldersForUser(
@@ -240,10 +268,7 @@ export async function listFoldersForUser(
`).all(...params) as Array<{ folder: string }>
const folders = rows.map(row => row.folder).filter(isValidFolderName)
if (!folders.length) {
folders.push(DEFAULT_FOLDER)
}
return folders.sort((a, b) => a.localeCompare(b))
return sortFolderNames(folders)
}
function buildBackendFilterSql(
+29 -2
View File
@@ -1,12 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
import { generateImageKey, sortFolderNames, validateImageKey } from './image-key'
describe('validateImageKey', () => {
it('accepts valid keys', () => {
it('accepts valid date-layout keys', () => {
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/2025/01/photo.jpg')).toBe(true)
})
it('accepts valid flat-layout keys', () => {
expect(validateImageKey('images/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/photo.jpg')).toBe(true)
})
it('sorts images first then alphabetically', () => {
expect(sortFolderNames(['blog', 'images', 'twikoo'])).toEqual(['images', 'blog', 'twikoo'])
expect(sortFolderNames(['blog'])).toEqual(['images', 'blog'])
})
it('rejects path traversal and malformed keys', () => {
expect(validateImageKey('')).toBe(false)
expect(validateImageKey('/images/2026/08/a.webp')).toBe(false)
@@ -15,5 +25,22 @@ describe('validateImageKey', () => {
expect(validateImageKey('api/2026/08/a.webp')).toBe(false)
expect(validateImageKey('images/26/8/a.webp')).toBe(false)
expect(validateImageKey('images/2026/08/a.txt')).toBe(false)
expect(validateImageKey('images/2026/a.webp')).toBe(false)
})
})
describe('generateImageKey', () => {
const date = new Date('2026-08-15T12:00:00Z')
it('generates date-layout keys by default', () => {
const key = generateImageKey('image/webp', date, 'images')
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true)
})
it('generates flat-layout keys when requested', () => {
const key = generateImageKey('image/webp', date, 'images', 'flat')
expect(key).toMatch(/^images\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true)
})
})
+49 -8
View File
@@ -11,6 +11,8 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
export const DEFAULT_FOLDER = 'images'
export type StorageLayout = 'date' | 'flat'
/** 避免与站点路由 / 静态资源冲突 */
const RESERVED_FOLDERS = new Set([
'api',
@@ -25,6 +27,8 @@ const RESERVED_FOLDERS = new Set([
'sw'
])
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
function generateRandomId(length = 12): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const bytes = new Uint8Array(length)
@@ -49,15 +53,37 @@ export function normalizeFolderName(raw: string | undefined | null): string {
return trimmed
}
/** 默认目录 images 始终排在首位,其余按字母序 */
export function sortFolderNames(folders: string[]): string[] {
const seen = new Set<string>()
const valid: string[] = []
for (const folder of folders) {
if (!isValidFolderName(folder) || seen.has(folder)) continue
seen.add(folder)
valid.push(folder)
}
if (!seen.has(DEFAULT_FOLDER)) {
valid.push(DEFAULT_FOLDER)
}
const rest = valid
.filter(folder => folder !== DEFAULT_FOLDER)
.sort((a, b) => a.localeCompare(b))
return [DEFAULT_FOLDER, ...rest]
}
export function generateImageKey(
contentType: AllowedMimeType,
date = new Date(),
prefix = DEFAULT_FOLDER
prefix = DEFAULT_FOLDER,
layout: StorageLayout = 'date'
): string {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const id = generateRandomId(12)
const ext = mimeToExtension(contentType)
if (layout === 'flat') {
return `${prefix}/${id}.${ext}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${prefix}/${year}/${month}/${id}.${ext}`
}
@@ -67,9 +93,24 @@ export function validateImageKey(key: string): boolean {
if (key.includes('..')) return false
if (key.includes('//')) return false
const match = key.match(
/^([A-Za-z0-9][A-Za-z0-9_-]*)\/\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
)
if (!match) return false
return isValidFolderName(match[1]!)
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
const folder = parts[0]!
if (!isValidFolderName(folder)) return false
const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return false
if (parts.length === 2) {
return true
}
if (parts.length === 4) {
const year = parts[1]!
const month = parts[2]!
return /^\d{4}$/.test(year) && /^\d{2}$/.test(month)
}
return false
}
+21 -4
View File
@@ -1,14 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isHiddenImageDatePath, toPublicImagePath } from './image-public-path'
import {
isBareImageFilename,
isHiddenImageDatePath,
toPublicImagePath
} 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 the first path segment when hideFolder is true', () => {
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('2026/08/a.webp')
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('2026/08/a.webp')
it('returns basename when hideFolder is true', () => {
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('images/a.webp', true)).toBe('a.webp')
})
})
@@ -21,3 +27,14 @@ describe('isHiddenImageDatePath', () => {
expect(isHiddenImageDatePath('images/2026/08/demo.webp')).toBe(false)
})
})
describe('isBareImageFilename', () => {
it('accepts bare filenames', () => {
expect(isBareImageFilename('demo.webp')).toBe(true)
})
it('rejects paths with directories', () => {
expect(isBareImageFilename('2026/08/demo.webp')).toBe(false)
expect(isBareImageFilename('images/demo.webp')).toBe(false)
})
})
+11 -3
View File
@@ -1,13 +1,21 @@
/** 对外 URL 路径:可选去掉 key 的首段目录(如 images/) */
import { basename } from 'node:path'
/** 对外 URL 路径:可选隐藏目录前缀(输出纯文件名) */
export function toPublicImagePath(key: string, hideFolder: boolean): string {
if (!hideFolder) return key
const slash = key.indexOf('/')
return slash > 0 ? key.slice(slash + 1) : key
return basename(key)
}
const HIDDEN_DATE_PATH_PATTERN
= /^\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
const BARE_FILENAME_PATTERN
= /^[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
export function isHiddenImageDatePath(path: string): boolean {
return HIDDEN_DATE_PATH_PATTERN.test(path)
}
export function isBareImageFilename(path: string): boolean {
return BARE_FILENAME_PATTERN.test(path)
}
+4 -2
View File
@@ -14,7 +14,7 @@ import {
import type { LogSource } from './db'
import { generateImageKey } from './image-key'
import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getWebpQuality } from './env'
import { getStorageLayout, getWebpQuality } from './env'
import { insertActivityLog } from './db'
import { putImage } from './storage'
import { logInfo, logException } from './logger'
@@ -122,10 +122,12 @@ export async function processSingleImageUpload(
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
const uploadedAt = new Date().toISOString()
const layout = getStorageLayout(event)
const key = generateImageKey(
compressed.mime,
new Date(uploadedAt),
input.prefix ?? 'images'
input.prefix ?? 'images',
layout
)
let backendId: string
+15 -1
View File
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest'
import { requestPathToImageKey } from './serve-image'
describe('requestPathToImageKey hide folder', () => {
it('resolves bare filename to default folder flat key', () => {
expect(requestPathToImageKey('/demo.webp', { hideFolder: true }))
.toBe('images/demo.webp')
})
it('resolves hidden date path to default folder key', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: true }))
.toBe('images/2026/08/demo.webp')
@@ -10,10 +15,19 @@ describe('requestPathToImageKey hide folder', () => {
it('still accepts full key when hide folder is enabled', () => {
expect(requestPathToImageKey('/images/2026/08/demo.webp', { hideFolder: true }))
.toBe('images/2026/08/demo.webp')
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: true }))
.toBe('images/demo.webp')
})
it('does not resolve hidden path when hide folder is disabled', () => {
it('does not resolve short paths when hide folder is disabled', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false }))
.toBeNull()
expect(requestPathToImageKey('/demo.webp', { hideFolder: false }))
.toBeNull()
})
it('accepts flat full key when hide folder is disabled', () => {
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: false }))
.toBe('images/demo.webp')
})
})
+7 -4
View File
@@ -6,7 +6,7 @@ import {
getSiteBaseUrlConfigured,
hostnameFromBaseUrl
} from './env'
import { findImageKeyByDatePath } from './image-index'
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
import { validateImageKey } from './image-key'
import { buildPublicImageUrl } from './image-response'
import { createImageStream, headImage } from './storage'
@@ -20,7 +20,7 @@ import { getBackendRowForKey } from './storage/resolver'
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
*/
const ERROR_CACHE_CONTROL = 'public, max-age=60'
const ERROR_CACHE_CONTROL = 'private, no-cache, max-age=0'
function getAllowedHosts(event: H3Event): Set<string> {
const hosts = new Set<string>()
@@ -83,8 +83,11 @@ export function requestPathToImageKey(
if (validateImageKey(raw)) return raw
if (options?.hideFolder) {
const resolved = findImageKeyByDatePath(raw)
if (resolved && validateImageKey(resolved)) return resolved
const byFilename = findImageKeyByFilename(raw)
if (byFilename && validateImageKey(byFilename)) return byFilename
const byDatePath = findImageKeyByDatePath(raw)
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
}
return null
+12 -3
View File
@@ -53,13 +53,22 @@ export async function putImage(
}
export async function headImage(key: string): Promise<StoredImage | null> {
const indexed = getImageIndexAsStored(key)
if (indexed) return indexed
const backend = await getBackendForKey(key)
const stored = await backend.head(key)
if (!stored) return null
const indexed = getImageIndexAsStored(key)
if (indexed) {
return {
...stored,
backendId: backend.id,
originalName: indexed.originalName,
contentType: indexed.contentType,
uploadedAt: indexed.uploadedAt,
userId: indexed.userId
}
}
insertImageIndex({
key: stored.key,
backendId: backend.id,