服务器配置

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

+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))
}