feat: storage path layout, basename URLs, and bidirectional domain isolation
Add storage_use_date_path for flat vs date-key storage layout and emit basename-only public URLs when hide-folder is enabled. Block image paths on the site host when domain separation is active so only the image host serves files. Update settings UI, i18n, tests, and deployment docs.
This commit is contained in:
1 parent
f7488753ca
commit
438c1d5529
22 files changed
+609
-219
No files matched your search
@@ -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)
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ describe('image index key validation', () => {
|
||||
it('accepts standard image keys used in index', () => {
|
||||
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
|
||||
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
|
||||
expect(validateImageKey('images/abc.webp')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects path traversal keys', () => {
|
||||
|
||||
+35
-10
@@ -2,8 +2,8 @@ import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getDb } from './db'
|
||||
import { getDataDir } from './data-dir'
|
||||
import { DEFAULT_FOLDER, isValidFolderName, validateImageKey } from './image-key'
|
||||
import { isHiddenImageDatePath } from './image-public-path'
|
||||
import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
|
||||
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
|
||||
import {
|
||||
ensureDefaultBackends,
|
||||
ensureStorageSchema,
|
||||
@@ -138,6 +138,29 @@ export function findImageKeyByDatePath(datePath: string): string | null {
|
||||
return defaultFolder?.key ?? rows[0]!.key
|
||||
}
|
||||
|
||||
/** 隐藏目录 URL(纯文件名)反查完整 key;多目录同名时优先 images */
|
||||
export function findImageKeyByFilename(filename: string): string | null {
|
||||
if (!isBareImageFilename(filename)) return null
|
||||
|
||||
ensureStorageSchema()
|
||||
const suffix = `/${filename}`
|
||||
const rows = getDb().prepare(`
|
||||
SELECT key FROM images WHERE key LIKE ?
|
||||
`).all(`%${suffix}`) as Array<{ key: string }>
|
||||
|
||||
if (rows.length === 0) {
|
||||
const flatFallback = `${DEFAULT_FOLDER}/${filename}`
|
||||
return validateImageKey(flatFallback) ? flatFallback : null
|
||||
}
|
||||
if (rows.length === 1) return rows[0]!.key
|
||||
|
||||
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`)
|
||||
if (exactDefault) return exactDefault.key
|
||||
|
||||
const defaultFolder = rows.find(row => row.key.startsWith(`${DEFAULT_FOLDER}/`))
|
||||
return defaultFolder?.key ?? rows[0]!.key
|
||||
}
|
||||
|
||||
async function readDirNames(path: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(path, { withFileTypes: true })
|
||||
@@ -164,8 +187,16 @@ async function listLocalImageKeys(): Promise<string[]> {
|
||||
for (const prefix of prefixes) {
|
||||
if (!isValidFolderName(prefix)) continue
|
||||
const root = join(dataDir, prefix)
|
||||
const rootFiles = await readFileNames(root)
|
||||
for (const file of rootFiles) {
|
||||
if (file.endsWith(META_SUFFIX)) continue
|
||||
const key = `${prefix}/${file}`
|
||||
if (validateImageKey(key)) keys.push(key)
|
||||
}
|
||||
|
||||
const years = await readDirNames(root)
|
||||
for (const year of years) {
|
||||
if (!/^\d{4}$/.test(year)) continue
|
||||
const months = await readDirNames(join(root, year))
|
||||
for (const month of months) {
|
||||
const files = await readFileNames(join(root, year, month))
|
||||
@@ -222,10 +253,7 @@ export async function listFolders(): Promise<string[]> {
|
||||
`).all() as Array<{ folder: string }>
|
||||
|
||||
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
||||
if (!folders.includes(DEFAULT_FOLDER)) {
|
||||
folders.push(DEFAULT_FOLDER)
|
||||
}
|
||||
return folders.sort((a, b) => a.localeCompare(b))
|
||||
return sortFolderNames(folders)
|
||||
}
|
||||
|
||||
export async function listFoldersForUser(
|
||||
@@ -240,10 +268,7 @@ export async function listFoldersForUser(
|
||||
`).all(...params) as Array<{ folder: string }>
|
||||
|
||||
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
||||
if (!folders.length) {
|
||||
folders.push(DEFAULT_FOLDER)
|
||||
}
|
||||
return folders.sort((a, b) => a.localeCompare(b))
|
||||
return sortFolderNames(folders)
|
||||
}
|
||||
|
||||
function buildBackendFilterSql(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,8 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
|
||||
|
||||
export const DEFAULT_FOLDER = 'images'
|
||||
|
||||
export type StorageLayout = 'date' | 'flat'
|
||||
|
||||
/** 避免与站点路由 / 静态资源冲突 */
|
||||
const RESERVED_FOLDERS = new Set([
|
||||
'api',
|
||||
@@ -25,6 +27,8 @@ const RESERVED_FOLDERS = new Set([
|
||||
'sw'
|
||||
])
|
||||
|
||||
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
||||
|
||||
function generateRandomId(length = 12): string {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
const bytes = new Uint8Array(length)
|
||||
@@ -49,15 +53,37 @@ export function normalizeFolderName(raw: string | undefined | null): string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** 默认目录 images 始终排在首位,其余按字母序 */
|
||||
export function sortFolderNames(folders: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const valid: string[] = []
|
||||
for (const folder of folders) {
|
||||
if (!isValidFolderName(folder) || seen.has(folder)) continue
|
||||
seen.add(folder)
|
||||
valid.push(folder)
|
||||
}
|
||||
if (!seen.has(DEFAULT_FOLDER)) {
|
||||
valid.push(DEFAULT_FOLDER)
|
||||
}
|
||||
const rest = valid
|
||||
.filter(folder => folder !== DEFAULT_FOLDER)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
return [DEFAULT_FOLDER, ...rest]
|
||||
}
|
||||
|
||||
export function generateImageKey(
|
||||
contentType: AllowedMimeType,
|
||||
date = new Date(),
|
||||
prefix = DEFAULT_FOLDER
|
||||
prefix = DEFAULT_FOLDER,
|
||||
layout: StorageLayout = 'date'
|
||||
): string {
|
||||
const year = date.getUTCFullYear()
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
const id = generateRandomId(12)
|
||||
const ext = mimeToExtension(contentType)
|
||||
if (layout === 'flat') {
|
||||
return `${prefix}/${id}.${ext}`
|
||||
}
|
||||
const year = date.getUTCFullYear()
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${prefix}/${year}/${month}/${id}.${ext}`
|
||||
}
|
||||
|
||||
@@ -67,9 +93,24 @@ export function validateImageKey(key: string): boolean {
|
||||
if (key.includes('..')) return false
|
||||
if (key.includes('//')) return false
|
||||
|
||||
const match = key.match(
|
||||
/^([A-Za-z0-9][A-Za-z0-9_-]*)\/\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
||||
)
|
||||
if (!match) return false
|
||||
return isValidFolderName(match[1]!)
|
||||
const parts = key.split('/')
|
||||
if (parts.length < 2 || parts.some(part => !part)) return false
|
||||
|
||||
const folder = parts[0]!
|
||||
if (!isValidFolderName(folder)) return false
|
||||
|
||||
const filename = parts[parts.length - 1]!
|
||||
if (!IMAGE_EXT_PATTERN.test(filename)) return false
|
||||
|
||||
if (parts.length === 2) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (parts.length === 4) {
|
||||
const year = parts[1]!
|
||||
const month = parts[2]!
|
||||
return /^\d{4}$/.test(year) && /^\d{2}$/.test(month)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user