release: v1.2.2 — 双域名配置与 Host 隔离修复
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
修复设置页误清空管理域、配置与生效 URL 混淆及第三 Host 绕过隔离;加固 Settings API 与中间件,并更新文档站。
This commit is contained in:
1 parent
6c6766221b
commit
c9c34515dd
36 files changed
+2578
-180
No files matched your search
@@ -23,7 +23,7 @@ import {
|
||||
parseAutoDeleteDays,
|
||||
parseWebpQuality,
|
||||
setGlobalAutoDeletePolicy,
|
||||
validateDomainSeparationPair
|
||||
validateSettingsDomainPatch
|
||||
} from '../../utils/env'
|
||||
|
||||
interface SettingsPatchBody {
|
||||
@@ -31,6 +31,7 @@ interface SettingsPatchBody {
|
||||
allowedRefererHosts?: unknown
|
||||
siteBaseUrl?: unknown
|
||||
imageBaseUrl?: unknown
|
||||
domainSeparation?: unknown
|
||||
hideFolderInUrl?: unknown
|
||||
storageUseDatePath?: unknown
|
||||
autoDeleteDays?: unknown
|
||||
@@ -71,6 +72,11 @@ export default defineEventHandler(async (event) => {
|
||||
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
|
||||
}
|
||||
|
||||
const existingSite = normalizeSiteBaseUrl(getSetting(SETTINGS_SITE_BASE_URL) ?? '')
|
||||
const existingImage = normalizeImageBaseUrl(getSetting(SETTINGS_IMAGE_BASE_URL) ?? '')
|
||||
let nextSite = existingSite
|
||||
let nextImage = existingImage
|
||||
|
||||
if (body.siteBaseUrl !== undefined) {
|
||||
const configured = normalizeSiteBaseUrl(
|
||||
typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : ''
|
||||
@@ -79,11 +85,11 @@ export default defineEventHandler(async (event) => {
|
||||
createApiError(
|
||||
event,
|
||||
'INVALID_REQUEST',
|
||||
'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
|
||||
'SITE_BASE_URL 需为 http(s) 地址,且末尾不加 /',
|
||||
400
|
||||
)
|
||||
}
|
||||
setSetting(SETTINGS_SITE_BASE_URL, configured)
|
||||
nextSite = configured
|
||||
}
|
||||
|
||||
if (body.imageBaseUrl !== undefined) {
|
||||
@@ -98,22 +104,36 @@ export default defineEventHandler(async (event) => {
|
||||
400
|
||||
)
|
||||
}
|
||||
setSetting(SETTINGS_IMAGE_BASE_URL, configured)
|
||||
nextImage = configured
|
||||
}
|
||||
|
||||
if (body.siteBaseUrl !== undefined || body.imageBaseUrl !== undefined) {
|
||||
const siteConfigured = normalizeSiteBaseUrl(
|
||||
getSetting(SETTINGS_SITE_BASE_URL) ?? ''
|
||||
)
|
||||
const imageConfigured = normalizeImageBaseUrl(
|
||||
getSetting(SETTINGS_IMAGE_BASE_URL) ?? ''
|
||||
)
|
||||
if (siteConfigured && imageConfigured) {
|
||||
const pairError = validateDomainSeparationPair(siteConfigured, imageConfigured)
|
||||
if (pairError) {
|
||||
createApiError(event, 'INVALID_REQUEST', pairError, 400)
|
||||
}
|
||||
}
|
||||
const domainSeparationProvided = body.domainSeparation !== undefined
|
||||
const wantSeparation = domainSeparationProvided
|
||||
? Boolean(body.domainSeparation)
|
||||
: null
|
||||
|
||||
const patchError = validateSettingsDomainPatch({
|
||||
existingSite,
|
||||
existingImage,
|
||||
nextSite,
|
||||
nextImage,
|
||||
wantSeparation,
|
||||
siteBaseUrlProvided: body.siteBaseUrl !== undefined
|
||||
})
|
||||
if (patchError) {
|
||||
createApiError(event, 'INVALID_REQUEST', patchError, 400)
|
||||
}
|
||||
|
||||
if (wantSeparation === false) {
|
||||
nextSite = ''
|
||||
}
|
||||
|
||||
if (body.siteBaseUrl !== undefined || body.domainSeparation !== undefined) {
|
||||
setSetting(SETTINGS_SITE_BASE_URL, nextSite)
|
||||
}
|
||||
|
||||
if (body.imageBaseUrl !== undefined) {
|
||||
setSetting(SETTINGS_IMAGE_BASE_URL, nextImage)
|
||||
}
|
||||
|
||||
if (body.autoDeleteDays !== undefined) {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
|
||||
import {
|
||||
shouldBlockImageHostRequest,
|
||||
shouldBlockSiteHostImageRequest,
|
||||
shouldBlockUnknownHostRequest
|
||||
} from '../utils/host-isolation'
|
||||
|
||||
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
|
||||
if (
|
||||
shouldBlockUnknownHostRequest(event)
|
||||
|| shouldBlockImageHostRequest(event)
|
||||
|| shouldBlockSiteHostImageRequest(event)
|
||||
) {
|
||||
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getAutoDeleteDays,
|
||||
getAutoDeleteDaysSource,
|
||||
getRefererSource,
|
||||
getSettingsPayload,
|
||||
getSiteBaseUrl,
|
||||
getSiteBaseUrlConfigured,
|
||||
getSiteBaseUrlSource,
|
||||
@@ -17,13 +18,15 @@ import {
|
||||
isAutoDeleteDaysEnvConfigured,
|
||||
isRefererEnvConfigured,
|
||||
isWebpQualityEnvConfigured,
|
||||
validateDomainSeparationPair
|
||||
validateDomainSeparationPair,
|
||||
validateSettingsDomainPatch
|
||||
} from './env'
|
||||
import {
|
||||
getSetting,
|
||||
SETTINGS_ALLOWED_REFERER_HOSTS,
|
||||
SETTINGS_API_UPLOAD_TOKEN,
|
||||
SETTINGS_AUTO_DELETE_DAYS,
|
||||
SETTINGS_IMAGE_BASE_URL,
|
||||
SETTINGS_SITE_BASE_URL,
|
||||
SETTINGS_WEBP_QUALITY,
|
||||
generateApiUploadToken
|
||||
@@ -255,3 +258,65 @@ describe('validateDomainSeparationPair', () => {
|
||||
)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSettingsDomainPatch', () => {
|
||||
const dualExisting = {
|
||||
existingSite: 'https://admin.example.com',
|
||||
existingImage: 'https://pic.example.com'
|
||||
}
|
||||
|
||||
it('requires both URLs when enabling domain separation', () => {
|
||||
expect(validateSettingsDomainPatch({
|
||||
...dualExisting,
|
||||
nextSite: '',
|
||||
nextImage: 'https://pic.example.com',
|
||||
wantSeparation: true,
|
||||
siteBaseUrlProvided: true
|
||||
})).toContain('同时填写')
|
||||
})
|
||||
|
||||
it('blocks clearing site without explicit disable when dual domain is active', () => {
|
||||
expect(validateSettingsDomainPatch({
|
||||
...dualExisting,
|
||||
nextSite: '',
|
||||
nextImage: 'https://pic.example.com',
|
||||
wantSeparation: null,
|
||||
siteBaseUrlProvided: true
|
||||
})).toContain('不能单独清空管理域名')
|
||||
})
|
||||
|
||||
it('allows clearing site when explicitly disabling domain separation', () => {
|
||||
expect(validateSettingsDomainPatch({
|
||||
...dualExisting,
|
||||
nextSite: '',
|
||||
nextImage: 'https://pic.example.com',
|
||||
wantSeparation: false,
|
||||
siteBaseUrlProvided: true
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSettingsPayload configured vs effective', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
delete process.env.SITE_BASE_URL
|
||||
delete process.env.IMAGE_BASE_URL
|
||||
})
|
||||
|
||||
it('returns configured site URL without request fallback', () => {
|
||||
vi.mocked(getSetting).mockImplementation((key) => {
|
||||
if (key === SETTINGS_SITE_BASE_URL) return 'https://admin.example.com'
|
||||
if (key === SETTINGS_IMAGE_BASE_URL) return 'https://pic.example.com'
|
||||
return null
|
||||
})
|
||||
vi.stubGlobal('getRequestURL', () => new URL('https://pages.dev'))
|
||||
|
||||
const event = mockEvent()
|
||||
const payload = getSettingsPayload(event)
|
||||
|
||||
expect(payload.siteBaseUrl).toBe('https://admin.example.com')
|
||||
expect(payload.effectiveSiteBaseUrl).toBe('https://admin.example.com')
|
||||
expect(payload.runtime.currentHost).toBe('pages.dev')
|
||||
expect(payload.runtime.hostRole).toBe('unknown')
|
||||
})
|
||||
})
|
||||
+101
-8
@@ -465,12 +465,102 @@ export function validateDomainSeparationPair(
|
||||
return null
|
||||
}
|
||||
|
||||
export type RequestHostRole = 'site' | 'image' | 'unknown' | 'single'
|
||||
|
||||
export function getIsolationRequestHost(event: H3Event): string {
|
||||
const requestUrl = getRequestURL(event, {
|
||||
xForwardedHost: true,
|
||||
xForwardedProto: true
|
||||
})
|
||||
return requestUrl.hostname.toLowerCase()
|
||||
}
|
||||
|
||||
export function validateSettingsDomainPatch(input: {
|
||||
existingSite: string
|
||||
existingImage: string
|
||||
nextSite: string
|
||||
nextImage: string
|
||||
wantSeparation: boolean | null
|
||||
siteBaseUrlProvided: boolean
|
||||
}): string | null {
|
||||
const {
|
||||
existingSite,
|
||||
existingImage,
|
||||
nextSite,
|
||||
nextImage,
|
||||
wantSeparation,
|
||||
siteBaseUrlProvided
|
||||
} = input
|
||||
|
||||
const hadDualDomain = (() => {
|
||||
const siteHost = hostnameFromBaseUrl(existingSite)
|
||||
const imageHost = hostnameFromBaseUrl(existingImage)
|
||||
return Boolean(siteHost && imageHost && siteHost !== imageHost)
|
||||
})()
|
||||
|
||||
if (wantSeparation === true) {
|
||||
if (!nextSite || !nextImage) {
|
||||
return '启用域名分离时需同时填写网站域名与图片域名'
|
||||
}
|
||||
return validateDomainSeparationPair(nextSite, nextImage)
|
||||
}
|
||||
|
||||
if (wantSeparation === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (siteBaseUrlProvided && !nextSite && existingSite && hadDualDomain) {
|
||||
return '已启用域名分离时不能单独清空管理域名,请显式关闭域名分离'
|
||||
}
|
||||
|
||||
if (nextSite && nextImage) {
|
||||
return validateDomainSeparationPair(nextSite, nextImage)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getRequestRuntime(event: H3Event) {
|
||||
const requestUrl = getRequestURL(event, {
|
||||
xForwardedHost: true,
|
||||
xForwardedProto: true
|
||||
})
|
||||
const currentHost = requestUrl.hostname.toLowerCase()
|
||||
const currentOrigin = requestUrl.origin
|
||||
|
||||
if (!isDomainSeparationActive(event)) {
|
||||
return {
|
||||
currentOrigin,
|
||||
currentHost,
|
||||
hostRole: 'single' as const
|
||||
}
|
||||
}
|
||||
|
||||
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
||||
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
||||
|
||||
let hostRole: RequestHostRole = 'unknown'
|
||||
if (currentHost === siteHost) {
|
||||
hostRole = 'site'
|
||||
} else if (currentHost === imageHost) {
|
||||
hostRole = 'image'
|
||||
}
|
||||
|
||||
return {
|
||||
currentOrigin,
|
||||
currentHost,
|
||||
hostRole
|
||||
}
|
||||
}
|
||||
|
||||
export function getSettingsPayload(event: H3Event) {
|
||||
const config = useRuntimeConfig(event)
|
||||
const webpQuality = getWebpQuality(event)
|
||||
const allowedRefererHosts = getAllowedRefererHostsRaw(event)
|
||||
const siteBaseUrl = getSiteBaseUrl(event)
|
||||
const imageBaseUrl = getImageBaseUrl(event)
|
||||
const siteBaseUrlConfigured = getSiteBaseUrlConfigured(event)
|
||||
const imageBaseUrlConfigured = getImageBaseUrlConfigured(event)
|
||||
const effectiveSiteBaseUrl = getSiteBaseUrl(event)
|
||||
const effectiveImageBaseUrl = getImageBaseUrl(event)
|
||||
const autoDeleteDays = getAutoDeleteDays(event)
|
||||
const appVersion = config.appVersion as string
|
||||
const domainSeparation = isDomainSeparationActive(event)
|
||||
@@ -488,13 +578,16 @@ export function getSettingsPayload(event: H3Event) {
|
||||
allowedRefererHosts,
|
||||
refererSource: getRefererSource(),
|
||||
refererEnvFallback: getRefererHostsFromEnv(),
|
||||
siteBaseUrl,
|
||||
siteBaseUrlConfigured: getSiteBaseUrlConfigured(event),
|
||||
siteBaseUrl: siteBaseUrlConfigured,
|
||||
siteBaseUrlConfigured,
|
||||
siteBaseUrlSource: getSiteBaseUrlSource(event),
|
||||
imageBaseUrl,
|
||||
imageBaseUrlConfigured: getImageBaseUrlConfigured(event),
|
||||
imageBaseUrl: imageBaseUrlConfigured,
|
||||
imageBaseUrlConfigured,
|
||||
imageBaseUrlSource: getImageBaseUrlSource(event),
|
||||
effectiveSiteBaseUrl,
|
||||
effectiveImageBaseUrl,
|
||||
domainSeparation,
|
||||
runtime: getRequestRuntime(event),
|
||||
hideFolderInUrl,
|
||||
hideFolderInUrlSource,
|
||||
storageUseDatePath,
|
||||
@@ -507,8 +600,8 @@ export function getSettingsPayload(event: H3Event) {
|
||||
env: {
|
||||
webpQuality,
|
||||
refererConfigured: allowedRefererHosts.length > 0,
|
||||
siteBaseUrl,
|
||||
imageBaseUrl,
|
||||
siteBaseUrl: effectiveSiteBaseUrl,
|
||||
imageBaseUrl: effectiveImageBaseUrl,
|
||||
hideFolderInUrl,
|
||||
appVersion
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { H3Event } from 'h3'
|
||||
import {
|
||||
shouldBlockImageHostRequest,
|
||||
shouldBlockSiteHostImageRequest
|
||||
shouldBlockSiteHostImageRequest,
|
||||
shouldBlockUnknownHostRequest
|
||||
} from './host-isolation'
|
||||
|
||||
function mockEvent(): H3Event {
|
||||
@@ -96,6 +97,18 @@ describe('shouldBlockSiteHostImageRequest', () => {
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks public image paths on site host', () => {
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockSiteHostImageRequest(event, {
|
||||
separationActive: true,
|
||||
siteHost,
|
||||
requestHost: siteHost,
|
||||
method: 'GET',
|
||||
pathname: '/2026/08/demo.webp',
|
||||
hideFolder: false
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks legacy date short paths on site host', () => {
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockSiteHostImageRequest(event, {
|
||||
@@ -130,3 +143,71 @@ describe('shouldBlockSiteHostImageRequest', () => {
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldBlockUnknownHostRequest', () => {
|
||||
const imageHost = 'image.example.com'
|
||||
const siteHost = 'admin.example.com'
|
||||
|
||||
it('does not block when separation is inactive', () => {
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockUnknownHostRequest(event, {
|
||||
separationActive: false,
|
||||
requestHost: 'pages.dev',
|
||||
siteHost,
|
||||
imageHost
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks unconfigured third host when separation is active', () => {
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockUnknownHostRequest(event, {
|
||||
separationActive: true,
|
||||
requestHost: 'pages.dev',
|
||||
siteHost,
|
||||
imageHost
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks admin paths on image host when host is unconfigured', () => {
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockUnknownHostRequest(event, {
|
||||
separationActive: true,
|
||||
requestHost: 'old-img.example.com',
|
||||
siteHost,
|
||||
imageHost
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('allows configured site host', () => {
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockUnknownHostRequest(event, {
|
||||
separationActive: true,
|
||||
requestHost: siteHost,
|
||||
siteHost,
|
||||
imageHost
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('allows configured image host', () => {
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockUnknownHostRequest(event, {
|
||||
separationActive: true,
|
||||
requestHost: imageHost,
|
||||
siteHost,
|
||||
imageHost
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('allows localhost in development', () => {
|
||||
const previous = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
const event = mockEvent()
|
||||
expect(shouldBlockUnknownHostRequest(event, {
|
||||
separationActive: true,
|
||||
requestHost: 'localhost',
|
||||
siteHost,
|
||||
imageHost
|
||||
})).toBe(false)
|
||||
process.env.NODE_ENV = previous
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,20 @@ import {
|
||||
} from './env'
|
||||
import { requestPathToImageKey } from './serve-image'
|
||||
|
||||
const DEV_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
|
||||
|
||||
function getIsolationRequestHost(event: H3Event): string {
|
||||
const requestUrl = getRequestURL(event, {
|
||||
xForwardedHost: true,
|
||||
xForwardedProto: true
|
||||
})
|
||||
return requestUrl.hostname.toLowerCase()
|
||||
}
|
||||
|
||||
function isDevLocalHost(host: string): boolean {
|
||||
return process.env.NODE_ENV === 'development' && DEV_LOCAL_HOSTS.has(host)
|
||||
}
|
||||
|
||||
export function getConfiguredSiteHost(event: H3Event): string | null {
|
||||
return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
|
||||
}
|
||||
@@ -16,6 +30,30 @@ export function getConfiguredImageHost(event: H3Event): string | null {
|
||||
return hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
|
||||
}
|
||||
|
||||
/** 双域名开启时,拒绝未配置的第三 Host(开发环境 localhost 例外) */
|
||||
export function shouldBlockUnknownHostRequest(
|
||||
event: H3Event,
|
||||
options?: {
|
||||
requestHost?: string
|
||||
separationActive?: boolean
|
||||
siteHost?: string | null
|
||||
imageHost?: string | null
|
||||
}
|
||||
): boolean {
|
||||
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
|
||||
if (!separationActive) return false
|
||||
|
||||
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
|
||||
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
|
||||
if (!siteHost || !imageHost) return false
|
||||
|
||||
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
|
||||
if (requestHost === siteHost || requestHost === imageHost) return false
|
||||
if (isDevLocalHost(requestHost)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function shouldBlockImageHostRequest(
|
||||
event: H3Event,
|
||||
options?: {
|
||||
@@ -34,19 +72,16 @@ export function shouldBlockImageHostRequest(
|
||||
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
|
||||
if (!imageHost) return false
|
||||
|
||||
const requestUrl = options?.requestHost
|
||||
? null
|
||||
: getRequestURL(event, {
|
||||
xForwardedHost: true,
|
||||
xForwardedProto: true
|
||||
})
|
||||
const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase()
|
||||
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
|
||||
if (requestHost !== imageHost) return false
|
||||
|
||||
const method = (options?.method ?? getMethod(event)).toUpperCase()
|
||||
if (method !== 'GET' && method !== 'HEAD') return true
|
||||
|
||||
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
|
||||
const pathname = options?.pathname ?? getRequestURL(event, {
|
||||
xForwardedHost: true,
|
||||
xForwardedProto: true
|
||||
}).pathname
|
||||
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
|
||||
return requestPathToImageKey(pathname, {
|
||||
hideFolder
|
||||
@@ -71,19 +106,16 @@ export function shouldBlockSiteHostImageRequest(
|
||||
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()
|
||||
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).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 pathname = options?.pathname ?? getRequestURL(event, {
|
||||
xForwardedHost: true,
|
||||
xForwardedProto: true
|
||||
}).pathname
|
||||
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
|
||||
return requestPathToImageKey(pathname, {
|
||||
hideFolder
|
||||
|
||||
@@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isBareImageFilename,
|
||||
isHiddenImageDatePath,
|
||||
toPublicImagePath
|
||||
storageKeyToPublicPath,
|
||||
toPublicImagePath,
|
||||
toStorageKeyFromPublicPath
|
||||
} 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 internal images/ prefix when hideFolder is false', () => {
|
||||
expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('2026/08/a.webp')
|
||||
expect(toPublicImagePath('images/a.webp', false)).toBe('a.webp')
|
||||
expect(toPublicImagePath('images/blog/a.webp', false)).toBe('blog/a.webp')
|
||||
})
|
||||
|
||||
it('returns basename when hideFolder is true', () => {
|
||||
@@ -18,6 +21,29 @@ describe('toPublicImagePath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('storageKeyToPublicPath', () => {
|
||||
it('removes images/ prefix only', () => {
|
||||
expect(storageKeyToPublicPath('images/2026/08/a.webp')).toBe('2026/08/a.webp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toStorageKeyFromPublicPath', () => {
|
||||
it('maps public relative paths to storage keys', () => {
|
||||
expect(toStorageKeyFromPublicPath('2026/08/a.webp')).toBe('images/2026/08/a.webp')
|
||||
expect(toStorageKeyFromPublicPath('blog/a.webp')).toBe('images/blog/a.webp')
|
||||
expect(toStorageKeyFromPublicPath('a.webp')).toBe('images/a.webp')
|
||||
})
|
||||
|
||||
it('accepts legacy full storage keys', () => {
|
||||
expect(toStorageKeyFromPublicPath('images/2026/08/a.webp')).toBe('images/2026/08/a.webp')
|
||||
})
|
||||
|
||||
it('rejects invalid paths', () => {
|
||||
expect(toStorageKeyFromPublicPath('')).toBeNull()
|
||||
expect(toStorageKeyFromPublicPath('../../../etc/passwd')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isHiddenImageDatePath', () => {
|
||||
it('accepts YYYY/MM/file paths', () => {
|
||||
expect(isHiddenImageDatePath('2026/08/demo.webp')).toBe(true)
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import { basename } from 'node:path'
|
||||
import { DEFAULT_FOLDER, validateImageKey } from './image-key'
|
||||
|
||||
/** 对外 URL 路径:可选隐藏目录前缀(输出纯文件名) */
|
||||
/** 存储 key → 对外 URL 路径(不含域名) */
|
||||
export function storageKeyToPublicPath(key: string): string {
|
||||
const prefix = `${DEFAULT_FOLDER}/`
|
||||
if (key.startsWith(prefix)) return key.slice(prefix.length)
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外 URL 路径:
|
||||
* - hideFolder=true:仅文件名(img.com/a8K3xP.webp)
|
||||
* - hideFolder=false:去掉内部 images/ 前缀(img.com/2026/08/a.webp)
|
||||
*/
|
||||
export function toPublicImagePath(key: string, hideFolder: boolean): string {
|
||||
if (!hideFolder) return key
|
||||
return basename(key)
|
||||
if (hideFolder) return basename(key)
|
||||
return storageKeyToPublicPath(key)
|
||||
}
|
||||
|
||||
/** 公开相对路径 → 存储 key;legacy 完整 key(含 images/)仍接受 */
|
||||
export function toStorageKeyFromPublicPath(publicPath: string): string | null {
|
||||
const raw = publicPath.replace(/^\/+/, '')
|
||||
if (!raw) return null
|
||||
if (validateImageKey(raw)) return raw
|
||||
const candidate = `${DEFAULT_FOLDER}/${raw}`
|
||||
return validateImageKey(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
const HIDDEN_DATE_PATH_PATTERN
|
||||
|
||||
@@ -19,14 +19,16 @@ describe('requestPathToImageKey hide folder', () => {
|
||||
.toBe('images/demo.webp')
|
||||
})
|
||||
|
||||
it('does not resolve short paths when hide folder is disabled', () => {
|
||||
it('resolves public paths when hide folder is disabled', () => {
|
||||
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false }))
|
||||
.toBeNull()
|
||||
.toBe('images/2026/08/demo.webp')
|
||||
expect(requestPathToImageKey('/blog/demo.webp', { hideFolder: false }))
|
||||
.toBe('images/blog/demo.webp')
|
||||
expect(requestPathToImageKey('/demo.webp', { hideFolder: false }))
|
||||
.toBeNull()
|
||||
.toBe('images/demo.webp')
|
||||
})
|
||||
|
||||
it('accepts flat full key when hide folder is disabled', () => {
|
||||
it('accepts legacy full key when hide folder is disabled', () => {
|
||||
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: false }))
|
||||
.toBe('images/demo.webp')
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './env'
|
||||
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
||||
import { validateImageKey } from './image-key'
|
||||
import { toStorageKeyFromPublicPath } from './image-public-path'
|
||||
import { buildPublicImageUrl } from './image-response'
|
||||
import { createImageStream, headImage } from './storage'
|
||||
import { getBackendRowForKey } from './storage/resolver'
|
||||
@@ -80,15 +81,20 @@ export function requestPathToImageKey(
|
||||
options?: { hideFolder?: boolean }
|
||||
): string | null {
|
||||
const raw = decodeURIComponent(pathname).replace(/^\/+/, '')
|
||||
if (!raw) return null
|
||||
|
||||
if (validateImageKey(raw)) return raw
|
||||
|
||||
if (options?.hideFolder) {
|
||||
const byFilename = findImageKeyByFilename(raw)
|
||||
if (byFilename && validateImageKey(byFilename)) return byFilename
|
||||
const fromPublic = toStorageKeyFromPublicPath(raw)
|
||||
if (fromPublic) return fromPublic
|
||||
|
||||
const byDatePath = findImageKeyByDatePath(raw)
|
||||
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
|
||||
}
|
||||
if (!options?.hideFolder) return null
|
||||
|
||||
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