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
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:
1 parent
55effa204e
commit
d8c1303c32
41 files changed
+2170
-299
No files matched your search
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { shouldBlockImageHostRequest } from '../utils/host-isolation'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
if (shouldBlockImageHostRequest(event)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user