diff --git a/.env.example b/.env.example index f1eb495..2b128d9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/pages/logs.vue b/app/pages/logs.vue index a909a96..368c44b 100644 --- a/app/pages/logs.vue +++ b/app/pages/logs.vue @@ -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) { diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index e35886d..d5ec014 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -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" >
-
-
+
+

- {{ t('settings.uploadAccount') }} + {{ t('settings.featureToggles') }}

-
- -

- {{ t('settings.defaultFolderHint') }} -

- -
- -
-
-
-

- {{ t('settings.accessControl') }} -

- - {{ sourceBadge(settings.refererSource).label }} - -
-

- {{ t('settings.refererHint') }} -

- -
-
- -
-

- {{ t('settings.linkGeneration') }} -

- - + -

- {{ t('setup.domainSeparationProxyHint') }} - - {{ t('setup.domainSeparationProxyExample') }} - -

- -
-
-
- - - {{ sourceBadge(settings.siteBaseUrlSource).label }} - -
-

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

- -

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

-
- -
-
- - - {{ sourceBadge(settings.imageBaseUrlSource).label }} - -
-

- {{ t('settings.imageBaseUrlHint') }} -

- -

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

-
-
- -
-
- - - {{ sourceBadge(settings.imageBaseUrlSource).label }} - -
-

- {{ t('settings.imageBaseUrlSingleHint') }} -

- -

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

-
- -
-
+ +
+
+ +

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

+ +
+ +
+
+ + + {{ sourceBadge(settings.refererSource).label }} + +
+

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

+ +
+
+
+ +
+

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

+ +
+ + +

+ {{ t('setup.domainSeparationProxyHint') }} + + {{ t('setup.domainSeparationProxyExample') }} + +

+ +
+
+
+ + + {{ sourceBadge(settings.siteBaseUrlSource).label }} + +
+

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

+ +

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

+
+ +
+
+ + + {{ sourceBadge(settings.imageBaseUrlSource).label }} + +
+

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

+ +

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

+
+
+ +
+
+ + + {{ sourceBadge(settings.imageBaseUrlSource).label }} + +
+

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

+ +

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

+
+
@@ -522,14 +556,14 @@ watch(isAuthenticated, async (authed, prev) => {
- + {{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }} +
{
+ + diff --git a/docs/domain-separation.md b/docs/domain-separation.md index a8bc07d..2180de1 100644 --- a/docs/domain-separation.md +++ b/docs/domain-separation.md @@ -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/` | 能打开,但与双域名配置无关,登录态不共享 | diff --git a/i18n/locales/en.json b/i18n/locales/en.json index aa314c2..43c4060 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index f6d34f0..86e5d32 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -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": "默认", diff --git a/server/api/settings/index.patch.ts b/server/api/settings/index.patch.ts index 7c15ae4..5185014 100644 --- a/server/api/settings/index.patch.ts +++ b/server/api/settings/index.patch.ts @@ -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) }) diff --git a/server/middleware/00-host-isolation.ts b/server/middleware/00-host-isolation.ts index 4ac896f..216aa26 100644 --- a/server/middleware/00-host-isolation.ts +++ b/server/middleware/00-host-isolation.ts @@ -1,7 +1,7 @@ -import { shouldBlockImageHostRequest } from '../utils/host-isolation' +import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation' export default defineEventHandler((event) => { - if (shouldBlockImageHostRequest(event)) { + if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) { throw createError({ statusCode: 404, statusMessage: 'Not Found' }) } }) diff --git a/server/middleware/01-serve-image.ts b/server/middleware/01-serve-image.ts index 2bdb2b6..4e487f3 100644 --- a/server/middleware/01-serve-image.ts +++ b/server/middleware/01-serve-image.ts @@ -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) => { diff --git a/server/utils/db.ts b/server/utils/db.ts index d858673..d6eed90 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -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' diff --git a/server/utils/env.ts b/server/utils/env.ts index 4ec4db8..09e596b 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -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, diff --git a/server/utils/host-isolation.test.ts b/server/utils/host-isolation.test.ts index 7030a2f..761abec 100644 --- a/server/utils/host-isolation.test.ts +++ b/server/utils/host-isolation.test.ts @@ -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) + }) +}) diff --git a/server/utils/host-isolation.ts b/server/utils/host-isolation.ts index 690e42f..8664867 100644 --- a/server/utils/host-isolation.ts +++ b/server/utils/host-isolation.ts @@ -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 +} diff --git a/server/utils/image-index.test.ts b/server/utils/image-index.test.ts index b92a70a..6c54c33 100644 --- a/server/utils/image-index.test.ts +++ b/server/utils/image-index.test.ts @@ -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', () => { diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts index 462f605..8ff85f0 100644 --- a/server/utils/image-index.ts +++ b/server/utils/image-index.ts @@ -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 { try { const entries = await fs.readdir(path, { withFileTypes: true }) @@ -164,8 +187,16 @@ async function listLocalImageKeys(): Promise { 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 { `).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( diff --git a/server/utils/image-key.test.ts b/server/utils/image-key.test.ts index 0e5140e..b4b3946 100644 --- a/server/utils/image-key.test.ts +++ b/server/utils/image-key.test.ts @@ -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) }) }) diff --git a/server/utils/image-key.ts b/server/utils/image-key.ts index 32f7d50..ada2473 100644 --- a/server/utils/image-key.ts +++ b/server/utils/image-key.ts @@ -11,6 +11,8 @@ const EXT_MAP: Record = { 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() + 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 } diff --git a/server/utils/image-public-path.test.ts b/server/utils/image-public-path.test.ts index 7e1ab2a..146dd49 100644 --- a/server/utils/image-public-path.test.ts +++ b/server/utils/image-public-path.test.ts @@ -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) + }) +}) diff --git a/server/utils/image-public-path.ts b/server/utils/image-public-path.ts index 2a7f91d..0f2dc18 100644 --- a/server/utils/image-public-path.ts +++ b/server/utils/image-public-path.ts @@ -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) +} diff --git a/server/utils/process-image-upload.ts b/server/utils/process-image-upload.ts index 1e3e766..79f1432 100644 --- a/server/utils/process-image-upload.ts +++ b/server/utils/process-image-upload.ts @@ -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 diff --git a/server/utils/serve-image-path.test.ts b/server/utils/serve-image-path.test.ts index 6cb536c..b5b1a77 100644 --- a/server/utils/serve-image-path.test.ts +++ b/server/utils/serve-image-path.test.ts @@ -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') }) }) diff --git a/server/utils/serve-image.ts b/server/utils/serve-image.ts index 002b368..9a79b23 100644 --- a/server/utils/serve-image.ts +++ b/server/utils/serve-image.ts @@ -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' @@ -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