服务器配置

This commit is contained in:
tianmo committed 2026-10-17 16:09:31 +08:00
1 parent a1b4a63552
commit fa7c8244b0
33 files changed
+1882 -363

No files matched your search

+111
View File
@@ -9,6 +9,12 @@ import {
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_VIDEO_BASE_URL,
SETTINGS_FILE_BASE_URL,
SETTINGS_DOMAIN_SEPARATION,
SETTINGS_IMAGE_DOMAIN_SEPARATED,
SETTINGS_VIDEO_DOMAIN_SEPARATED,
SETTINGS_FILE_DOMAIN_SEPARATED,
SETTINGS_WEBP_QUALITY,
SETTINGS_LOGIN_VERIFICATION_METHOD,
SETTINGS_TURNSTILE_SITE_KEY,
@@ -28,6 +34,8 @@ import {
parseLoginVerificationMethod,
MAX_AUTO_DELETE_DAYS,
normalizeImageBaseUrl,
normalizeVideoBaseUrl,
normalizeFileBaseUrl,
normalizeRefererHosts,
normalizeSiteBaseUrl,
parseAutoDeleteDays,
@@ -38,6 +46,8 @@ import {
} from '../../utils/env'
import {
normalizeAllowedMimeTypes,
normalizeAllowedVideoExtensions,
normalizeForbiddenFileExtensions,
parseLoginRateMax,
parseLoginRateWindowMinutes,
parseUploadMaxFileSizeMb,
@@ -52,6 +62,11 @@ interface SettingsPatchBody {
allowedRefererHosts?: unknown
siteBaseUrl?: unknown
imageBaseUrl?: unknown
videoBaseUrl?: unknown
fileBaseUrl?: unknown
imageDomainSeparated?: unknown
videoDomainSeparated?: unknown
fileDomainSeparated?: unknown
domainSeparation?: unknown
hideFolderInUrl?: unknown
storageUseDatePath?: unknown
@@ -64,6 +79,8 @@ interface SettingsPatchBody {
capSecret?: unknown
uploadMaxFileSizeMb?: unknown
allowedMimeTypes?: unknown
allowedVideoExtensions?: unknown
forbiddenFileExtensions?: unknown
uploadRateIpMax?: unknown
uploadRateTokenMax?: unknown
uploadRateWindowMinutes?: unknown
@@ -109,8 +126,18 @@ export default defineEventHandler(async (event) => {
const existingSite = normalizeSiteBaseUrl(getSetting(SETTINGS_SITE_BASE_URL) ?? '')
const existingImage = normalizeImageBaseUrl(getSetting(SETTINGS_IMAGE_BASE_URL) ?? '')
const existingVideo = normalizeVideoBaseUrl(getSetting(SETTINGS_VIDEO_BASE_URL) ?? '')
const existingFile = normalizeFileBaseUrl(getSetting(SETTINGS_FILE_BASE_URL) ?? '')
const existingImageSeparated = getSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED) === 'true'
const existingVideoSeparated = getSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED) === 'true'
const existingFileSeparated = getSetting(SETTINGS_FILE_DOMAIN_SEPARATED) === 'true'
let nextSite = existingSite
let nextImage = existingImage
let nextVideo = existingVideo
let nextFile = existingFile
let nextImageSeparated = existingImageSeparated
let nextVideoSeparated = existingVideoSeparated
let nextFileSeparated = existingFileSeparated
if (body.siteBaseUrl !== undefined) {
const configured = normalizeSiteBaseUrl(
@@ -142,6 +169,36 @@ export default defineEventHandler(async (event) => {
nextImage = configured
}
if (body.videoBaseUrl !== undefined) {
const configured = normalizeVideoBaseUrl(
typeof body.videoBaseUrl === 'string' ? body.videoBaseUrl : ''
)
if (configured && !isValidImageBaseUrl(configured)) {
createApiError(event, 'INVALID_REQUEST', '视频域名需为 http(s) 地址,且末尾不加 /', 400)
}
nextVideo = configured
}
if (body.fileBaseUrl !== undefined) {
const configured = normalizeFileBaseUrl(
typeof body.fileBaseUrl === 'string' ? body.fileBaseUrl : ''
)
if (configured && !isValidImageBaseUrl(configured)) {
createApiError(event, 'INVALID_REQUEST', '文件域名需为 http(s) 地址,且末尾不加 /', 400)
}
nextFile = configured
}
if (body.imageDomainSeparated !== undefined) {
nextImageSeparated = Boolean(body.imageDomainSeparated)
}
if (body.videoDomainSeparated !== undefined) {
nextVideoSeparated = Boolean(body.videoDomainSeparated)
}
if (body.fileDomainSeparated !== undefined) {
nextFileSeparated = Boolean(body.fileDomainSeparated)
}
const domainSeparationProvided = body.domainSeparation !== undefined
const wantSeparation = domainSeparationProvided
? Boolean(body.domainSeparation)
@@ -150,19 +207,38 @@ export default defineEventHandler(async (event) => {
const patchError = validateSettingsDomainPatch({
existingSite,
existingImage,
existingVideo,
existingFile,
nextSite,
nextImage,
nextVideo,
nextFile,
wantSeparation,
siteBaseUrlProvided: body.siteBaseUrl !== undefined
})
if (patchError) {
createApiError(event, 'INVALID_REQUEST', patchError, 400)
}
if (wantSeparation === true) {
if (nextImageSeparated && !nextImage) {
createApiError(event, 'INVALID_REQUEST', '开启图片独立域名时必须填写图片域名', 400)
}
if (nextVideoSeparated && !nextVideo) {
createApiError(event, 'INVALID_REQUEST', '开启视频独立域名时必须填写视频域名', 400)
}
if (nextFileSeparated && !nextFile) {
createApiError(event, 'INVALID_REQUEST', '开启文件独立域名时必须填写文件域名', 400)
}
}
if (wantSeparation === false) {
nextSite = ''
}
if (body.domainSeparation !== undefined) {
setSetting(SETTINGS_DOMAIN_SEPARATION, wantSeparation ? 'true' : 'false')
}
if (body.siteBaseUrl !== undefined || body.domainSeparation !== undefined) {
setSetting(SETTINGS_SITE_BASE_URL, nextSite)
}
@@ -170,6 +246,21 @@ export default defineEventHandler(async (event) => {
if (body.imageBaseUrl !== undefined) {
setSetting(SETTINGS_IMAGE_BASE_URL, nextImage)
}
if (body.videoBaseUrl !== undefined || body.domainSeparation !== undefined) {
setSetting(SETTINGS_VIDEO_BASE_URL, nextVideo)
}
if (body.fileBaseUrl !== undefined || body.domainSeparation !== undefined) {
setSetting(SETTINGS_FILE_BASE_URL, nextFile)
}
if (body.imageDomainSeparated !== undefined) {
setSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED, nextImageSeparated ? 'true' : 'false')
}
if (body.videoDomainSeparated !== undefined) {
setSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED, nextVideoSeparated ? 'true' : 'false')
}
if (body.fileDomainSeparated !== undefined) {
setSetting(SETTINGS_FILE_DOMAIN_SEPARATED, nextFileSeparated ? 'true' : 'false')
}
if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
@@ -289,6 +380,26 @@ export default defineEventHandler(async (event) => {
setUploadPolicySettings({ allowedMimeTypes: normalized })
}
if (body.allowedVideoExtensions !== undefined) {
const raw = Array.isArray(body.allowedVideoExtensions)
? body.allowedVideoExtensions.map(item => String(item))
: String(body.allowedVideoExtensions).split(',')
const normalized = normalizeAllowedVideoExtensions(raw.join(','))
if (normalized.length === 0) {
createApiError(event, 'INVALID_REQUEST', '至少选择一种允许上传的视频格式', 400)
}
setUploadPolicySettings({ allowedVideoExtensions: normalized })
}
if (body.forbiddenFileExtensions !== undefined) {
const raw = Array.isArray(body.forbiddenFileExtensions)
? body.forbiddenFileExtensions.map(item => String(item))
: String(body.forbiddenFileExtensions).split(',')
setUploadPolicySettings({
forbiddenFileExtensions: normalizeForbiddenFileExtensions(raw.join(','))
})
}
if (body.uploadRateIpMax !== undefined) {
const value = parseUploadRateIpMax(String(body.uploadRateIpMax))
if (value === null) {
+32 -2
View File
@@ -4,10 +4,23 @@ import {
createUserAccount,
isInitialized
} from '../utils/auth'
import { setAllowRegistration, setSetting, SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL } from '../utils/db'
import {
setAllowRegistration,
setSetting,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_VIDEO_BASE_URL,
SETTINGS_FILE_BASE_URL,
SETTINGS_DOMAIN_SEPARATION,
SETTINGS_IMAGE_DOMAIN_SEPARATED,
SETTINGS_VIDEO_DOMAIN_SEPARATED,
SETTINGS_FILE_DOMAIN_SEPARATED
} from '../utils/db'
import {
normalizeFileBaseUrl,
normalizeImageBaseUrl,
normalizeSiteBaseUrl,
normalizeVideoBaseUrl,
validateDomainSeparationPair
} from '../utils/env'
import { clientIp, logInfo } from '../utils/logger'
@@ -19,6 +32,8 @@ interface SetupBody {
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
videoBaseUrl?: string
fileBaseUrl?: string
}
export default defineEventHandler(async (event) => {
@@ -42,12 +57,27 @@ export default defineEventHandler(async (event) => {
if (body?.domainSeparation) {
const siteBaseUrl = normalizeSiteBaseUrl(body.siteBaseUrl ?? '')
const imageBaseUrl = normalizeImageBaseUrl(body.imageBaseUrl ?? '')
const pairError = validateDomainSeparationPair(siteBaseUrl, imageBaseUrl)
const videoBaseUrl = normalizeVideoBaseUrl(body.videoBaseUrl ?? '')
const fileBaseUrl = normalizeFileBaseUrl(body.fileBaseUrl ?? '')
const pairError = validateDomainSeparationPair(
siteBaseUrl,
imageBaseUrl,
videoBaseUrl,
fileBaseUrl
)
if (pairError) {
createApiError(event, 'INVALID_REQUEST', pairError, 400)
}
setSetting(SETTINGS_SITE_BASE_URL, siteBaseUrl)
setSetting(SETTINGS_IMAGE_BASE_URL, imageBaseUrl)
setSetting(SETTINGS_VIDEO_BASE_URL, videoBaseUrl)
setSetting(SETTINGS_FILE_BASE_URL, fileBaseUrl)
setSetting(SETTINGS_DOMAIN_SEPARATION, 'true')
setSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED, imageBaseUrl ? 'true' : 'false')
setSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED, videoBaseUrl ? 'true' : 'false')
setSetting(SETTINGS_FILE_DOMAIN_SEPARATED, fileBaseUrl ? 'true' : 'false')
} else {
setSetting(SETTINGS_DOMAIN_SEPARATION, 'false')
}
await createSession(event, user.id)
+14
View File
@@ -0,0 +1,14 @@
import {
getAllowedMimeTypesList,
getAllowedVideoExtensions,
getForbiddenFileExtensions
} from '../utils/upload-policy'
export default defineEventHandler((event) => {
setResponseHeader(event, 'Cache-Control', 'no-store')
return {
allowedMimeTypes: getAllowedMimeTypesList(),
allowedVideoExtensions: getAllowedVideoExtensions(),
forbiddenFileExtensions: getForbiddenFileExtensions()
}
})
+9 -1
View File
@@ -1,6 +1,12 @@
import { requireUserAuth } from '../../utils/access'
import { ensureUserApiToken } from '../../utils/db'
import { getImageBaseUrl, getSiteBaseUrl, isHideFolderInUrl } from '../../utils/env'
import {
getFileBaseUrl,
getImageBaseUrl,
getSiteBaseUrl,
getVideoBaseUrl,
isHideFolderInUrl
} from '../../utils/env'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
@@ -15,6 +21,8 @@ export default defineEventHandler(async (event) => {
refererConfigured: false,
siteBaseUrl: getSiteBaseUrl(event),
imageBaseUrl: getImageBaseUrl(event),
videoBaseUrl: getVideoBaseUrl(event),
fileBaseUrl: getFileBaseUrl(event),
hideFolderInUrl: isHideFolderInUrl(event),
appVersion: config.appVersion as string
}
+26 -6
View File
@@ -4,21 +4,29 @@ import { DatabaseSync } from 'node:sqlite'
const SITE_KEY = 'site_base_url'
const IMAGE_KEY = 'image_base_url'
const VIDEO_KEY = 'video_base_url'
const FILE_KEY = 'file_base_url'
const DOMAIN_SEPARATION_KEY = 'domain_separation_enabled'
const args = process.argv.slice(2)
const siteOnly = args.includes('--site')
const imageOnly = args.includes('--image')
const videoOnly = args.includes('--video')
const fileOnly = args.includes('--file')
const selectiveFlags = [siteOnly, imageOnly, videoOnly, fileOnly].filter(Boolean).length
if (args.some(arg => arg.startsWith('--') && arg !== '--site' && arg !== '--image')) {
console.error('Usage: clear-domains [--site] [--image]')
console.error(' (no flags) Clear both site and image URLs from settings')
if (args.some(arg => !['--site', '--image', '--video', '--file'].includes(arg))) {
console.error('Usage: clear-domains [--site] [--image] [--video] [--file]')
console.error(' (no flags) Clear all domain URLs from settings')
console.error(' --site Clear site URL only')
console.error(' --image Clear image URL only')
console.error(' --video Clear video URL only')
console.error(' --file Clear file URL only')
process.exit(1)
}
if (siteOnly && imageOnly) {
console.error('Usage: specify at most one of --site or --image')
if (selectiveFlags > 1) {
console.error('Usage: specify at most one selective domain flag')
process.exit(1)
}
@@ -37,12 +45,18 @@ function readSetting(key) {
const beforeSite = readSetting(SITE_KEY)
const beforeImage = readSetting(IMAGE_KEY)
const beforeVideo = readSetting(VIDEO_KEY)
const beforeFile = readSetting(FILE_KEY)
const keysToClear = siteOnly
? [SITE_KEY]
: imageOnly
? [IMAGE_KEY]
: [SITE_KEY, IMAGE_KEY]
: videoOnly
? [VIDEO_KEY]
: fileOnly
? [FILE_KEY]
: [SITE_KEY, IMAGE_KEY, VIDEO_KEY, FILE_KEY, DOMAIN_SEPARATION_KEY]
for (const key of keysToClear) {
db.prepare('DELETE FROM settings WHERE key = ?').run(key)
@@ -57,4 +71,10 @@ if (keysToClear.includes(SITE_KEY)) {
if (keysToClear.includes(IMAGE_KEY)) {
console.log(` image_base_url: ${beforeImage ?? '(unset)'} → (removed)`)
}
if (keysToClear.includes(VIDEO_KEY)) {
console.log(` video_base_url: ${beforeVideo ?? '(unset)'} → (removed)`)
}
if (keysToClear.includes(FILE_KEY)) {
console.log(` file_base_url: ${beforeFile ?? '(unset)'} → (removed)`)
}
console.log('[PicHost] Restart is not required. Refresh the admin page or use your previous access URL.')
+4
View File
@@ -1,5 +1,7 @@
import {
shouldBlockImageHostRequest,
shouldBlockVideoHostRequest,
shouldBlockFileHostRequest,
shouldBlockSiteHostImageRequest,
shouldBlockUnknownHostRequest
} from '../utils/host-isolation'
@@ -10,6 +12,8 @@ export default defineEventHandler((event) => {
if (
shouldBlockUnknownHostRequest(event)
|| shouldBlockImageHostRequest(event)
|| shouldBlockVideoHostRequest(event)
|| shouldBlockFileHostRequest(event)
|| shouldBlockSiteHostImageRequest(event)
) {
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
+2
View File
@@ -41,6 +41,8 @@ export const VIDEO_EXTENSIONS = [
'mpeg'
] as const
export type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
/** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */
export const FILE_CACHE_CONTROL
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
+6
View File
@@ -509,6 +509,12 @@ 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_VIDEO_BASE_URL = 'video_base_url'
export const SETTINGS_FILE_BASE_URL = 'file_base_url'
export const SETTINGS_DOMAIN_SEPARATION = 'domain_separation_enabled'
export const SETTINGS_IMAGE_DOMAIN_SEPARATED = 'image_domain_separated'
export const SETTINGS_VIDEO_DOMAIN_SEPARATED = 'video_domain_separated'
export const SETTINGS_FILE_DOMAIN_SEPARATED = 'file_domain_separated'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
+20 -2
View File
@@ -244,11 +244,11 @@ describe('validateDomainSeparationPair', () => {
expect(validateDomainSeparationPair('', 'https://pic.example.com')).toContain('同时填写')
})
it('rejects identical hostnames', () => {
it('allows identical hostnames for compatible multi-role hosting', () => {
expect(validateDomainSeparationPair(
'https://same.example.com',
'https://same.example.com'
)).toContain('相同主机名')
)).toBeNull()
})
it('accepts different hostnames', () => {
@@ -257,6 +257,24 @@ describe('validateDomainSeparationPair', () => {
'https://pic.example.com'
)).toBeNull()
})
it('accepts distinct image, video and file hostnames', () => {
expect(validateDomainSeparationPair(
'https://admin.example.com',
'https://pic.example.com',
'https://video.example.com',
'https://file.example.com'
)).toBeNull()
})
it('allows duplicate media hostnames', () => {
expect(validateDomainSeparationPair(
'https://admin.example.com',
'https://pic.example.com',
'https://pic.example.com',
'https://file.example.com'
)).toBeNull()
})
})
describe('validateSettingsDomainPatch', () => {
+157 -11
View File
@@ -10,6 +10,12 @@ import {
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_VIDEO_BASE_URL,
SETTINGS_FILE_BASE_URL,
SETTINGS_DOMAIN_SEPARATION,
SETTINGS_IMAGE_DOMAIN_SEPARATED,
SETTINGS_VIDEO_DOMAIN_SEPARATED,
SETTINGS_FILE_DOMAIN_SEPARATED,
SETTINGS_FILE_BED_ACCESS,
SETTINGS_WEBP_QUALITY,
SETTINGS_LOGIN_VERIFICATION_METHOD,
@@ -84,6 +90,14 @@ export function normalizeSiteBaseUrl(raw: string): string {
return normalizeBaseUrl(raw)
}
export function normalizeVideoBaseUrl(raw: string): string {
return normalizeBaseUrl(raw)
}
export function normalizeFileBaseUrl(raw: string): string {
return normalizeBaseUrl(raw)
}
export function hostnameFromBaseUrl(value: string): string | null {
if (!value) return null
try {
@@ -137,6 +151,38 @@ export function getSiteBaseUrlConfigured(event: H3Event): string {
return getSiteBaseUrlFromEnv(event)
}
export function getVideoBaseUrlFromEnv(event: H3Event): string {
return normalizeVideoBaseUrl(getRuntimeEnv(event, 'VIDEO_BASE_URL', 'videoBaseUrl'))
}
export function getVideoBaseUrlSource(event: H3Event): SettingSource {
if (getSetting(SETTINGS_VIDEO_BASE_URL) !== null) return 'db'
if (getVideoBaseUrlFromEnv(event)) return 'env'
return 'none'
}
export function getVideoBaseUrlConfigured(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_VIDEO_BASE_URL)
if (dbRaw !== null) return normalizeVideoBaseUrl(dbRaw)
return getVideoBaseUrlFromEnv(event)
}
export function getFileBaseUrlFromEnv(event: H3Event): string {
return normalizeFileBaseUrl(getRuntimeEnv(event, 'FILE_BASE_URL', 'fileBaseUrl'))
}
export function getFileBaseUrlSource(event: H3Event): SettingSource {
if (getSetting(SETTINGS_FILE_BASE_URL) !== null) return 'db'
if (getFileBaseUrlFromEnv(event)) return 'env'
return 'none'
}
export function getFileBaseUrlConfigured(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_FILE_BASE_URL)
if (dbRaw !== null) return normalizeFileBaseUrl(dbRaw)
return getFileBaseUrlFromEnv(event)
}
export function getSiteBaseUrl(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_SITE_BASE_URL)
if (dbRaw !== null) {
@@ -155,6 +201,15 @@ export function getSiteBaseUrl(event: H3Event): string {
}
export function getImageBaseUrl(event: H3Event): string {
if (isDomainSeparationActive(event) && !isImageDomainSeparated(event)) {
return getSiteBaseUrl(event)
}
if (
getSetting(SETTINGS_DOMAIN_SEPARATION) === 'false'
&& getSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED) === 'false'
) {
return getSiteBaseUrl(event)
}
const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL)
if (dbRaw !== null) {
const configured = normalizeImageBaseUrl(dbRaw)
@@ -172,6 +227,64 @@ export function getImageBaseUrl(event: H3Event): string {
return requestUrl.origin
}
export function getVideoBaseUrl(event: H3Event): string {
if (isDomainSeparationActive(event) && !isVideoDomainSeparated(event)) {
return getSiteBaseUrl(event)
}
if (
getSetting(SETTINGS_DOMAIN_SEPARATION) === 'false'
&& getSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED) === 'false'
) {
return getSiteBaseUrl(event)
}
return getVideoBaseUrlConfigured(event) || getImageBaseUrl(event)
}
export function getFileBaseUrl(event: H3Event): string {
if (isDomainSeparationActive(event) && !isFileDomainSeparated(event)) {
return getSiteBaseUrl(event)
}
if (
getSetting(SETTINGS_DOMAIN_SEPARATION) === 'false'
&& getSetting(SETTINGS_FILE_DOMAIN_SEPARATED) === 'false'
) {
return getSiteBaseUrl(event)
}
return getFileBaseUrlConfigured(event) || getImageBaseUrl(event)
}
export function getMediaBaseUrl(event: H3Event, key: string): string {
if (key.startsWith('videos/')) return getVideoBaseUrl(event)
if (key.startsWith('files/')) return getFileBaseUrl(event)
return getImageBaseUrl(event)
}
function parseStoredBoolean(raw: string | null): boolean | null {
if (raw === null) return null
return raw === 'true'
}
export function isImageDomainSeparated(event: H3Event): boolean {
if (!isDomainSeparationActive(event)) return false
const explicit = parseStoredBoolean(getSetting(SETTINGS_IMAGE_DOMAIN_SEPARATED))
if (explicit !== null) return explicit
return Boolean(getImageBaseUrlConfigured(event))
}
export function isVideoDomainSeparated(event: H3Event): boolean {
if (!isDomainSeparationActive(event)) return false
const explicit = parseStoredBoolean(getSetting(SETTINGS_VIDEO_DOMAIN_SEPARATED))
if (explicit !== null) return explicit
return Boolean(getVideoBaseUrlConfigured(event))
}
export function isFileDomainSeparated(event: H3Event): boolean {
if (!isDomainSeparationActive(event)) return false
const explicit = parseStoredBoolean(getSetting(SETTINGS_FILE_DOMAIN_SEPARATED))
if (explicit !== null) return explicit
return Boolean(getFileBaseUrlConfigured(event))
}
export function isHideFolderInUrlFromEnv(): boolean {
try {
return process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase() === 'true'
@@ -555,26 +668,32 @@ export function isValidSiteBaseUrl(value: string): boolean {
export function validateDomainSeparationPair(
siteBaseUrl: string,
imageBaseUrl: string
imageBaseUrl: string,
videoBaseUrl = '',
fileBaseUrl = ''
): string | null {
if (!siteBaseUrl || !imageBaseUrl) {
return '启用域名分离时需同时填写网站域名与图片域名'
return '启用域名分离时需同时填写后台域名与图片域名'
}
if (!isValidSiteBaseUrl(siteBaseUrl) || !isValidImageBaseUrl(imageBaseUrl)) {
if (
!isValidSiteBaseUrl(siteBaseUrl)
|| !isValidImageBaseUrl(imageBaseUrl)
|| (videoBaseUrl && !isValidImageBaseUrl(videoBaseUrl))
|| (fileBaseUrl && !isValidImageBaseUrl(fileBaseUrl))
) {
return '域名需为 http(s) 地址,且末尾不加 /'
}
const siteHost = hostnameFromBaseUrl(siteBaseUrl)
const imageHost = hostnameFromBaseUrl(imageBaseUrl)
if (!siteHost || !imageHost) {
const videoHost = hostnameFromBaseUrl(videoBaseUrl)
const fileHost = hostnameFromBaseUrl(fileBaseUrl)
if (!siteHost || !imageHost || (videoBaseUrl && !videoHost) || (fileBaseUrl && !fileHost)) {
return '域名格式无效'
}
if (siteHost === imageHost) {
return '网站域名与图片域名不能使用相同主机名'
}
return null
}
export type RequestHostRole = 'site' | 'image' | 'unknown' | 'single'
export type RequestHostRole = 'site' | 'image' | 'video' | 'file' | 'unknown' | 'single'
export function getIsolationRequestHost(event: H3Event): string {
const requestUrl = getRequestURL(event, {
@@ -587,8 +706,12 @@ export function getIsolationRequestHost(event: H3Event): string {
export function validateSettingsDomainPatch(input: {
existingSite: string
existingImage: string
existingVideo?: string
existingFile?: string
nextSite: string
nextImage: string
nextVideo?: string
nextFile?: string
wantSeparation: boolean | null
siteBaseUrlProvided: boolean
}): string | null {
@@ -597,6 +720,8 @@ export function validateSettingsDomainPatch(input: {
existingImage,
nextSite,
nextImage,
nextVideo = '',
nextFile = '',
wantSeparation,
siteBaseUrlProvided
} = input
@@ -609,9 +734,9 @@ export function validateSettingsDomainPatch(input: {
if (wantSeparation === true) {
if (!nextSite || !nextImage) {
return '启用域名分离时需同时填写网站域名与图片域名'
return '启用域名分离时需同时填写后台域名与图片域名'
}
return validateDomainSeparationPair(nextSite, nextImage)
return validateDomainSeparationPair(nextSite, nextImage, nextVideo, nextFile)
}
if (wantSeparation === false) {
@@ -623,7 +748,7 @@ export function validateSettingsDomainPatch(input: {
}
if (nextSite && nextImage) {
return validateDomainSeparationPair(nextSite, nextImage)
return validateDomainSeparationPair(nextSite, nextImage, nextVideo, nextFile)
}
return null
@@ -647,12 +772,18 @@ export function getRequestRuntime(event: H3Event) {
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
const videoHost = hostnameFromBaseUrl(getVideoBaseUrl(event))
const fileHost = hostnameFromBaseUrl(getFileBaseUrl(event))
let hostRole: RequestHostRole = 'unknown'
if (currentHost === siteHost) {
hostRole = 'site'
} else if (currentHost === imageHost) {
hostRole = 'image'
} else if (currentHost === videoHost) {
hostRole = 'video'
} else if (currentHost === fileHost) {
hostRole = 'file'
}
return {
@@ -668,8 +799,12 @@ export function getSettingsPayload(event: H3Event) {
const allowedRefererHosts = getAllowedRefererHostsRaw(event)
const siteBaseUrlConfigured = getSiteBaseUrlConfigured(event)
const imageBaseUrlConfigured = getImageBaseUrlConfigured(event)
const videoBaseUrlConfigured = getVideoBaseUrlConfigured(event)
const fileBaseUrlConfigured = getFileBaseUrlConfigured(event)
const effectiveSiteBaseUrl = getSiteBaseUrl(event)
const effectiveImageBaseUrl = getImageBaseUrl(event)
const effectiveVideoBaseUrl = getVideoBaseUrl(event)
const effectiveFileBaseUrl = getFileBaseUrl(event)
const autoDeleteDays = getAutoDeleteDays(event)
const appVersion = config.appVersion as string
const domainSeparation = isDomainSeparationActive(event)
@@ -693,8 +828,17 @@ export function getSettingsPayload(event: H3Event) {
imageBaseUrl: imageBaseUrlConfigured,
imageBaseUrlConfigured,
imageBaseUrlSource: getImageBaseUrlSource(event),
imageDomainSeparated: isImageDomainSeparated(event),
videoBaseUrl: videoBaseUrlConfigured,
videoBaseUrlSource: getVideoBaseUrlSource(event),
videoDomainSeparated: isVideoDomainSeparated(event),
fileBaseUrl: fileBaseUrlConfigured,
fileBaseUrlSource: getFileBaseUrlSource(event),
fileDomainSeparated: isFileDomainSeparated(event),
effectiveSiteBaseUrl,
effectiveImageBaseUrl,
effectiveVideoBaseUrl,
effectiveFileBaseUrl,
domainSeparation,
runtime: getRequestRuntime(event),
hideFolderInUrl,
@@ -725,6 +869,8 @@ export function getSettingsPayload(event: H3Event) {
}
export function isDomainSeparationActive(event: H3Event): boolean {
const explicit = getSetting(SETTINGS_DOMAIN_SEPARATION)
if (explicit !== null) return explicit === 'true'
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
return Boolean(siteHost && imageHost && siteHost !== imageHost)
+10 -11
View File
@@ -2,7 +2,6 @@ import type { H3Event } from 'h3'
import type { UploadErrorItem } from '~/types/image'
import type { FileItem } from '~/types/file'
import {
FORBIDDEN_FILE_EXTENSIONS,
MAX_FILE_BED_FILE_SIZE,
MAX_VIDEO_FILE_SIZE
} from './constants'
@@ -10,8 +9,7 @@ import { contentTypeFromKey } from './content-type'
import {
extensionFromFilename,
generateFileKey,
generateVideoKey,
isVideoExtension
generateVideoKey
} from './image-key'
import { getStorageLayout } from './env'
import { resolveImageUrl } from './image-response'
@@ -22,12 +20,13 @@ import { logException, logInfo } from './logger'
import { putImage } from './storage'
import { generateVideoThumbnail } from './transcode'
import { updateImageThumb } from './image-index'
import {
getAllowedVideoExtensions,
isForbiddenFileExtension,
isVideoExtensionAllowed
} from './upload-policy'
/** 文件床禁止上传的扩展名(html / htm / svg) */
export function isForbiddenFileExtension(extension: string): boolean {
if (!extension) return false
return (FORBIDDEN_FILE_EXTENSIONS as readonly string[]).includes(extension.toLowerCase())
}
export { isForbiddenFileExtension }
/** 下载文件名清洗:保留中文等字符,去掉路径与控制字符 */
export function sanitizeDownloadName(name: string): string {
@@ -141,7 +140,7 @@ export async function processSingleFileUpload(
name: originalName,
error: {
code: 'UNSUPPORTED_FILE_TYPE',
message: '不允许上传 html / htm / svg 文件'
message: `不允许上传 ${extension.toUpperCase()} 文件`
}
}
}
@@ -271,13 +270,13 @@ export async function processSingleVideoUpload(
}
const extension = extensionFromFilename(originalName)
if (!isVideoExtension(extension)) {
if (!isVideoExtensionAllowed(extension)) {
return {
error: {
name: originalName,
error: {
code: 'UNSUPPORTED_FILE_TYPE',
message: '只支持常见视频格式(MP4 / WebM / MOV / MKV 等)'
message: `只支持 ${getAllowedVideoExtensions().map(item => item.toUpperCase()).join(' / ')} 视频格式`
}
}
}
+150 -1
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
import {
shouldBlockFileHostRequest,
shouldBlockImageHostRequest,
shouldBlockSiteHostImageRequest,
shouldBlockUnknownHostRequest
shouldBlockUnknownHostRequest,
shouldBlockVideoHostRequest
} from './host-isolation'
function mockEvent(): H3Event {
@@ -32,6 +34,7 @@ describe('shouldBlockImageHostRequest', () => {
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
videoHost: 'video.example.com',
requestHost: imageHost,
method: 'GET',
pathname: '/settings',
@@ -39,11 +42,25 @@ describe('shouldBlockImageHostRequest', () => {
})).toBe(true)
})
it('allows video paths on image host when no video domain is configured', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
videoHost: null,
fileHost: null,
requestHost: imageHost,
method: 'GET',
pathname: '/videos/2026/08/demo.mp4'
})).toBe(false)
})
it('allows image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
videoHost: 'video.example.com',
requestHost: imageHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp',
@@ -62,6 +79,39 @@ describe('shouldBlockImageHostRequest', () => {
})).toBe(true)
})
it('blocks video paths on the image host', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
videoHost: 'video.example.com',
requestHost: imageHost,
method: 'GET',
pathname: '/videos/2026/08/demo.mp4'
})).toBe(true)
})
it('allows admin and all media paths when one host serves every role', () => {
const event = mockEvent()
for (const pathname of [
'/settings',
'/images/2026/08/demo.webp',
'/videos/2026/08/demo.mp4',
'/files/2026/08/demo.pdf'
]) {
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
siteHost: imageHost,
imageHost,
videoHost: imageHost,
fileHost: imageHost,
requestHost: imageHost,
method: 'GET',
pathname
})).toBe(false)
}
})
it('does not block site host admin paths', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
@@ -74,6 +124,47 @@ describe('shouldBlockImageHostRequest', () => {
})
})
describe('video and file media hosts', () => {
const videoHost = 'video.example.com'
const fileHost = 'file.example.com'
it('allows only video paths on the video host', () => {
const event = mockEvent()
expect(shouldBlockVideoHostRequest(event, {
separationActive: true,
videoHost,
requestHost: videoHost,
method: 'GET',
pathname: '/videos/2026/08/demo.mp4'
})).toBe(false)
expect(shouldBlockVideoHostRequest(event, {
separationActive: true,
videoHost,
requestHost: videoHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(true)
})
it('allows only file paths on the file host', () => {
const event = mockEvent()
expect(shouldBlockFileHostRequest(event, {
separationActive: true,
fileHost,
requestHost: fileHost,
method: 'GET',
pathname: '/files/2026/08/demo.pdf'
})).toBe(false)
expect(shouldBlockFileHostRequest(event, {
separationActive: true,
fileHost,
requestHost: fileHost,
method: 'GET',
pathname: '/videos/2026/08/demo.mp4'
})).toBe(true)
})
})
describe('shouldBlockSiteHostImageRequest', () => {
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
@@ -146,6 +237,44 @@ describe('shouldBlockSiteHostImageRequest', () => {
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
it('blocks video and file paths on the site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/videos/2026/08/demo.mp4'
})).toBe(true)
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/files/2026/08/demo.pdf'
})).toBe(true)
})
it('allows media paths when all roles share the same host', () => {
const event = mockEvent()
for (const pathname of [
'/images/2026/08/demo.webp',
'/videos/2026/08/demo.mp4',
'/files/2026/08/demo.pdf'
]) {
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
imageHost: siteHost,
videoHost: siteHost,
fileHost: siteHost,
requestHost: siteHost,
method: 'GET',
pathname
})).toBe(false)
}
})
})
describe('shouldBlockUnknownHostRequest', () => {
@@ -202,6 +331,26 @@ describe('shouldBlockUnknownHostRequest', () => {
})).toBe(false)
})
it('allows configured video and file hosts', () => {
const event = mockEvent()
expect(shouldBlockUnknownHostRequest(event, {
separationActive: true,
requestHost: 'video.example.com',
siteHost,
imageHost,
videoHost: 'video.example.com',
fileHost: 'file.example.com'
})).toBe(false)
expect(shouldBlockUnknownHostRequest(event, {
separationActive: true,
requestHost: 'file.example.com',
siteHost,
imageHost,
videoHost: 'video.example.com',
fileHost: 'file.example.com'
})).toBe(false)
})
it('allows localhost in development', () => {
const previous = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
+160 -20
View File
@@ -2,10 +2,16 @@ import type { H3Event } from 'h3'
import {
getImageBaseUrlConfigured,
getSiteBaseUrlConfigured,
getVideoBaseUrlConfigured,
getFileBaseUrlConfigured,
hostnameFromBaseUrl,
isFileDomainSeparated,
isImageDomainSeparated,
isDomainSeparationActive,
isHideFolderInUrl
isHideFolderInUrl,
isVideoDomainSeparated
} from './env'
import { validateFileKey, validateImageKey, validateStorageKey, validateVideoKey } from './image-key'
import { requestPathToImageKey } from './serve-image'
const DEV_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
@@ -22,15 +28,34 @@ function isDevLocalHost(host: string): boolean {
return process.env.NODE_ENV === 'development' && DEV_LOCAL_HOSTS.has(host)
}
function configuredHostOrNull(loader: () => string | null): string | null {
try {
return loader()
} catch {
return null
}
}
export function getConfiguredSiteHost(event: H3Event): string | null {
return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
}
export function getConfiguredImageHost(event: H3Event): string | null {
if (!isImageDomainSeparated(event)) return null
return hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
}
/** 双域名开启时,拒绝未配置的第三 Host(开发环境 localhost 例外) */
export function getConfiguredVideoHost(event: H3Event): string | null {
if (!isVideoDomainSeparated(event)) return null
return hostnameFromBaseUrl(getVideoBaseUrlConfigured(event))
}
export function getConfiguredFileHost(event: H3Event): string | null {
if (!isFileDomainSeparated(event)) return null
return hostnameFromBaseUrl(getFileBaseUrlConfigured(event))
}
/** 域名分离开启时,拒绝未配置的 Host(开发环境 localhost 例外) */
export function shouldBlockUnknownHostRequest(
event: H3Event,
options?: {
@@ -38,6 +63,8 @@ export function shouldBlockUnknownHostRequest(
separationActive?: boolean
siteHost?: string | null
imageHost?: string | null
videoHost?: string | null
fileHost?: string | null
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
@@ -45,15 +72,48 @@ export function shouldBlockUnknownHostRequest(
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
const videoHost = options?.videoHost
?? configuredHostOrNull(() => getConfiguredVideoHost(event))
const fileHost = options?.fileHost
?? configuredHostOrNull(() => getConfiguredFileHost(event))
if (!siteHost || !imageHost) return false
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
if (requestHost === siteHost || requestHost === imageHost) return false
if (
requestHost === siteHost
|| requestHost === imageHost
|| (videoHost && requestHost === videoHost)
|| (fileHost && requestHost === fileHost)
) return false
if (isDevLocalHost(requestHost)) return false
return true
}
function requestKey(
event: H3Event,
options?: {
pathname?: string
hideFolder?: boolean
}
): string | null {
const pathname = options?.pathname ?? getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).pathname
return requestPathToImageKey(pathname, {
hideFolder: options?.hideFolder ?? isHideFolderInUrl(event)
})
}
function mediaKindForKey(key: string | null): 'image' | 'video' | 'file' | null {
if (!key || !validateStorageKey(key)) return null
if (validateImageKey(key)) return 'image'
if (validateVideoKey(key)) return 'video'
if (validateFileKey(key)) return 'file'
return null
}
export function shouldBlockImageHostRequest(
event: H3Event,
options?: {
@@ -63,6 +123,8 @@ export function shouldBlockImageHostRequest(
separationActive?: boolean
imageHost?: string | null
siteHost?: string | null
videoHost?: string | null
fileHost?: string | null
hideFolder?: boolean
}
): boolean {
@@ -74,21 +136,85 @@ export function shouldBlockImageHostRequest(
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
if (requestHost !== imageHost) return false
const siteHost = options?.siteHost ?? configuredHostOrNull(() => getConfiguredSiteHost(event))
if (siteHost === imageHost) return false
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return true
const pathname = options?.pathname ?? getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).pathname
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
}) === null
const kind = mediaKindForKey(requestKey(event, options))
if (kind === 'image') return false
const videoHost = options?.videoHost
?? configuredHostOrNull(() => getConfiguredVideoHost(event))
const fileHost = options?.fileHost
?? configuredHostOrNull(() => getConfiguredFileHost(event))
if (kind === 'video' && (!videoHost || videoHost === imageHost)) return false
if (kind === 'file' && (!fileHost || fileHost === imageHost)) return false
return true
}
/** 网站域禁止直链出图(仅图片域可 GET/HEAD 图片路径) */
function shouldBlockMediaHostRequest(
event: H3Event,
expectedKind: 'video' | 'file',
configuredHost: string | null,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
hideFolder?: boolean
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
if (!separationActive || !configuredHost) return false
const siteHost = configuredHostOrNull(() => getConfiguredSiteHost(event))
if (siteHost === configuredHost) return false
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
if (requestHost !== configuredHost) return false
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return true
return mediaKindForKey(requestKey(event, options)) !== expectedKind
}
export function shouldBlockVideoHostRequest(
event: H3Event,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
videoHost?: string | null
hideFolder?: boolean
}
): boolean {
return shouldBlockMediaHostRequest(
event,
'video',
options?.videoHost ?? getConfiguredVideoHost(event),
options
)
}
export function shouldBlockFileHostRequest(
event: H3Event,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
fileHost?: string | null
hideFolder?: boolean
}
): boolean {
return shouldBlockMediaHostRequest(
event,
'file',
options?.fileHost ?? getConfiguredFileHost(event),
options
)
}
/** 网站域禁止直接访问任何媒体路径(仅对应媒体域可 GET/HEAD) */
export function shouldBlockSiteHostImageRequest(
event: H3Event,
options?: {
@@ -97,6 +223,9 @@ export function shouldBlockSiteHostImageRequest(
requestHost?: string
separationActive?: boolean
siteHost?: string | null
imageHost?: string | null
videoHost?: string | null
fileHost?: string | null
hideFolder?: boolean
}
): boolean {
@@ -112,12 +241,23 @@ export function shouldBlockSiteHostImageRequest(
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return false
const pathname = options?.pathname ?? getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).pathname
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
}) !== null
const kind = mediaKindForKey(requestKey(event, options))
if (!kind) return false
if (kind === 'image') {
const imageHost = options?.imageHost
?? configuredHostOrNull(() => getConfiguredImageHost(event))
return imageHost !== siteHost
}
if (kind === 'video') {
const imageHost = options?.imageHost
?? configuredHostOrNull(() => getConfiguredImageHost(event))
const videoHost = options?.videoHost
?? configuredHostOrNull(() => getConfiguredVideoHost(event))
return (videoHost || imageHost) !== siteHost
}
const imageHost = options?.imageHost
?? configuredHostOrNull(() => getConfiguredImageHost(event))
const fileHost = options?.fileHost
?? configuredHostOrNull(() => getConfiguredFileHost(event))
return (fileHost || imageHost) !== siteHost
}
+3 -3
View File
@@ -5,7 +5,7 @@ import type { AllowedMimeType } from './constants'
import type { StoredImage } from './storage'
import type { TagItem } from './tags'
import { getTagsForImageKeys } from './tags'
import { getImageBaseUrl, isHideFolderInUrl } from './env'
import { getMediaBaseUrl, isHideFolderInUrl } from './env'
import { toPublicImagePath } from './image-public-path'
import { getActiveBackendRow } from './storage/resolver'
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
@@ -130,7 +130,7 @@ export function resolveImageUrl(
const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey
return `${base}/${publicPath}`
}
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
return buildImageUrl(getMediaBaseUrl(event, key), key, hideFolder)
}
const backendRow = backendId
@@ -141,7 +141,7 @@ export function resolveImageUrl(
return buildPublicImageUrl(backendRow, key, hideFolder)
}
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
return buildImageUrl(getMediaBaseUrl(event, key), key, hideFolder)
}
export function buildMarkdown(url: string, alt: string): string {
+12 -3
View File
@@ -7,10 +7,15 @@ import {
import {
getAllowedRefererHostsRaw,
getFileBedAccessMode,
getFileBaseUrlConfigured,
getImageBaseUrlConfigured,
getVideoBaseUrlConfigured,
getSiteBaseUrlConfigured,
hostnameFromBaseUrl,
isRefererConfigured
isFileDomainSeparated,
isImageDomainSeparated,
isRefererConfigured,
isVideoDomainSeparated
} from './env'
import { isFileAccessAuthorized } from './access'
import { buildAttachmentDisposition } from './file-bed'
@@ -51,11 +56,15 @@ function getAllowedHosts(event: H3Event): Set<string> {
})
hosts.add(requestUrl.hostname.toLowerCase())
// 双域名分离时,管理域页面会引用图片域直链,需互相放行 Referer
// 域名分离时,后台页面会引用各资源域直链,需互相放行 Referer
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
const videoHost = hostnameFromBaseUrl(getVideoBaseUrlConfigured(event))
const fileHost = hostnameFromBaseUrl(getFileBaseUrlConfigured(event))
if (siteHost) hosts.add(siteHost)
if (imageHost) hosts.add(imageHost)
if (imageHost && isImageDomainSeparated(event)) hosts.add(imageHost)
if (videoHost && isVideoDomainSeparated(event)) hosts.add(videoHost)
if (fileHost && isFileDomainSeparated(event)) hosts.add(fileHost)
return hosts
}
+23
View File
@@ -3,11 +3,15 @@ import { describe, expect, it } from 'vitest'
import {
DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
normalizeAllowedMimeTypes,
normalizeAllowedVideoExtensions,
normalizeForbiddenFileExtensions,
parseLoginRateMax,
parsePreserveOriginalUpload,
parseUploadMaxFileSizeMb,
parseUploadRateIpMax,
serializeAllowedMimeTypes,
serializeAllowedVideoExtensions,
serializeForbiddenFileExtensions,
serializePreserveOriginalUpload
} from './upload-policy'
@@ -30,6 +34,25 @@ describe('upload-policy parsers', () => {
expect(serializeAllowedMimeTypes(['image/webp', 'image/jpeg'])).toBe('image/jpeg,image/webp')
})
it('normalizes and serializes allowed video extensions', () => {
expect(normalizeAllowedVideoExtensions('.MOV, mp4,mkv,mp4,ts')).toEqual([
'mov',
'mp4',
'mkv'
])
expect(serializeAllowedVideoExtensions(['mkv', 'mp4'])).toBe('mp4,mkv')
})
it('normalizes and serializes forbidden file extensions', () => {
expect(normalizeForbiddenFileExtensions('.HTML, exe,php,exe,bad/ext')).toEqual([
'html',
'exe',
'php'
])
expect(serializeForbiddenFileExtensions(['php', 'svg', 'html']))
.toBe('html,svg,php')
})
it('parses rate limit values', () => {
expect(parseUploadRateIpMax('60')).toBe(60)
expect(parseLoginRateMax('10')).toBe(10)
+159 -1
View File
@@ -1,5 +1,8 @@
import {
ALLOWED_MIME_TYPES,
FORBIDDEN_FILE_EXTENSIONS,
VIDEO_EXTENSIONS,
type VideoExtension,
type AllowedMimeType
} from './constants'
import { getSetting, setSetting } from './db'
@@ -8,6 +11,8 @@ import { TELEGRAM_MAX_FILE_BYTES } from './storage/telegram'
export const SETTINGS_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb'
export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types'
export const SETTINGS_ALLOWED_VIDEO_EXTENSIONS = 'allowed_video_extensions'
export const SETTINGS_FORBIDDEN_FILE_EXTENSIONS = 'forbidden_file_extensions'
export const SETTINGS_UPLOAD_RATE_IP_MAX = 'upload_rate_ip_max'
export const SETTINGS_UPLOAD_RATE_TOKEN_MAX = 'upload_rate_token_max'
export const SETTINGS_UPLOAD_RATE_WINDOW_MINUTES = 'upload_rate_window_minutes'
@@ -41,6 +46,37 @@ export const ALL_MIME_TYPE_OPTIONS = [
{ mime: 'image/x-icon', label: 'ICO' }
] as const satisfies ReadonlyArray<{ mime: AllowedMimeType, label: string }>
export const ALL_VIDEO_EXTENSION_OPTIONS = VIDEO_EXTENSIONS.map(extension => ({
extension,
label: extension.toUpperCase()
}))
export const FORBIDDEN_FILE_EXTENSION_OPTIONS = [
'html',
'htm',
'xhtml',
'svg',
'xml',
'js',
'mjs',
'cjs',
'php',
'phtml',
'sh',
'bash',
'zsh',
'bat',
'cmd',
'ps1',
'exe',
'dll',
'msi',
'scr'
].map(extension => ({
extension,
label: extension.toUpperCase()
}))
export type PolicySource = 'env' | 'db' | 'default'
function readEnv(name: string): string {
@@ -119,12 +155,79 @@ export function serializeAllowedMimeTypes(mimes: readonly string[]): string {
.join(',')
}
export function normalizeAllowedVideoExtensions(raw: string): VideoExtension[] {
const allowedSet = new Set<string>(VIDEO_EXTENSIONS)
const seen = new Set<string>()
const result: VideoExtension[] = []
for (const part of raw.split(',')) {
const extension = part.trim().toLowerCase().replace(/^\.+/, '')
if (!extension || seen.has(extension) || !allowedSet.has(extension)) continue
seen.add(extension)
result.push(extension as VideoExtension)
}
return result
}
export function serializeAllowedVideoExtensions(extensions: readonly string[]): string {
const normalized = new Set(normalizeAllowedVideoExtensions(extensions.join(',')))
return VIDEO_EXTENSIONS
.filter(extension => normalized.has(extension))
.join(',')
}
export function normalizeForbiddenFileExtensions(raw: string): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const part of raw.split(',')) {
const extension = part
.trim()
.toLowerCase()
.replace(/^\.+/, '')
if (!extension || seen.has(extension)) continue
if (!/^[a-z0-9][a-z0-9_-]{0,15}$/.test(extension)) continue
seen.add(extension)
result.push(extension)
}
return result
}
export function serializeForbiddenFileExtensions(extensions: readonly string[]): string {
const configuredOrder = new Map(
FORBIDDEN_FILE_EXTENSION_OPTIONS.map((option, index) => [option.extension, index])
)
return normalizeForbiddenFileExtensions(extensions.join(','))
.sort((left, right) => {
const leftOrder = configuredOrder.get(left)
const rightOrder = configuredOrder.get(right)
if (leftOrder !== undefined || rightOrder !== undefined) {
return (leftOrder ?? Number.MAX_SAFE_INTEGER)
- (rightOrder ?? Number.MAX_SAFE_INTEGER)
}
return left.localeCompare(right)
})
.join(',')
}
function getUploadMaxFileSizeMbSource(): PolicySource {
if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db'
if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env'
return 'default'
}
function getAllowedVideoExtensionsSource(): PolicySource {
if (getSetting(SETTINGS_ALLOWED_VIDEO_EXTENSIONS) !== null) return 'db'
if (normalizeAllowedVideoExtensions(readEnv('ALLOWED_VIDEO_EXTENSIONS')).length > 0) {
return 'env'
}
return 'default'
}
function getForbiddenFileExtensionsSource(): PolicySource {
if (getSetting(SETTINGS_FORBIDDEN_FILE_EXTENSIONS) !== null) return 'db'
if (readEnv('FORBIDDEN_FILE_EXTENSIONS')) return 'env'
return 'default'
}
function getAllowedMimeTypesSource(): PolicySource {
if (getSetting(SETTINGS_ALLOWED_MIME_TYPES) !== null) return 'db'
if (normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')).length > 0) return 'env'
@@ -202,6 +305,35 @@ export function getAllowedMimeTypesList(): AllowedMimeType[] {
return [...ALLOWED_MIME_TYPES]
}
export function getAllowedVideoExtensions(): VideoExtension[] {
const dbRaw = getSetting(SETTINGS_ALLOWED_VIDEO_EXTENSIONS)
if (dbRaw !== null) {
const parsed = normalizeAllowedVideoExtensions(dbRaw)
if (parsed.length > 0) return parsed
}
const fromEnv = normalizeAllowedVideoExtensions(readEnv('ALLOWED_VIDEO_EXTENSIONS'))
if (fromEnv.length > 0) return fromEnv
return [...VIDEO_EXTENSIONS]
}
export function getForbiddenFileExtensions(): string[] {
const dbRaw = getSetting(SETTINGS_FORBIDDEN_FILE_EXTENSIONS)
if (dbRaw !== null) return normalizeForbiddenFileExtensions(dbRaw)
const envRaw = readEnv('FORBIDDEN_FILE_EXTENSIONS')
if (envRaw) return normalizeForbiddenFileExtensions(envRaw)
return [...FORBIDDEN_FILE_EXTENSIONS]
}
export function isVideoExtensionAllowed(extension: string): boolean {
const normalized = extension.trim().toLowerCase().replace(/^\.+/, '')
return getAllowedVideoExtensions().includes(normalized as VideoExtension)
}
export function isForbiddenFileExtension(extension: string): boolean {
const normalized = extension.trim().toLowerCase().replace(/^\.+/, '')
return Boolean(normalized) && getForbiddenFileExtensions().includes(normalized)
}
export function isMimeTypeAllowed(mime: string): mime is AllowedMimeType {
return getAllowedMimeTypesList().includes(mime as AllowedMimeType)
}
@@ -312,6 +444,16 @@ export function getUploadPolicySettingsPayload() {
allowedMimeTypes: getAllowedMimeTypesList(),
allowedMimeTypesSource: getAllowedMimeTypesSource(),
allowedMimeTypesEnvFallback: normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')),
allowedVideoExtensions: getAllowedVideoExtensions(),
allowedVideoExtensionsSource: getAllowedVideoExtensionsSource(),
allowedVideoExtensionsEnvFallback: normalizeAllowedVideoExtensions(
readEnv('ALLOWED_VIDEO_EXTENSIONS')
),
forbiddenFileExtensions: getForbiddenFileExtensions(),
forbiddenFileExtensionsSource: getForbiddenFileExtensionsSource(),
forbiddenFileExtensionsEnvFallback: normalizeForbiddenFileExtensions(
readEnv('FORBIDDEN_FILE_EXTENSIONS')
),
uploadRateIpMax: getUploadRateLimitSettings().ipMax,
uploadRateIpMaxSource: getUploadRateIpMaxSource(),
uploadRateTokenMax: getUploadRateLimitSettings().tokenMax,
@@ -327,13 +469,17 @@ export function getUploadPolicySettingsPayload() {
mimeTypeOptions: ALL_MIME_TYPE_OPTIONS.map(option => ({
mime: option.mime,
label: option.label
}))
})),
videoExtensionOptions: ALL_VIDEO_EXTENSION_OPTIONS,
forbiddenFileExtensionOptions: FORBIDDEN_FILE_EXTENSION_OPTIONS
}
}
export function setUploadPolicySettings(input: {
uploadMaxFileSizeMb?: number
allowedMimeTypes?: readonly string[]
allowedVideoExtensions?: readonly string[]
forbiddenFileExtensions?: readonly string[]
uploadRateIpMax?: number
uploadRateTokenMax?: number
uploadRateWindowMinutes?: number
@@ -347,6 +493,18 @@ export function setUploadPolicySettings(input: {
if (input.allowedMimeTypes !== undefined) {
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))
}
if (input.allowedVideoExtensions !== undefined) {
setSetting(
SETTINGS_ALLOWED_VIDEO_EXTENSIONS,
serializeAllowedVideoExtensions(input.allowedVideoExtensions)
)
}
if (input.forbiddenFileExtensions !== undefined) {
setSetting(
SETTINGS_FORBIDDEN_FILE_EXTENSIONS,
serializeForbiddenFileExtensions(input.forbiddenFileExtensions)
)
}
if (input.uploadRateIpMax !== undefined) {
setSetting(SETTINGS_UPLOAD_RATE_IP_MAX, String(input.uploadRateIpMax))
}