Files
FileHost/server/utils/serve-image.ts
T
Yeomai 89ea2fb25d
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
feat: 新增视频床与文件床,视频支持 HLS 转码
文件床
- 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立
- 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg
- 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载

视频床
- 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB
- 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行
- 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例)
- 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩
- 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接
- 上传时自动抽帧生成封面,视频与封面各有独立链接
- 删除视频时中断转码并清理切片、封面与源文件

媒体库
- 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布
- 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆
- 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件)

修复
- 清理无主的切片、封面与开发期遗留的 ffmpeg 进程
- 源文件按设计删除后,索引不再被启动孤儿清理误删
- 视频统计口径与列表一致,转码中的视频不计入统计
- 转码失败时回收已写入的部分切片,避免残留
- 设置页停用检查更新(与上游版本号已不对应)

其他
- Docker 镜像安装 ffmpeg
- git remote 指向内网仓库
2026-10-17 03:14:57 +08:00

223 lines
7.0 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 {
FILE_CACHE_CONTROL,
FILE_PRIVATE_CACHE_CONTROL,
IMAGE_CACHE_CONTROL
} from './constants'
import {
getAllowedRefererHostsRaw,
getFileBedAccessMode,
getImageBaseUrlConfigured,
getSiteBaseUrlConfigured,
hostnameFromBaseUrl,
isRefererConfigured
} from './env'
import { isFileAccessAuthorized } from './access'
import { buildAttachmentDisposition } from './file-bed'
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
import {
validateFileKey,
validateImageKey,
validateStorageKey,
validateVideoKey
} from './image-key'
import { isBareImageFilename, toStorageKeyFromPublicPath } from './image-public-path'
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 = 'private, no-cache, max-age=0'
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
}
}
/** 未配置防盗链白名单时不拦截;已配置时校验 Referer(缺失 Referer 仍放行) */
export function isHotlinkBlocked(event: H3Event, referer: string | null): boolean {
if (!isRefererConfigured(event)) return false
return !isRefererAllowed(referer, getAllowedHosts(event))
}
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 (!raw) return null
if (validateStorageKey(raw)) return raw
// 简短链接:纯文件名须先走索引反查,不能猜成 images/文件名(与按年/月存储冲突)
if (options?.hideFolder && isBareImageFilename(raw)) {
const byFilename = findImageKeyByFilename(raw)
if (byFilename && validateImageKey(byFilename)) return byFilename
return null
}
const fromPublic = toStorageKeyFromPublicPath(raw)
if (fromPublic) return fromPublic
if (!options?.hideFolder) return null
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 isFile = validateFileKey(key)
const isVideo = validateVideoKey(key)
// 文件与视频共用「文件床访问方式」开关
const isMedia = isFile || isVideo
const fileAccessMode = isMedia ? getFileBedAccessMode() : 'public'
const referer = getHeader(event, 'referer') ?? null
if (isHotlinkBlocked(event, referer)) {
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
throw createError({
statusCode: 403,
statusMessage: 'Forbidden: hotlink protection'
})
}
// Token 模式:文件直链需要 Auth-Token / ?token= / 已登录会话
if (isMedia && fileAccessMode === 'token' && !(await isFileAccessAuthorized(event))) {
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
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)
// Token 模式不做对象存储直链跳转,避免绕过鉴权
if (
fileAccessMode === 'public'
&& 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',
isMedia
? (fileAccessMode === 'token' ? FILE_PRIVATE_CACHE_CONTROL : FILE_CACHE_CONTROL)
: IMAGE_CACHE_CONTROL
)
setHeader(event, 'Accept-Ranges', 'bytes')
setHeader(event, 'X-Content-Type-Options', 'nosniff')
setHeader(event, 'Content-Type', stored.contentType)
// 文件床统一以附件形式下发,避免上传的文档在站点域名下被渲染执行
if (isFile) {
setHeader(event, 'Content-Disposition', buildAttachmentDisposition(stored.originalName))
}
// 视频走 inline 播放,跨域(图片域名)访问需要放行 CORS
if (isVideo) {
setHeader(event, 'Access-Control-Allow-Origin', '*')
}
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))
}