服务器配置
This commit is contained in:
1 parent
a1b4a63552
commit
fa7c8244b0
33 files changed
+1882
-363
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.')
|
||||
@@ -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)
|
||||
|
||||
@@ -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