release: v1.1.3 — dual-domain separation and URL options
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s

Add admin/image domain separation with host isolation middleware,
optional hide-folder public URLs, GitHub release update check, and
Lucky/Nginx deployment docs. Switch license to GPL-3.0-only.
This commit is contained in:
O96u committed 2026-08-27 12:13:58 +08:00
1 parent 55effa204e
commit d8c1303c32
41 files changed
+2170 -299

No files matched your search

+43 -1
View File
@@ -1,9 +1,12 @@
import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
getSetting,
setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
setAllowRegistration
} from '../../utils/db'
@@ -11,18 +14,23 @@ import {
getSettingsPayload,
isValidImageBaseUrl,
isValidRefererHost,
isValidSiteBaseUrl,
MAX_AUTO_DELETE_DAYS,
normalizeImageBaseUrl,
normalizeRefererHosts,
normalizeSiteBaseUrl,
parseAutoDeleteDays,
parseWebpQuality,
setGlobalAutoDeletePolicy
setGlobalAutoDeletePolicy,
validateDomainSeparationPair
} from '../../utils/env'
interface SettingsPatchBody {
webpQuality?: unknown
allowedRefererHosts?: unknown
siteBaseUrl?: unknown
imageBaseUrl?: unknown
hideFolderInUrl?: unknown
autoDeleteDays?: unknown
allowRegistration?: unknown
}
@@ -61,6 +69,21 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
}
if (body.siteBaseUrl !== undefined) {
const configured = normalizeSiteBaseUrl(
typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : ''
)
if (configured && !isValidSiteBaseUrl(configured)) {
createApiError(
event,
'INVALID_REQUEST',
'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
400
)
}
setSetting(SETTINGS_SITE_BASE_URL, configured)
}
if (body.imageBaseUrl !== undefined) {
const configured = normalizeImageBaseUrl(
typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : ''
@@ -76,6 +99,21 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_IMAGE_BASE_URL, 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)
}
}
}
if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) {
@@ -93,5 +131,9 @@ export default defineEventHandler(async (event) => {
setAllowRegistration(Boolean(body.allowRegistration))
}
if (body.hideFolderInUrl !== undefined) {
setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false')
}
return getSettingsPayload(event)
})
+21 -1
View File
@@ -4,13 +4,21 @@ import {
createUserAccount,
isInitialized
} from '../utils/auth'
import { setAllowRegistration } from '../utils/db'
import { setAllowRegistration, setSetting, SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL } from '../utils/db'
import {
normalizeImageBaseUrl,
normalizeSiteBaseUrl,
validateDomainSeparationPair
} from '../utils/env'
import { clientIp, logInfo } from '../utils/logger'
interface SetupBody {
username?: string
password?: string
allowRegistration?: boolean
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
}
export default defineEventHandler(async (event) => {
@@ -30,6 +38,18 @@ export default defineEventHandler(async (event) => {
})
setAllowRegistration(Boolean(body?.allowRegistration))
if (body?.domainSeparation) {
const siteBaseUrl = normalizeSiteBaseUrl(body.siteBaseUrl ?? '')
const imageBaseUrl = normalizeImageBaseUrl(body.imageBaseUrl ?? '')
const pairError = validateDomainSeparationPair(siteBaseUrl, imageBaseUrl)
if (pairError) {
createApiError(event, 'INVALID_REQUEST', pairError, 400)
}
setSetting(SETTINGS_SITE_BASE_URL, siteBaseUrl)
setSetting(SETTINGS_IMAGE_BASE_URL, imageBaseUrl)
}
await createSession(event, user.id)
logInfo('setup complete', { ip: clientIp(event), username: user.username })
+3 -1
View File
@@ -1,6 +1,6 @@
import { requireUserAuth } from '../../utils/access'
import { ensureUserApiToken } from '../../utils/db'
import { getImageBaseUrl } from '../../utils/env'
import { getImageBaseUrl, getSiteBaseUrl, isHideFolderInUrl } from '../../utils/env'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
@@ -13,7 +13,9 @@ export default defineEventHandler(async (event) => {
env: {
webpQuality: 80,
refererConfigured: false,
siteBaseUrl: getSiteBaseUrl(event),
imageBaseUrl: getImageBaseUrl(event),
hideFolderInUrl: isHideFolderInUrl(event),
appVersion: config.appVersion as string
}
}
+29
View File
@@ -0,0 +1,29 @@
import { requireAdminAuth } from '../../utils/access'
import {
fetchLatestGitHubRelease,
isVersionNewer
} from '../../utils/app-version'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const config = useRuntimeConfig(event)
const currentVersion = String(config.appVersion ?? '')
const latest = await fetchLatestGitHubRelease()
if (!latest) {
return {
currentVersion,
latestVersion: null,
updateAvailable: false,
releaseUrl: null
}
}
return {
currentVersion,
latestVersion: latest.latestVersion,
updateAvailable: isVersionNewer(latest.latestVersion, currentVersion),
releaseUrl: latest.releaseUrl
}
})
+7
View File
@@ -0,0 +1,7 @@
import { shouldBlockImageHostRequest } from '../utils/host-isolation'
export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event)) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
})
+3 -1
View File
@@ -8,7 +8,9 @@ export default defineEventHandler(async (event) => {
const method = getMethod(event)
if (method !== 'GET' && method !== 'HEAD') return
const key = requestPathToImageKey(getRequestURL(event).pathname)
const key = requestPathToImageKey(getRequestURL(event).pathname, {
hideFolder: isHideFolderInUrl(event)
})
if (!key) return
return serveImageByKey(event, key)
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isVersionNewer, normalizeVersionTag, parseVersionParts } from './app-version'
describe('app-version', () => {
it('normalizes tag prefixes', () => {
expect(normalizeVersionTag('v1.1.2')).toBe('1.1.2')
expect(normalizeVersionTag('V2.0.0')).toBe('2.0.0')
})
it('parses dotted versions', () => {
expect(parseVersionParts('1.10.3')).toEqual([1, 10, 3])
})
it('detects newer versions', () => {
expect(isVersionNewer('1.2.0', '1.1.2')).toBe(true)
expect(isVersionNewer('1.1.2', '1.1.2')).toBe(false)
expect(isVersionNewer('1.1.1', '1.1.2')).toBe(false)
expect(isVersionNewer('2.0.0', '1.9.9')).toBe(true)
})
})
+77
View File
@@ -0,0 +1,77 @@
const GITHUB_REPO = 'O96u/PicHost'
const RELEASES_LATEST_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
const CACHE_TTL_MS = 60 * 60 * 1000
export interface LatestReleaseInfo {
latestVersion: string
releaseUrl: string
}
let cachedRelease: { fetchedAt: number, value: LatestReleaseInfo | null } | null = null
export function normalizeVersionTag(tag: string): string {
return tag.trim().replace(/^v/i, '')
}
export function parseVersionParts(version: string): number[] {
return normalizeVersionTag(version)
.split('.')
.map(part => Number.parseInt(part, 10))
.map(part => (Number.isFinite(part) ? part : 0))
}
export function isVersionNewer(candidate: string, current: string): boolean {
const a = parseVersionParts(candidate)
const b = parseVersionParts(current)
const length = Math.max(a.length, b.length)
for (let i = 0; i < length; i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0)
if (diff !== 0) return diff > 0
}
return false
}
export async function fetchLatestGitHubRelease(): Promise<LatestReleaseInfo | null> {
const now = Date.now()
if (cachedRelease && now - cachedRelease.fetchedAt < CACHE_TTL_MS) {
return cachedRelease.value
}
try {
const response = await fetch(RELEASES_LATEST_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'PicHost'
},
signal: AbortSignal.timeout(8000)
})
if (!response.ok) {
cachedRelease = { fetchedAt: now, value: null }
return null
}
const data = await response.json() as {
tag_name?: string
html_url?: string
}
const tag = typeof data.tag_name === 'string' ? data.tag_name : ''
const releaseUrl = typeof data.html_url === 'string' ? data.html_url : ''
if (!tag) {
cachedRelease = { fetchedAt: now, value: null }
return null
}
const value: LatestReleaseInfo = {
latestVersion: normalizeVersionTag(tag),
releaseUrl: releaseUrl || `https://github.com/${GITHUB_REPO}/releases/latest`
}
cachedRelease = { fetchedAt: now, value }
return value
} catch {
cachedRelease = { fetchedAt: now, value: null }
return null
}
}
+2
View File
@@ -373,6 +373,8 @@ export function deleteSessionsForUser(userId: number): void {
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_SITE_BASE_URL = 'site_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_ENABLED_AT = 'auto_delete_enabled_at'
+56 -2
View File
@@ -8,18 +8,23 @@ import {
getAutoDeleteDays,
getAutoDeleteDaysSource,
getRefererSource,
getSiteBaseUrl,
getSiteBaseUrlConfigured,
getSiteBaseUrlSource,
getWebpQuality,
getWebpQualitySource,
isApiUploadTokenEnvConfigured,
isAutoDeleteDaysEnvConfigured,
isRefererEnvConfigured,
isWebpQualityEnvConfigured
isWebpQualityEnvConfigured,
validateDomainSeparationPair
} from './env'
import {
getSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_API_UPLOAD_TOKEN,
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
generateApiUploadToken
} from './db'
@@ -34,9 +39,13 @@ vi.mock('./db', async (importOriginal) => {
})
vi.stubGlobal('useRuntimeConfig', () => ({
apiUploadToken: ''
apiUploadToken: '',
siteBaseUrl: '',
imageBaseUrl: ''
}))
vi.stubGlobal('getRequestURL', () => new URL('https://fallback.example.com'))
function mockEvent(): H3Event {
return {
node: { req: {}, res: {} },
@@ -201,3 +210,48 @@ describe('getAutoDeleteDays priority', () => {
expect(getAutoDeleteDaysSource()).toBe('default')
})
})
describe('getSiteBaseUrl priority', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.SITE_BASE_URL
})
it('prefers database over environment variable', () => {
process.env.SITE_BASE_URL = 'https://env.example.com'
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_SITE_BASE_URL ? 'https://db.example.com' : null
)
const event = mockEvent()
expect(getSiteBaseUrlConfigured(event)).toBe('https://db.example.com')
expect(getSiteBaseUrlSource(event)).toBe('db')
expect(getSiteBaseUrl(event)).toBe('https://db.example.com')
})
it('falls back to request origin when unset', () => {
vi.mocked(getSetting).mockReturnValue(null)
const event = mockEvent()
expect(getSiteBaseUrl(event)).toBe('https://fallback.example.com')
expect(getSiteBaseUrlSource(event)).toBe('none')
})
})
describe('validateDomainSeparationPair', () => {
it('rejects missing values', () => {
expect(validateDomainSeparationPair('', 'https://pic.example.com')).toContain('同时填写')
})
it('rejects identical hostnames', () => {
expect(validateDomainSeparationPair(
'https://same.example.com',
'https://same.example.com'
)).toContain('相同主机名')
})
it('accepts different hostnames', () => {
expect(validateDomainSeparationPair(
'https://admin.example.com',
'https://pic.example.com'
)).toBeNull()
})
})
+137 -2
View File
@@ -6,7 +6,9 @@ import {
SETTINGS_API_UPLOAD_TOKEN,
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_AUTO_DELETE_ENABLED_AT,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
isAllowRegistration
} from './db'
@@ -47,10 +49,27 @@ export function getAdminSecret(event: H3Event): string {
export type SettingSource = 'env' | 'db' | 'none'
export function normalizeImageBaseUrl(raw: string): string {
export function normalizeBaseUrl(raw: string): string {
return raw.trim().replace(/\/$/, '')
}
export function normalizeImageBaseUrl(raw: string): string {
return normalizeBaseUrl(raw)
}
export function normalizeSiteBaseUrl(raw: string): string {
return normalizeBaseUrl(raw)
}
export function hostnameFromBaseUrl(value: string): string | null {
if (!value) return null
try {
return new URL(value).hostname.toLowerCase()
} catch {
return null
}
}
export function getImageBaseUrlFromEnv(event: H3Event): string {
return normalizeImageBaseUrl(
getRuntimeEnv(event, 'IMAGE_BASE_URL', 'imageBaseUrl')
@@ -73,6 +92,45 @@ export function getImageBaseUrlConfigured(event: H3Event): string {
return getImageBaseUrlFromEnv(event)
}
export function getSiteBaseUrlFromEnv(event: H3Event): string {
return normalizeSiteBaseUrl(
getRuntimeEnv(event, 'SITE_BASE_URL', 'siteBaseUrl')
)
}
export function isSiteBaseUrlEnvConfigured(event: H3Event): boolean {
return getSiteBaseUrlFromEnv(event).length > 0
}
export function getSiteBaseUrlSource(event: H3Event): SettingSource {
if (getSetting(SETTINGS_SITE_BASE_URL) !== null) return 'db'
if (isSiteBaseUrlEnvConfigured(event)) return 'env'
return 'none'
}
export function getSiteBaseUrlConfigured(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_SITE_BASE_URL)
if (dbRaw !== null) return normalizeSiteBaseUrl(dbRaw)
return getSiteBaseUrlFromEnv(event)
}
export function getSiteBaseUrl(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_SITE_BASE_URL)
if (dbRaw !== null) {
const configured = normalizeSiteBaseUrl(dbRaw)
if (configured) return configured
} else {
const fromEnv = getSiteBaseUrlFromEnv(event)
if (fromEnv) return fromEnv
}
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
return requestUrl.origin
}
export function getImageBaseUrl(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL)
if (dbRaw !== null) {
@@ -91,6 +149,36 @@ export function getImageBaseUrl(event: H3Event): string {
return requestUrl.origin
}
export function isHideFolderInUrlFromEnv(): boolean {
try {
return process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase() === 'true'
} catch {
return false
}
}
export function isHideFolderInUrlEnvConfigured(): boolean {
try {
const raw = process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase()
return raw === 'true' || raw === 'false'
} catch {
return false
}
}
export function getHideFolderInUrlSource(): SettingSource {
if (getSetting(SETTINGS_HIDE_FOLDER_IN_URL) !== null) return 'db'
if (isHideFolderInUrlEnvConfigured()) return 'env'
return 'none'
}
/** 优先级:SQLite settings → 环境变量 → 默认 false */
export function isHideFolderInUrl(_event?: H3Event): boolean {
const dbRaw = getSetting(SETTINGS_HIDE_FOLDER_IN_URL)
if (dbRaw !== null) return dbRaw === 'true'
return isHideFolderInUrlFromEnv()
}
export function getApiUploadTokenFromEnv(event: H3Event): string {
return (
getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken')
@@ -294,7 +382,7 @@ export function isValidRefererHost(host: string): boolean {
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(host)
}
export function isValidImageBaseUrl(value: string): boolean {
export function isValidBaseUrl(value: string): boolean {
if (!value) return true
try {
const url = new URL(value)
@@ -304,13 +392,46 @@ export function isValidImageBaseUrl(value: string): boolean {
}
}
export function isValidImageBaseUrl(value: string): boolean {
return isValidBaseUrl(value)
}
export function isValidSiteBaseUrl(value: string): boolean {
return isValidBaseUrl(value)
}
export function validateDomainSeparationPair(
siteBaseUrl: string,
imageBaseUrl: string
): string | null {
if (!siteBaseUrl || !imageBaseUrl) {
return '启用域名分离时需同时填写网站域名与图片域名'
}
if (!isValidSiteBaseUrl(siteBaseUrl) || !isValidImageBaseUrl(imageBaseUrl)) {
return '域名需为 http(s) 地址,且末尾不加 /'
}
const siteHost = hostnameFromBaseUrl(siteBaseUrl)
const imageHost = hostnameFromBaseUrl(imageBaseUrl)
if (!siteHost || !imageHost) {
return '域名格式无效'
}
if (siteHost === imageHost) {
return '网站域名与图片域名不能使用相同主机名'
}
return null
}
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 autoDeleteDays = getAutoDeleteDays(event)
const appVersion = config.appVersion as string
const domainSeparation = isDomainSeparationActive(event)
const hideFolderInUrl = isHideFolderInUrl(event)
const hideFolderInUrlSource = getHideFolderInUrlSource()
return {
apiUploadToken: getApiUploadToken(event),
@@ -321,9 +442,15 @@ export function getSettingsPayload(event: H3Event) {
allowedRefererHosts,
refererSource: getRefererSource(),
refererEnvFallback: getRefererHostsFromEnv(),
siteBaseUrl,
siteBaseUrlConfigured: getSiteBaseUrlConfigured(event),
siteBaseUrlSource: getSiteBaseUrlSource(event),
imageBaseUrl,
imageBaseUrlConfigured: getImageBaseUrlConfigured(event),
imageBaseUrlSource: getImageBaseUrlSource(event),
domainSeparation,
hideFolderInUrl,
hideFolderInUrlSource,
autoDeleteDays,
autoDeleteDaysSource: getAutoDeleteDaysSource(),
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
@@ -332,8 +459,16 @@ export function getSettingsPayload(event: H3Event) {
env: {
webpQuality,
refererConfigured: allowedRefererHosts.length > 0,
siteBaseUrl,
imageBaseUrl,
hideFolderInUrl,
appVersion
}
}
}
export function isDomainSeparationActive(event: H3Event): boolean {
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
return Boolean(siteHost && imageHost && siteHost !== imageHost)
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
import { shouldBlockImageHostRequest } from './host-isolation'
function mockEvent(): H3Event {
return {
node: { req: {}, res: {} },
context: {}
} as H3Event
}
describe('shouldBlockImageHostRequest', () => {
const imageHost = 'pic.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: false,
requestHost: imageHost,
method: 'GET',
pathname: '/settings'
})).toBe(false)
})
it('blocks non-image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: imageHost,
method: 'GET',
pathname: '/settings'
})).toBe(true)
})
it('allows image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: imageHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
it('blocks POST on image host even for image paths', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: imageHost,
method: 'POST',
pathname: '/images/2026/08/demo.webp'
})).toBe(true)
})
it('does not block site host admin paths', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: siteHost,
method: 'GET',
pathname: '/api/images'
})).toBe(false)
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { H3Event } from 'h3'
import {
getImageBaseUrlConfigured,
getSiteBaseUrlConfigured,
hostnameFromBaseUrl,
isDomainSeparationActive,
isHideFolderInUrl
} from './env'
import { requestPathToImageKey } from './serve-image'
export function getConfiguredSiteHost(event: H3Event): string | null {
return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
}
export function getConfiguredImageHost(event: H3Event): string | null {
return hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
}
export function shouldBlockImageHostRequest(
event: H3Event,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
imageHost?: string | null
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
if (!separationActive) return false
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()
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 ?? ''
return requestPathToImageKey(pathname, {
hideFolder: isHideFolderInUrl(event)
}) === null
}
+25 -1
View File
@@ -2,7 +2,8 @@ import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { getDb } from './db'
import { getDataDir } from './data-dir'
import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key'
import { DEFAULT_FOLDER, isValidFolderName, validateImageKey } from './image-key'
import { isHiddenImageDatePath } from './image-public-path'
import {
ensureDefaultBackends,
ensureStorageSchema,
@@ -114,6 +115,29 @@ export function getImageIndexAsStored(key: string): StoredImage | null {
return row ? rowToStoredImage(row) : null
}
/** 隐藏目录 URL(YYYY/MM/file)反查完整 key;多目录同名时优先 images */
export function findImageKeyByDatePath(datePath: string): string | null {
if (!isHiddenImageDatePath(datePath)) return null
ensureStorageSchema()
const suffix = `/${datePath}`
const rows = getDb().prepare(`
SELECT key FROM images WHERE key LIKE ?
`).all(`%${suffix}`) as Array<{ key: string }>
if (rows.length === 0) {
const fallback = `${DEFAULT_FOLDER}/${datePath}`
return validateImageKey(fallback) ? fallback : null
}
if (rows.length === 1) return rows[0]!.key
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${datePath}`)
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 })
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { 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')
})
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')
})
})
describe('isHiddenImageDatePath', () => {
it('accepts YYYY/MM/file paths', () => {
expect(isHiddenImageDatePath('2026/08/demo.webp')).toBe(true)
})
it('rejects folder-prefixed paths', () => {
expect(isHiddenImageDatePath('images/2026/08/demo.webp')).toBe(false)
})
})
+13
View File
@@ -0,0 +1,13 @@
/** 对外 URL 路径:可选去掉 key 的首段目录(如 images/) */
export function toPublicImagePath(key: string, hideFolder: boolean): string {
if (!hideFolder) return key
const slash = key.indexOf('/')
return slash > 0 ? key.slice(slash + 1) : key
}
const HIDDEN_DATE_PATH_PATTERN
= /^\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
export function isHiddenImageDatePath(path: string): boolean {
return HIDDEN_DATE_PATH_PATTERN.test(path)
}
+25 -10
View File
@@ -2,7 +2,8 @@ import type { H3Event } from 'h3'
import type { ImageItem } from '~/types/image'
import type { AllowedMimeType } from './constants'
import type { StoredImage } from './storage'
import { getImageBaseUrl } from './env'
import { getImageBaseUrl, isHideFolderInUrl } from './env'
import { toPublicImagePath } from './image-public-path'
import { getActiveBackendRow } from './storage/resolver'
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
import type { StorageBackendType, StorageBackendRow } from './storage/types'
@@ -75,19 +76,30 @@ export function mapStoredImageToItem(
return item
}
export function buildImageUrl(baseUrl: string, key: string): string {
export function buildImageUrl(
baseUrl: string,
key: string,
hideFolder = false
): string {
const normalizedBase = baseUrl.replace(/\/$/, '')
return `${normalizedBase}/${key}`
const path = toPublicImagePath(key, hideFolder)
return `${normalizedBase}/${path}`
}
export function buildPublicImageUrl(backendRow: StorageBackendRow, key: string): string {
export function buildPublicImageUrl(
backendRow: StorageBackendRow,
key: string,
hideFolder = false
): string {
const base = backendRow.public_url.replace(/\/$/, '')
const path = toPublicImagePath(key, hideFolder)
if (backendRow.type === 's3') {
const config = parseS3Config(backendRow.config_json)
const objectKey = buildObjectKey(config?.prefix, key)
return `${base}/${objectKey}`
const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey
return `${base}/${publicPath}`
}
return `${base}/${key}`
return `${base}/${path}`
}
export function resolveImageUrl(
@@ -95,14 +107,17 @@ export function resolveImageUrl(
key: string,
backendId?: string
): string {
const hideFolder = isHideFolderInUrl(event)
const envConfig = getStorageBackendFromEnv()
if (envConfig && (!backendId || backendId === 's3-primary')) {
if (envConfig.servingMode === 'public' && envConfig.publicUrl) {
const prefix = envConfig.config.prefix as string | undefined
const objectKey = buildObjectKey(prefix, key)
return `${envConfig.publicUrl.replace(/\/$/, '')}/${objectKey}`
const base = envConfig.publicUrl.replace(/\/$/, '')
const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey
return `${base}/${publicPath}`
}
return buildImageUrl(getImageBaseUrl(event), key)
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
}
const backendRow = backendId
@@ -110,10 +125,10 @@ export function resolveImageUrl(
: getActiveBackendRow()
if (backendRow?.serving_mode === 'public' && backendRow.public_url) {
return buildPublicImageUrl(backendRow, key)
return buildPublicImageUrl(backendRow, key, hideFolder)
}
return buildImageUrl(getImageBaseUrl(event), key)
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
}
export function buildMarkdown(url: string, alt: string): string {
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { requestPathToImageKey } from './serve-image'
describe('requestPathToImageKey hide folder', () => {
it('resolves hidden date path to default folder key', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: true }))
.toBe('images/2026/08/demo.webp')
})
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')
})
it('does not resolve hidden path when hide folder is disabled', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false }))
.toBeNull()
})
})
+26 -4
View File
@@ -1,6 +1,12 @@
import type { H3Event } from 'h3'
import { IMAGE_CACHE_CONTROL } from './constants'
import { getAllowedRefererHostsRaw } from './env'
import {
getAllowedRefererHostsRaw,
getImageBaseUrlConfigured,
getSiteBaseUrlConfigured,
hostnameFromBaseUrl
} from './env'
import { findImageKeyByDatePath } from './image-index'
import { validateImageKey } from './image-key'
import { buildPublicImageUrl } from './image-response'
import { createImageStream, headImage } from './storage'
@@ -31,6 +37,12 @@ function getAllowedHosts(event: H3Event): Set<string> {
})
hosts.add(requestUrl.hostname.toLowerCase())
// 双域名分离时,管理域页面会引用图片域直链,需互相放行 Referer
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
if (siteHost) hosts.add(siteHost)
if (imageHost) hosts.add(imageHost)
return hosts
}
@@ -63,9 +75,19 @@ function parseRangeHeader(
return { start, end: safeEnd }
}
export function requestPathToImageKey(pathname: string): string | null {
const key = decodeURIComponent(pathname).replace(/^\/+/, '')
return validateImageKey(key) ? key : null
export function requestPathToImageKey(
pathname: string,
options?: { hideFolder?: boolean }
): string | null {
const raw = decodeURIComponent(pathname).replace(/^\/+/, '')
if (validateImageKey(raw)) return raw
if (options?.hideFolder) {
const resolved = findImageKeyByDatePath(raw)
if (resolved && validateImageKey(resolved)) return resolved
}
return null
}
export async function serveImageByKey(event: H3Event, key: string) {