Files
FileHost/server/utils/serve-image.ts
T
O96u d8c1303c32
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
release: v1.1.3 — dual-domain separation and URL options
Add admin/image domain separation with host isolation middleware,
optional hide-folder public URLs, GitHub release update check, and
Lucky/Nginx deployment docs. Switch license to GPL-3.0-only.
2026-08-27 12:13:58 +08:00

155 lines
4.7 KiB
TypeScript
Raw Permalink 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 } 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 resolved = findImageKeyByDatePath(raw)
if (resolved && validateImageKey(resolved)) return resolved
}
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))
}