Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dee0437e6 | ||
|
|
f25c89855f | ||
|
|
b0a5fbfca5 | ||
|
|
438c1d5529 | ||
|
|
f7488753ca |
No files matched your search
+5
-1
@@ -14,9 +14,13 @@
|
|||||||
# 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置)
|
# 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置)
|
||||||
# SITE_BASE_URL=https://admin.example.com
|
# 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
|
# 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
|
# IMAGE_BASE_URL=https://pic.example.com
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
|
本仓库自 **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
|
## [1.1.3] - 2026-08-27
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -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
|
GNU GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 29 June 2007
|
||||||
|
|||||||
+4
-2
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
| Branch | Notes |
|
| 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 |
|
| [**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.
|
Use **main** for the full product and multiple cloud backends.
|
||||||
@@ -228,7 +228,9 @@ npm test # Unit tests
|
|||||||
|
|
||||||
| Version | Plan |
|
| 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.2** | Activity logs, gallery storage filter, upload rate limits |
|
||||||
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
|
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
|
||||||
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
|
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
|
||||||
|
|||||||
@@ -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 |
|
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
|
||||||
|
|
||||||
日常使用、需要管理多种存储后端,请直接用 **main**。
|
日常使用、需要管理多种存储后端,请直接用 **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.2** | 操作日志、图库存储筛选、上传限流 |
|
||||||
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
|
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
|
||||||
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
|
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
|
||||||
|
|||||||
@@ -26,8 +26,48 @@ const emit = defineEmits<{
|
|||||||
const { formatFileSize } = useFileSize()
|
const { formatFileSize } = useFileSize()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const imageError = ref(false)
|
const imageError = ref(false)
|
||||||
|
const retryCount = ref(0)
|
||||||
const copyFormat = ref<CopyFormat>('url')
|
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(() => [
|
const copyFormatItems = computed(() => [
|
||||||
{ label: t('copy.url'), value: 'url' as const },
|
{ label: t('copy.url'), value: 'url' as const },
|
||||||
{ label: t('copy.markdown'), value: 'markdown' 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">
|
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
||||||
<img
|
<img
|
||||||
v-if="!imageError"
|
v-if="!imageError"
|
||||||
:src="image.url"
|
:key="`${image.key}-${retryCount}`"
|
||||||
|
:src="previewSrc"
|
||||||
:alt="image.originalName"
|
:alt="image.originalName"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="absolute inset-0 size-full object-cover object-center"
|
class="absolute inset-0 size-full object-cover object-center"
|
||||||
@error="imageError = true"
|
@error="onPreviewError"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
+2
-2
@@ -104,8 +104,8 @@ function storageIcon(storage: ActivityLogStorage | null) {
|
|||||||
|
|
||||||
function publicPathFromKey(key: string) {
|
function publicPathFromKey(key: string) {
|
||||||
if (!hideFolderInUrl.value) return key
|
if (!hideFolderInUrl.value) return key
|
||||||
const slash = key.indexOf('/')
|
const slash = key.lastIndexOf('/')
|
||||||
return slash > 0 ? key.slice(slash + 1) : key
|
return slash >= 0 ? key.slice(slash + 1) : key
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrl(key: string) {
|
function imageUrl(key: string) {
|
||||||
|
|||||||
+247
-171
@@ -20,6 +20,8 @@ interface SettingsResponse {
|
|||||||
domainSeparation: boolean
|
domainSeparation: boolean
|
||||||
hideFolderInUrl: boolean
|
hideFolderInUrl: boolean
|
||||||
hideFolderInUrlSource: SettingSource
|
hideFolderInUrlSource: SettingSource
|
||||||
|
storageUseDatePath: boolean
|
||||||
|
storageUseDatePathSource: SettingSource
|
||||||
allowRegistration: boolean
|
allowRegistration: boolean
|
||||||
appVersion: string
|
appVersion: string
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,7 @@ const refererDraft = ref('')
|
|||||||
const siteBaseUrlDraft = ref('')
|
const siteBaseUrlDraft = ref('')
|
||||||
const imageBaseUrlDraft = ref('')
|
const imageBaseUrlDraft = ref('')
|
||||||
const hideFolderInUrlDraft = ref(false)
|
const hideFolderInUrlDraft = ref(false)
|
||||||
|
const storageUseDatePathDraft = ref(true)
|
||||||
const allowRegistrationDraft = ref(false)
|
const allowRegistrationDraft = ref(false)
|
||||||
const domainSeparationDraft = ref(false)
|
const domainSeparationDraft = ref(false)
|
||||||
const hideFolderConfirmOpen = ref(false)
|
const hideFolderConfirmOpen = ref(false)
|
||||||
@@ -61,6 +64,7 @@ const hasServerChanges = computed(() => {
|
|||||||
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '')
|
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '')
|
||||||
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|
||||||
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
||||||
|
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
||||||
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,6 +105,7 @@ function applySettings(data: SettingsResponse) {
|
|||||||
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : ''
|
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : ''
|
||||||
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
|
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
|
||||||
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
||||||
|
storageUseDatePathDraft.value = data.storageUseDatePath
|
||||||
allowRegistrationDraft.value = data.allowRegistration
|
allowRegistrationDraft.value = data.allowRegistration
|
||||||
domainSeparationDraft.value = data.domainSeparation
|
domainSeparationDraft.value = data.domainSeparation
|
||||||
}
|
}
|
||||||
@@ -170,6 +175,7 @@ async function saveServerSettings() {
|
|||||||
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
|
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
|
||||||
imageBaseUrl: imageBaseUrlDraft.value,
|
imageBaseUrl: imageBaseUrlDraft.value,
|
||||||
hideFolderInUrl: hideFolderInUrlDraft.value,
|
hideFolderInUrl: hideFolderInUrlDraft.value,
|
||||||
|
storageUseDatePath: storageUseDatePathDraft.value,
|
||||||
allowRegistration: allowRegistrationDraft.value
|
allowRegistration: allowRegistrationDraft.value
|
||||||
})
|
})
|
||||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||||
@@ -299,192 +305,54 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
class="p-5 sm:p-6"
|
class="p-5 sm:p-6"
|
||||||
>
|
>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div class="grid gap-5 lg:grid-cols-2">
|
<div class="grid min-w-0 gap-5 lg:grid-cols-2 lg:items-stretch">
|
||||||
<div class="space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
|
<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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('settings.uploadAccount') }}
|
{{ t('settings.featureToggles') }}
|
||||||
</h3>
|
</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">
|
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
|
||||||
<UCheckbox
|
<UCheckbox
|
||||||
v-model="allowRegistrationDraft"
|
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="block text-sm font-medium">{{ t('settings.allowRegistration') }}</span>
|
||||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||||
{{ t('settings.allowRegistrationHint') }}
|
{{ t('settings.allowRegistrationHint') }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
|
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<UCheckbox
|
||||||
<h3 class="text-sm font-semibold">
|
:model-value="storageUseDatePathDraft"
|
||||||
{{ t('settings.accessControl') }}
|
class="mt-0.5 shrink-0"
|
||||||
</h3>
|
:disabled="settings.storageUseDatePathSource === 'env'"
|
||||||
<UBadge
|
@update:model-value="(v) => storageUseDatePathDraft = v === true"
|
||||||
:color="sourceBadge(settings.refererSource).color"
|
/>
|
||||||
variant="subtle"
|
<span class="min-w-0">
|
||||||
size="xs"
|
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||||
>
|
{{ t('settings.storageUseDatePath') }}
|
||||||
{{ sourceBadge(settings.refererSource).label }}
|
<UBadge
|
||||||
</UBadge>
|
v-if="settings.storageUseDatePathSource === 'env'"
|
||||||
</div>
|
:color="sourceBadge(settings.storageUseDatePathSource).color"
|
||||||
<p class="text-xs leading-relaxed text-muted">
|
variant="subtle"
|
||||||
{{ t('settings.refererHint') }}
|
size="xs"
|
||||||
</p>
|
>
|
||||||
<UTextarea
|
{{ sourceBadge(settings.storageUseDatePathSource).label }}
|
||||||
v-model="refererDraft"
|
</UBadge>
|
||||||
:placeholder="t('settings.refererPlaceholder')"
|
</span>
|
||||||
:rows="5"
|
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||||
class="min-h-0 w-full flex-1 font-mono text-sm"
|
{{ t('settings.storageUseDatePathHint') }}
|
||||||
:ui="{ root: 'flex-1' }"
|
</span>
|
||||||
/>
|
|
||||||
</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') }}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<p
|
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
|
||||||
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">
|
|
||||||
<UCheckbox
|
<UCheckbox
|
||||||
:model-value="hideFolderInUrlDraft"
|
:model-value="hideFolderInUrlDraft"
|
||||||
class="mt-0.5"
|
class="mt-0.5 shrink-0"
|
||||||
:disabled="settings.hideFolderInUrlSource === 'env'"
|
:disabled="settings.hideFolderInUrlSource === 'env'"
|
||||||
@update:model-value="onHideFolderDraftChange"
|
@update:model-value="onHideFolderDraftChange"
|
||||||
/>
|
/>
|
||||||
@@ -506,6 +374,172 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,14 +556,14 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="settings"
|
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">
|
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
|
||||||
<UIcon
|
<UIcon
|
||||||
name="i-lucide-key-round"
|
name="i-lucide-key-round"
|
||||||
class="size-3.5 shrink-0"
|
class="size-3.5 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span class="min-w-0">
|
||||||
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
|
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
|
||||||
<a
|
<a
|
||||||
:href="releaseCheck?.updateAvailable && releaseCheck.releaseUrl
|
:href="releaseCheck?.updateAvailable && releaseCheck.releaseUrl
|
||||||
@@ -563,7 +597,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
<UButton
|
<UButton
|
||||||
:label="t('settings.manageToken')"
|
:label="t('settings.manageToken')"
|
||||||
icon="i-lucide-external-link"
|
icon="i-lucide-external-link"
|
||||||
@@ -609,3 +643,45 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
</UModal>
|
</UModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# 双域名分离 · 推荐部署架构
|
# 双域名分离 · 推荐部署架构
|
||||||
|
|
||||||
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 在应用层隔离:图片域仅放行图片路径,其余请求返回 404。
|
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 双向隔离:
|
||||||
|
|
||||||
|
- **网站域**:后台、API、上传;**禁止**直链出图(图片路径返回 404)
|
||||||
|
- **图片域**:仅放行图片路径;其余请求返回 404
|
||||||
|
|
||||||
## 架构说明
|
## 架构说明
|
||||||
|
|
||||||
@@ -19,7 +22,7 @@
|
|||||||
## Nginx 示例
|
## Nginx 示例
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# admin.example.com — 后台 + API + 出图
|
# admin.example.com — 后台 + API(不出图,直链请走图片域)
|
||||||
server {
|
server {
|
||||||
server_name admin.example.com;
|
server_name admin.example.com;
|
||||||
location / {
|
location / {
|
||||||
@@ -111,6 +114,7 @@ npm run dev
|
|||||||
| 地址 | 预期 |
|
| 地址 | 预期 |
|
||||||
| ---- | ---- |
|
| ---- | ---- |
|
||||||
| `http://admin.pichost.test:3000/` | 正常进入后台 |
|
| `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/` | **404**(正常,图片域不提供后台) |
|
||||||
| `http://pic.pichost.test:3000/images/...` | 能出图 |
|
| `http://pic.pichost.test:3000/images/...` | 能出图 |
|
||||||
| `http://localhost:3000/` | 能打开,但与双域名配置无关,登录态不共享 |
|
| `http://localhost:3000/` | 能打开,但与双域名配置无关,登录态不共享 |
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"title": "System settings",
|
"title": "System settings",
|
||||||
"subtitle": "Saved to database; environment variables take precedence",
|
"subtitle": "Saved to database; environment variables take precedence",
|
||||||
"uploadAccount": "Upload & accounts",
|
"uploadAccount": "Upload & accounts",
|
||||||
|
"featureToggles": "Feature toggles",
|
||||||
"defaultFolder": "Default upload folder",
|
"defaultFolder": "Default upload folder",
|
||||||
"defaultFolderHint": "Stored in this browser only; affects your upload target",
|
"defaultFolderHint": "Stored in this browser only; affects your upload target",
|
||||||
"allowRegistration": "Open registration",
|
"allowRegistration": "Open registration",
|
||||||
@@ -188,6 +189,8 @@
|
|||||||
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
|
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
|
||||||
"refererPlaceholder": "example.com, www.example.com",
|
"refererPlaceholder": "example.com, www.example.com",
|
||||||
"linkGeneration": "Domains & links",
|
"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",
|
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
|
||||||
"siteBaseUrl": "Site URL",
|
"siteBaseUrl": "Site URL",
|
||||||
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
|
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
|
||||||
@@ -199,10 +202,12 @@
|
|||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"imageBaseUrlActive": "Active: {url}",
|
"imageBaseUrlActive": "Active: {url}",
|
||||||
"hideFolderInUrl": "Hide folder in image URLs",
|
"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?",
|
"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",
|
"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",
|
"badgeEnv": ".env",
|
||||||
"badgeSaved": "Saved",
|
"badgeSaved": "Saved",
|
||||||
"badgeDefault": "Default",
|
"badgeDefault": "Default",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
"subtitle": "保存后写入数据库;环境变量优先级更高",
|
"subtitle": "保存后写入数据库;环境变量优先级更高",
|
||||||
"uploadAccount": "上传与账号",
|
"uploadAccount": "上传与账号",
|
||||||
|
"featureToggles": "功能开关",
|
||||||
"defaultFolder": "默认上传目录",
|
"defaultFolder": "默认上传目录",
|
||||||
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
|
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
|
||||||
"allowRegistration": "开放用户注册",
|
"allowRegistration": "开放用户注册",
|
||||||
@@ -188,6 +189,8 @@
|
|||||||
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
|
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
|
||||||
"refererPlaceholder": "example.com, www.example.com",
|
"refererPlaceholder": "example.com, www.example.com",
|
||||||
"linkGeneration": "域名与链接",
|
"linkGeneration": "域名与链接",
|
||||||
|
"domainSettings": "域名配置",
|
||||||
|
"storagePathSettings": "存储路径与直链",
|
||||||
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
|
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
|
||||||
"siteBaseUrl": "网站域名",
|
"siteBaseUrl": "网站域名",
|
||||||
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
|
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
|
||||||
@@ -199,10 +202,12 @@
|
|||||||
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
"imageBaseUrlPlaceholder": "https://pic.example.com",
|
||||||
"imageBaseUrlActive": "当前生效:{url}",
|
"imageBaseUrlActive": "当前生效:{url}",
|
||||||
"hideFolderInUrl": "隐藏直链目录前缀",
|
"hideFolderInUrl": "隐藏直链目录前缀",
|
||||||
"hideFolderInUrlHint": "开启后链接为 /2026/08/xxx.webp,不含 images/ 等目录名;旧链接仍可用",
|
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧链接仍可用",
|
||||||
"hideFolderConfirmTitle": "确认隐藏目录前缀?",
|
"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": "仍要开启",
|
"hideFolderConfirmEnable": "仍要开启",
|
||||||
|
"storageUseDatePath": "上传目录按年/月分组",
|
||||||
|
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
|
||||||
"badgeEnv": ".env",
|
"badgeEnv": ".env",
|
||||||
"badgeSaved": "已保存",
|
"badgeSaved": "已保存",
|
||||||
"badgeDefault": "默认",
|
"badgeDefault": "默认",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"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.",
|
"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": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
setSetting,
|
setSetting,
|
||||||
SETTINGS_ALLOWED_REFERER_HOSTS,
|
SETTINGS_ALLOWED_REFERER_HOSTS,
|
||||||
SETTINGS_HIDE_FOLDER_IN_URL,
|
SETTINGS_HIDE_FOLDER_IN_URL,
|
||||||
|
SETTINGS_STORAGE_USE_DATE_PATH,
|
||||||
SETTINGS_IMAGE_BASE_URL,
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
SETTINGS_SITE_BASE_URL,
|
SETTINGS_SITE_BASE_URL,
|
||||||
SETTINGS_WEBP_QUALITY,
|
SETTINGS_WEBP_QUALITY,
|
||||||
@@ -31,6 +32,7 @@ interface SettingsPatchBody {
|
|||||||
siteBaseUrl?: unknown
|
siteBaseUrl?: unknown
|
||||||
imageBaseUrl?: unknown
|
imageBaseUrl?: unknown
|
||||||
hideFolderInUrl?: unknown
|
hideFolderInUrl?: unknown
|
||||||
|
storageUseDatePath?: unknown
|
||||||
autoDeleteDays?: unknown
|
autoDeleteDays?: unknown
|
||||||
allowRegistration?: unknown
|
allowRegistration?: unknown
|
||||||
}
|
}
|
||||||
@@ -135,5 +137,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false')
|
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)
|
return getSettingsPayload(event)
|
||||||
})
|
})
|
||||||
@@ -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) => {
|
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' })
|
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
|
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。
|
* 仅当路径像图片 key 或短链时才接管。
|
||||||
* 其它请求(/_nuxt、/api、页面)原样放行。
|
* 其它请求(/_nuxt、/api、页面)原样放行。
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
|
|||||||
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
|
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
|
||||||
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
|
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
|
||||||
export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
|
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_SITE_BASE_URL = 'site_base_url'
|
||||||
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
|
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
|
||||||
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
|
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
SETTINGS_AUTO_DELETE_DAYS,
|
SETTINGS_AUTO_DELETE_DAYS,
|
||||||
SETTINGS_AUTO_DELETE_ENABLED_AT,
|
SETTINGS_AUTO_DELETE_ENABLED_AT,
|
||||||
SETTINGS_HIDE_FOLDER_IN_URL,
|
SETTINGS_HIDE_FOLDER_IN_URL,
|
||||||
|
SETTINGS_STORAGE_USE_DATE_PATH,
|
||||||
SETTINGS_IMAGE_BASE_URL,
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
SETTINGS_SITE_BASE_URL,
|
SETTINGS_SITE_BASE_URL,
|
||||||
SETTINGS_WEBP_QUALITY,
|
SETTINGS_WEBP_QUALITY,
|
||||||
@@ -179,6 +180,49 @@ export function isHideFolderInUrl(_event?: H3Event): boolean {
|
|||||||
return isHideFolderInUrlFromEnv()
|
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 {
|
export function getApiUploadTokenFromEnv(event: H3Event): string {
|
||||||
return (
|
return (
|
||||||
getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken')
|
getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken')
|
||||||
@@ -432,6 +476,8 @@ export function getSettingsPayload(event: H3Event) {
|
|||||||
const domainSeparation = isDomainSeparationActive(event)
|
const domainSeparation = isDomainSeparationActive(event)
|
||||||
const hideFolderInUrl = isHideFolderInUrl(event)
|
const hideFolderInUrl = isHideFolderInUrl(event)
|
||||||
const hideFolderInUrlSource = getHideFolderInUrlSource()
|
const hideFolderInUrlSource = getHideFolderInUrlSource()
|
||||||
|
const storageUseDatePath = isStorageUseDatePath(event)
|
||||||
|
const storageUseDatePathSource = getStorageUseDatePathSource()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiUploadToken: getApiUploadToken(event),
|
apiUploadToken: getApiUploadToken(event),
|
||||||
@@ -451,6 +497,8 @@ export function getSettingsPayload(event: H3Event) {
|
|||||||
domainSeparation,
|
domainSeparation,
|
||||||
hideFolderInUrl,
|
hideFolderInUrl,
|
||||||
hideFolderInUrlSource,
|
hideFolderInUrlSource,
|
||||||
|
storageUseDatePath,
|
||||||
|
storageUseDatePathSource,
|
||||||
autoDeleteDays,
|
autoDeleteDays,
|
||||||
autoDeleteDaysSource: getAutoDeleteDaysSource(),
|
autoDeleteDaysSource: getAutoDeleteDaysSource(),
|
||||||
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
|
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import { shouldBlockImageHostRequest } from './host-isolation'
|
import {
|
||||||
|
shouldBlockImageHostRequest,
|
||||||
|
shouldBlockSiteHostImageRequest
|
||||||
|
} from './host-isolation'
|
||||||
|
|
||||||
function mockEvent(): H3Event {
|
function mockEvent(): H3Event {
|
||||||
return {
|
return {
|
||||||
@@ -10,7 +13,7 @@ function mockEvent(): H3Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('shouldBlockImageHostRequest', () => {
|
describe('shouldBlockImageHostRequest', () => {
|
||||||
const imageHost = 'pic.example.com'
|
const imageHost = 'image.example.com'
|
||||||
const siteHost = 'admin.example.com'
|
const siteHost = 'admin.example.com'
|
||||||
|
|
||||||
it('does not block when separation is inactive', () => {
|
it('does not block when separation is inactive', () => {
|
||||||
@@ -67,3 +70,63 @@ describe('shouldBlockImageHostRequest', () => {
|
|||||||
})).toBe(false)
|
})).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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -24,6 +24,8 @@ export function shouldBlockImageHostRequest(
|
|||||||
requestHost?: string
|
requestHost?: string
|
||||||
separationActive?: boolean
|
separationActive?: boolean
|
||||||
imageHost?: string | null
|
imageHost?: string | null
|
||||||
|
siteHost?: string | null
|
||||||
|
hideFolder?: boolean
|
||||||
}
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
|
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
|
||||||
@@ -45,7 +47,45 @@ export function shouldBlockImageHostRequest(
|
|||||||
if (method !== 'GET' && method !== 'HEAD') return true
|
if (method !== 'GET' && method !== 'HEAD') return true
|
||||||
|
|
||||||
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
|
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
|
||||||
|
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
|
||||||
return requestPathToImageKey(pathname, {
|
return requestPathToImageKey(pathname, {
|
||||||
hideFolder: isHideFolderInUrl(event)
|
hideFolder
|
||||||
}) === null
|
}) === 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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ describe('image index key validation', () => {
|
|||||||
it('accepts standard image keys used in index', () => {
|
it('accepts standard image keys used in index', () => {
|
||||||
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
|
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
|
||||||
expect(validateImageKey('twikoo/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', () => {
|
it('rejects path traversal keys', () => {
|
||||||
|
|||||||
+35
-10
@@ -2,8 +2,8 @@ import { promises as fs } from 'node:fs'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getDataDir } from './data-dir'
|
import { getDataDir } from './data-dir'
|
||||||
import { DEFAULT_FOLDER, isValidFolderName, validateImageKey } from './image-key'
|
import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
|
||||||
import { isHiddenImageDatePath } from './image-public-path'
|
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
|
||||||
import {
|
import {
|
||||||
ensureDefaultBackends,
|
ensureDefaultBackends,
|
||||||
ensureStorageSchema,
|
ensureStorageSchema,
|
||||||
@@ -138,6 +138,29 @@ export function findImageKeyByDatePath(datePath: string): string | null {
|
|||||||
return defaultFolder?.key ?? rows[0]!.key
|
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[]> {
|
async function readDirNames(path: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(path, { withFileTypes: true })
|
const entries = await fs.readdir(path, { withFileTypes: true })
|
||||||
@@ -164,8 +187,16 @@ async function listLocalImageKeys(): Promise<string[]> {
|
|||||||
for (const prefix of prefixes) {
|
for (const prefix of prefixes) {
|
||||||
if (!isValidFolderName(prefix)) continue
|
if (!isValidFolderName(prefix)) continue
|
||||||
const root = join(dataDir, prefix)
|
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)
|
const years = await readDirNames(root)
|
||||||
for (const year of years) {
|
for (const year of years) {
|
||||||
|
if (!/^\d{4}$/.test(year)) continue
|
||||||
const months = await readDirNames(join(root, year))
|
const months = await readDirNames(join(root, year))
|
||||||
for (const month of months) {
|
for (const month of months) {
|
||||||
const files = await readFileNames(join(root, year, month))
|
const files = await readFileNames(join(root, year, month))
|
||||||
@@ -222,10 +253,7 @@ export async function listFolders(): Promise<string[]> {
|
|||||||
`).all() as Array<{ folder: string }>
|
`).all() as Array<{ folder: string }>
|
||||||
|
|
||||||
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
||||||
if (!folders.includes(DEFAULT_FOLDER)) {
|
return sortFolderNames(folders)
|
||||||
folders.push(DEFAULT_FOLDER)
|
|
||||||
}
|
|
||||||
return folders.sort((a, b) => a.localeCompare(b))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFoldersForUser(
|
export async function listFoldersForUser(
|
||||||
@@ -240,10 +268,7 @@ export async function listFoldersForUser(
|
|||||||
`).all(...params) as Array<{ folder: string }>
|
`).all(...params) as Array<{ folder: string }>
|
||||||
|
|
||||||
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
||||||
if (!folders.length) {
|
return sortFolderNames(folders)
|
||||||
folders.push(DEFAULT_FOLDER)
|
|
||||||
}
|
|
||||||
return folders.sort((a, b) => a.localeCompare(b))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackendFilterSql(
|
function buildBackendFilterSql(
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { validateImageKey } from './image-key'
|
import { generateImageKey, sortFolderNames, validateImageKey } from './image-key'
|
||||||
|
|
||||||
describe('validateImageKey', () => {
|
describe('validateImageKey', () => {
|
||||||
it('accepts valid keys', () => {
|
it('accepts valid date-layout keys', () => {
|
||||||
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
|
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
|
||||||
expect(validateImageKey('twikoo/2025/01/photo.jpg')).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', () => {
|
it('rejects path traversal and malformed keys', () => {
|
||||||
expect(validateImageKey('')).toBe(false)
|
expect(validateImageKey('')).toBe(false)
|
||||||
expect(validateImageKey('/images/2026/08/a.webp')).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('api/2026/08/a.webp')).toBe(false)
|
||||||
expect(validateImageKey('images/26/8/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/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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -11,6 +11,8 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
|
|||||||
|
|
||||||
export const DEFAULT_FOLDER = 'images'
|
export const DEFAULT_FOLDER = 'images'
|
||||||
|
|
||||||
|
export type StorageLayout = 'date' | 'flat'
|
||||||
|
|
||||||
/** 避免与站点路由 / 静态资源冲突 */
|
/** 避免与站点路由 / 静态资源冲突 */
|
||||||
const RESERVED_FOLDERS = new Set([
|
const RESERVED_FOLDERS = new Set([
|
||||||
'api',
|
'api',
|
||||||
@@ -25,6 +27,8 @@ const RESERVED_FOLDERS = new Set([
|
|||||||
'sw'
|
'sw'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
||||||
|
|
||||||
function generateRandomId(length = 12): string {
|
function generateRandomId(length = 12): string {
|
||||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
const bytes = new Uint8Array(length)
|
const bytes = new Uint8Array(length)
|
||||||
@@ -49,15 +53,37 @@ export function normalizeFolderName(raw: string | undefined | null): string {
|
|||||||
return trimmed
|
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(
|
export function generateImageKey(
|
||||||
contentType: AllowedMimeType,
|
contentType: AllowedMimeType,
|
||||||
date = new Date(),
|
date = new Date(),
|
||||||
prefix = DEFAULT_FOLDER
|
prefix = DEFAULT_FOLDER,
|
||||||
|
layout: StorageLayout = 'date'
|
||||||
): string {
|
): string {
|
||||||
const year = date.getUTCFullYear()
|
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
|
||||||
const id = generateRandomId(12)
|
const id = generateRandomId(12)
|
||||||
const ext = mimeToExtension(contentType)
|
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}`
|
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
|
||||||
if (key.includes('//')) return false
|
if (key.includes('//')) return false
|
||||||
|
|
||||||
const match = key.match(
|
const parts = key.split('/')
|
||||||
/^([A-Za-z0-9][A-Za-z0-9_-]*)\/\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
if (parts.length < 2 || parts.some(part => !part)) return false
|
||||||
)
|
|
||||||
if (!match) return false
|
const folder = parts[0]!
|
||||||
return isValidFolderName(match[1]!)
|
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
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isHiddenImageDatePath, toPublicImagePath } from './image-public-path'
|
import {
|
||||||
|
isBareImageFilename,
|
||||||
|
isHiddenImageDatePath,
|
||||||
|
toPublicImagePath
|
||||||
|
} from './image-public-path'
|
||||||
|
|
||||||
describe('toPublicImagePath', () => {
|
describe('toPublicImagePath', () => {
|
||||||
it('returns key unchanged when hideFolder is false', () => {
|
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/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', () => {
|
it('returns basename when hideFolder is true', () => {
|
||||||
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('2026/08/a.webp')
|
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp')
|
||||||
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('2026/08/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)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
/** 对外 URL 路径:可选去掉 key 的首段目录(如 images/) */
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
|
/** 对外 URL 路径:可选隐藏目录前缀(输出纯文件名) */
|
||||||
export function toPublicImagePath(key: string, hideFolder: boolean): string {
|
export function toPublicImagePath(key: string, hideFolder: boolean): string {
|
||||||
if (!hideFolder) return key
|
if (!hideFolder) return key
|
||||||
const slash = key.indexOf('/')
|
return basename(key)
|
||||||
return slash > 0 ? key.slice(slash + 1) : key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HIDDEN_DATE_PATH_PATTERN
|
const HIDDEN_DATE_PATH_PATTERN
|
||||||
= /^\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
= /^\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 {
|
export function isHiddenImageDatePath(path: string): boolean {
|
||||||
return HIDDEN_DATE_PATH_PATTERN.test(path)
|
return HIDDEN_DATE_PATH_PATTERN.test(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBareImageFilename(path: string): boolean {
|
||||||
|
return BARE_FILENAME_PATTERN.test(path)
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import type { LogSource } from './db'
|
import type { LogSource } from './db'
|
||||||
import { generateImageKey } from './image-key'
|
import { generateImageKey } from './image-key'
|
||||||
import { buildImageItem, sanitizeOriginalName } from './image-response'
|
import { buildImageItem, sanitizeOriginalName } from './image-response'
|
||||||
import { getWebpQuality } from './env'
|
import { getStorageLayout, getWebpQuality } from './env'
|
||||||
import { insertActivityLog } from './db'
|
import { insertActivityLog } from './db'
|
||||||
import { putImage } from './storage'
|
import { putImage } from './storage'
|
||||||
import { logInfo, logException } from './logger'
|
import { logInfo, logException } from './logger'
|
||||||
@@ -122,10 +122,12 @@ export async function processSingleImageUpload(
|
|||||||
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
|
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
|
||||||
|
|
||||||
const uploadedAt = new Date().toISOString()
|
const uploadedAt = new Date().toISOString()
|
||||||
|
const layout = getStorageLayout(event)
|
||||||
const key = generateImageKey(
|
const key = generateImageKey(
|
||||||
compressed.mime,
|
compressed.mime,
|
||||||
new Date(uploadedAt),
|
new Date(uploadedAt),
|
||||||
input.prefix ?? 'images'
|
input.prefix ?? 'images',
|
||||||
|
layout
|
||||||
)
|
)
|
||||||
|
|
||||||
let backendId: string
|
let backendId: string
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { requestPathToImageKey } from './serve-image'
|
import { requestPathToImageKey } from './serve-image'
|
||||||
|
|
||||||
describe('requestPathToImageKey hide folder', () => {
|
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', () => {
|
it('resolves hidden date path to default folder key', () => {
|
||||||
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: true }))
|
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: true }))
|
||||||
.toBe('images/2026/08/demo.webp')
|
.toBe('images/2026/08/demo.webp')
|
||||||
@@ -10,10 +15,19 @@ describe('requestPathToImageKey hide folder', () => {
|
|||||||
it('still accepts full key when hide folder is enabled', () => {
|
it('still accepts full key when hide folder is enabled', () => {
|
||||||
expect(requestPathToImageKey('/images/2026/08/demo.webp', { hideFolder: true }))
|
expect(requestPathToImageKey('/images/2026/08/demo.webp', { hideFolder: true }))
|
||||||
.toBe('images/2026/08/demo.webp')
|
.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 }))
|
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false }))
|
||||||
.toBeNull()
|
.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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getSiteBaseUrlConfigured,
|
getSiteBaseUrlConfigured,
|
||||||
hostnameFromBaseUrl
|
hostnameFromBaseUrl
|
||||||
} from './env'
|
} from './env'
|
||||||
import { findImageKeyByDatePath } from './image-index'
|
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
||||||
import { validateImageKey } from './image-key'
|
import { validateImageKey } from './image-key'
|
||||||
import { buildPublicImageUrl } from './image-response'
|
import { buildPublicImageUrl } from './image-response'
|
||||||
import { createImageStream, headImage } from './storage'
|
import { createImageStream, headImage } from './storage'
|
||||||
@@ -20,7 +20,7 @@ import { getBackendRowForKey } from './storage/resolver'
|
|||||||
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
|
* - 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> {
|
function getAllowedHosts(event: H3Event): Set<string> {
|
||||||
const hosts = new Set<string>()
|
const hosts = new Set<string>()
|
||||||
@@ -83,8 +83,11 @@ export function requestPathToImageKey(
|
|||||||
if (validateImageKey(raw)) return raw
|
if (validateImageKey(raw)) return raw
|
||||||
|
|
||||||
if (options?.hideFolder) {
|
if (options?.hideFolder) {
|
||||||
const resolved = findImageKeyByDatePath(raw)
|
const byFilename = findImageKeyByFilename(raw)
|
||||||
if (resolved && validateImageKey(resolved)) return resolved
|
if (byFilename && validateImageKey(byFilename)) return byFilename
|
||||||
|
|
||||||
|
const byDatePath = findImageKeyByDatePath(raw)
|
||||||
|
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
+12
-3
@@ -53,13 +53,22 @@ export async function putImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function headImage(key: string): Promise<StoredImage | null> {
|
export async function headImage(key: string): Promise<StoredImage | null> {
|
||||||
const indexed = getImageIndexAsStored(key)
|
|
||||||
if (indexed) return indexed
|
|
||||||
|
|
||||||
const backend = await getBackendForKey(key)
|
const backend = await getBackendForKey(key)
|
||||||
const stored = await backend.head(key)
|
const stored = await backend.head(key)
|
||||||
if (!stored) return null
|
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({
|
insertImageIndex({
|
||||||
key: stored.key,
|
key: stored.key,
|
||||||
backendId: backend.id,
|
backendId: backend.id,
|
||||||
|
|||||||
Reference in new issue
Block a user