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.
158 lines
4.8 KiB
TypeScript
158 lines
4.8 KiB
TypeScript
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))
|
||
}
|