服务器配置
This commit is contained in:
1 parent
a1b4a63552
commit
fa7c8244b0
33 files changed
+1882
-363
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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(' / ')} 视频格式`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user