ci / ci (22, ubuntu-latest) (push) Canceled after 0s
文件床 - 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立 - 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg - 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载 视频床 - 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB - 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行 - 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例) - 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩 - 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接 - 上传时自动抽帧生成封面,视频与封面各有独立链接 - 删除视频时中断转码并清理切片、封面与源文件 媒体库 - 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布 - 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆 - 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件) 修复 - 清理无主的切片、封面与开发期遗留的 ffmpeg 进程 - 源文件按设计删除后,索引不再被启动孤儿清理误删 - 视频统计口径与列表一致,转码中的视频不计入统计 - 转码失败时回收已写入的部分切片,避免残留 - 设置页停用检查更新(与上游版本号已不对应) 其他 - Docker 镜像安装 ffmpeg - git remote 指向内网仓库
223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
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))
|
||
}
|