Files
PicHost/server/utils/serve-image.ts
T
O96u 438c1d5529 feat: storage path layout, basename URLs, and bidirectional domain isolation
Add storage_use_date_path for flat vs date-key storage layout and emit
basename-only public URLs when hide-folder is enabled. Block image paths
on the site host when domain separation is active so only the image host
serves files. Update settings UI, i18n, tests, and deployment docs.
2026-08-28 09:56:45 +08:00

158 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { H3Event } from 'h3'
import { IMAGE_CACHE_CONTROL } from './constants'
import {
getAllowedRefererHostsRaw,
getImageBaseUrlConfigured,
getSiteBaseUrlConfigured,
hostnameFromBaseUrl
} from './env'
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
import { validateImageKey } from './image-key'
import { buildPublicImageUrl } from './image-response'
import { createImageStream, headImage } from './storage'
import { getBackendRowForKey } from './storage/resolver'
/**
* 图片直链处理(/{type}/YYYY/MM/file)
*
* 防盗链说明:
* - Referer 可以缺失(直接访问、部分客户端),缺失时放行
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
*/
const ERROR_CACHE_CONTROL = 'public, max-age=60'
function getAllowedHosts(event: H3Event): Set<string> {
const hosts = new Set<string>()
const raw = getAllowedRefererHostsRaw(event)
for (const part of raw.split(',')) {
const trimmed = part.trim().toLowerCase()
if (trimmed) hosts.add(trimmed)
}
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
hosts.add(requestUrl.hostname.toLowerCase())
// 双域名分离时,管理域页面会引用图片域直链,需互相放行 Referer
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
if (siteHost) hosts.add(siteHost)
if (imageHost) hosts.add(imageHost)
return hosts
}
export function isRefererAllowed(referer: string | null, allowedHosts: Set<string>): boolean {
if (!referer) return true
try {
const hostname = new URL(referer).hostname.toLowerCase()
return allowedHosts.has(hostname)
} catch {
return false
}
}
function parseRangeHeader(
rangeHeader: string | undefined,
size: number
): { start: number, end: number } | null {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null
const [startStr, endStr] = rangeHeader.slice(6).split('-', 2)
const start = startStr ? Number(startStr) : NaN
const end = endStr ? Number(endStr) : size - 1
if (!Number.isFinite(start) || start < 0 || start >= size) return null
const safeEnd = Number.isFinite(end) ? Math.min(end, size - 1) : size - 1
if (safeEnd < start) return null
return { start, end: safeEnd }
}
export function requestPathToImageKey(
pathname: string,
options?: { hideFolder?: boolean }
): string | null {
const raw = decodeURIComponent(pathname).replace(/^\/+/, '')
if (validateImageKey(raw)) return raw
if (options?.hideFolder) {
const byFilename = findImageKeyByFilename(raw)
if (byFilename && validateImageKey(byFilename)) return byFilename
const byDatePath = findImageKeyByDatePath(raw)
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
}
return null
}
export async function serveImageByKey(event: H3Event, key: string) {
const referer = getHeader(event, 'referer') ?? null
if (!isRefererAllowed(referer, getAllowedHosts(event))) {
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
throw createError({
statusCode: 403,
statusMessage: 'Forbidden: hotlink protection'
})
}
const stored = await headImage(key)
if (!stored) {
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
const backendRow = await getBackendRowForKey(key)
if (backendRow.serving_mode === 'public' && backendRow.public_url) {
const publicUrl = buildPublicImageUrl(backendRow, key)
return sendRedirect(event, publicUrl, 302)
}
const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"`
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', IMAGE_CACHE_CONTROL)
setHeader(event, 'Accept-Ranges', 'bytes')
setHeader(event, 'X-Content-Type-Options', 'nosniff')
setHeader(event, 'Content-Type', stored.contentType)
if (stored.contentType === 'image/svg+xml') {
setHeader(
event,
'Content-Security-Policy',
'default-src \'none\'; style-src \'unsafe-inline\''
)
}
const ifNoneMatch = getHeader(event, 'if-none-match')
if (ifNoneMatch && ifNoneMatch === etag) {
setResponseStatus(event, 304)
return null
}
const method = getMethod(event)
if (method === 'HEAD') {
setHeader(event, 'Content-Length', stored.size)
return null
}
const range = parseRangeHeader(getHeader(event, 'range'), stored.size)
if (range) {
setResponseStatus(event, 206)
setHeader(event, 'Content-Range', `bytes ${range.start}-${range.end}/${stored.size}`)
setHeader(event, 'Content-Length', range.end - range.start + 1)
return sendStream(event, await createImageStream(key, range))
}
setHeader(event, 'Content-Length', stored.size)
return sendStream(event, await createImageStream(key))
}