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 指向内网仓库
311 lines
8.5 KiB
TypeScript
311 lines
8.5 KiB
TypeScript
import type { H3Event } from 'h3'
|
||
import type { UploadErrorItem } from '~/types/image'
|
||
import type { FileItem } from '~/types/file'
|
||
import {
|
||
FORBIDDEN_FILE_EXTENSIONS,
|
||
MAX_FILE_BED_FILE_SIZE,
|
||
MAX_VIDEO_FILE_SIZE
|
||
} from './constants'
|
||
import { contentTypeFromKey } from './content-type'
|
||
import {
|
||
extensionFromFilename,
|
||
generateFileKey,
|
||
generateVideoKey,
|
||
isVideoExtension
|
||
} from './image-key'
|
||
import { getStorageLayout } from './env'
|
||
import { resolveImageUrl } from './image-response'
|
||
import type { LogSource } from './db'
|
||
import type { MediaType } from './storage/types'
|
||
import { logActivity } from './activity-log'
|
||
import { logException, logInfo } from './logger'
|
||
import { putImage } from './storage'
|
||
import { generateVideoThumbnail } from './transcode'
|
||
import { updateImageThumb } from './image-index'
|
||
|
||
/** 文件床禁止上传的扩展名(html / htm / svg) */
|
||
export function isForbiddenFileExtension(extension: string): boolean {
|
||
if (!extension) return false
|
||
return (FORBIDDEN_FILE_EXTENSIONS as readonly string[]).includes(extension.toLowerCase())
|
||
}
|
||
|
||
/** 下载文件名清洗:保留中文等字符,去掉路径与控制字符 */
|
||
export function sanitizeDownloadName(name: string): string {
|
||
const base = name.split(/[/\\]/).pop() ?? 'file'
|
||
const withoutControl = Array.from(base)
|
||
.filter(char => char.charCodeAt(0) > 0x1f && char.charCodeAt(0) !== 0x7f)
|
||
.join('')
|
||
const cleaned = withoutControl
|
||
.replace(/["<>|:*?]/g, '_')
|
||
.trim()
|
||
return cleaned.slice(0, 200) || 'file'
|
||
}
|
||
|
||
/** RFC 5987 兼容的附件下载响应头值 */
|
||
export function buildAttachmentDisposition(name: string): string {
|
||
const safe = sanitizeDownloadName(name)
|
||
const ascii = safe.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
||
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(safe)}`
|
||
}
|
||
|
||
export function generateFileStorageKey(originalName: string, date = new Date()): string {
|
||
return generateFileKey(extensionFromFilename(originalName), date, getStorageLayout())
|
||
}
|
||
|
||
export function generateVideoStorageKey(originalName: string, date = new Date()): string {
|
||
return generateVideoKey(extensionFromFilename(originalName), date, getStorageLayout())
|
||
}
|
||
|
||
/** 声明 MIME 清洗;不合法时按扩展名推断 */
|
||
export function normalizeFileContentType(
|
||
declared: string | undefined,
|
||
extension: string
|
||
): string {
|
||
const value = (declared ?? '').split(';')[0]!.trim().toLowerCase()
|
||
const valid = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(value)
|
||
if (valid) return value
|
||
return extension ? contentTypeFromKey(`file.${extension}`) : 'application/octet-stream'
|
||
}
|
||
|
||
/** 视频必须落到 video/* 上,否则按扩展名推断 */
|
||
export function normalizeVideoContentType(
|
||
declared: string | undefined,
|
||
extension: string
|
||
): string {
|
||
const normalized = normalizeFileContentType(declared, extension)
|
||
if (normalized.startsWith('video/')) return normalized
|
||
return contentTypeFromKey(`video.${extension}`)
|
||
}
|
||
|
||
export function buildFileItem(input: {
|
||
event: H3Event
|
||
key: string
|
||
backendId?: string
|
||
originalName: string
|
||
contentType: string
|
||
size: number
|
||
uploadedAt: string
|
||
}): FileItem {
|
||
const url = resolveImageUrl(input.event, input.key, input.backendId)
|
||
const name = sanitizeDownloadName(input.originalName)
|
||
|
||
return {
|
||
key: input.key,
|
||
url,
|
||
originalName: name,
|
||
contentType: input.contentType,
|
||
size: input.size,
|
||
uploadedAt: input.uploadedAt,
|
||
markdown: `[${name}](${url})`
|
||
}
|
||
}
|
||
|
||
export async function processSingleFileUpload(
|
||
event: H3Event,
|
||
input: {
|
||
bytes: Uint8Array
|
||
filename?: string
|
||
contentType?: string
|
||
source?: LogSource
|
||
userId?: number | null
|
||
}
|
||
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||
const originalName = sanitizeDownloadName(input.filename ?? 'file')
|
||
const bytes = input.bytes
|
||
|
||
if (!bytes.byteLength) {
|
||
return {
|
||
error: {
|
||
name: originalName,
|
||
error: { code: 'INVALID_REQUEST', message: '文件内容为空' }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bytes.byteLength > MAX_FILE_BED_FILE_SIZE) {
|
||
return {
|
||
error: {
|
||
name: originalName,
|
||
error: {
|
||
code: 'FILE_TOO_LARGE',
|
||
message: `单个文件不能超过 ${Math.round(MAX_FILE_BED_FILE_SIZE / 1024 / 1024)}MB`
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const extension = extensionFromFilename(originalName)
|
||
if (isForbiddenFileExtension(extension)) {
|
||
return {
|
||
error: {
|
||
name: originalName,
|
||
error: {
|
||
code: 'UNSUPPORTED_FILE_TYPE',
|
||
message: '不允许上传 html / htm / svg 文件'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const uploadedAt = new Date().toISOString()
|
||
const key = generateFileStorageKey(originalName, new Date(uploadedAt))
|
||
|
||
return persistMediaUpload(event, {
|
||
key,
|
||
bytes,
|
||
originalName,
|
||
uploadedAt,
|
||
contentType: normalizeFileContentType(input.contentType, extension),
|
||
source: input.source,
|
||
userId: input.userId,
|
||
mediaType: 'file'
|
||
})
|
||
}
|
||
|
||
/** 写存储 + 建索引 + 记日志 + 组装返回项(文件床与视频床共用) */
|
||
async function persistMediaUpload(
|
||
event: H3Event,
|
||
input: {
|
||
key: string
|
||
bytes: Uint8Array
|
||
originalName: string
|
||
uploadedAt: string
|
||
contentType: string
|
||
source?: LogSource
|
||
userId?: number | null
|
||
mediaType: MediaType
|
||
}
|
||
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||
let backendId: string
|
||
try {
|
||
backendId = await putImage(input.key, input.bytes, {
|
||
originalName: input.originalName,
|
||
uploadedAt: input.uploadedAt,
|
||
contentType: input.contentType,
|
||
size: input.bytes.byteLength,
|
||
userId: input.userId ?? null,
|
||
mediaType: input.mediaType
|
||
})
|
||
} catch (error) {
|
||
logException('upload failed: storage error', error, {
|
||
name: input.originalName,
|
||
source: input.source ?? 'web'
|
||
})
|
||
const detail = error instanceof Error ? error.message.trim() : ''
|
||
return {
|
||
error: {
|
||
name: input.originalName,
|
||
error: {
|
||
code: 'UPLOAD_FAILED',
|
||
message: detail.startsWith('Telegram') || detail.includes('群组')
|
||
? detail
|
||
: '上传失败,请稍后重试'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
logActivity(event, {
|
||
action: 'upload',
|
||
key: input.key,
|
||
originalName: input.originalName,
|
||
size: input.bytes.byteLength,
|
||
contentType: input.contentType,
|
||
source: input.source,
|
||
userId: input.userId ?? null,
|
||
backendId
|
||
})
|
||
|
||
logInfo(`${input.mediaType} upload success`, {
|
||
key: input.key,
|
||
size: input.bytes.byteLength,
|
||
contentType: input.contentType,
|
||
source: input.source ?? 'web'
|
||
})
|
||
|
||
return {
|
||
item: buildFileItem({
|
||
event,
|
||
key: input.key,
|
||
backendId,
|
||
originalName: input.originalName,
|
||
contentType: input.contentType,
|
||
size: input.bytes.byteLength,
|
||
uploadedAt: input.uploadedAt
|
||
})
|
||
}
|
||
}
|
||
|
||
export async function processSingleVideoUpload(
|
||
event: H3Event,
|
||
input: {
|
||
bytes: Uint8Array
|
||
filename?: string
|
||
contentType?: string
|
||
source?: LogSource
|
||
userId?: number | null
|
||
}
|
||
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||
const originalName = sanitizeDownloadName(input.filename ?? 'video')
|
||
const bytes = input.bytes
|
||
|
||
if (!bytes.byteLength) {
|
||
return {
|
||
error: {
|
||
name: originalName,
|
||
error: { code: 'INVALID_REQUEST', message: '文件内容为空' }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (bytes.byteLength > MAX_VIDEO_FILE_SIZE) {
|
||
return {
|
||
error: {
|
||
name: originalName,
|
||
error: {
|
||
code: 'FILE_TOO_LARGE',
|
||
message: `单个视频不能超过 ${Math.round(MAX_VIDEO_FILE_SIZE / 1024 / 1024)}MB`
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const extension = extensionFromFilename(originalName)
|
||
if (!isVideoExtension(extension)) {
|
||
return {
|
||
error: {
|
||
name: originalName,
|
||
error: {
|
||
code: 'UNSUPPORTED_FILE_TYPE',
|
||
message: '只支持常见视频格式(MP4 / WebM / MOV / MKV 等)'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const uploadedAt = new Date().toISOString()
|
||
const key = generateVideoStorageKey(originalName, new Date(uploadedAt))
|
||
|
||
const result = await persistMediaUpload(event, {
|
||
key,
|
||
bytes,
|
||
originalName,
|
||
uploadedAt,
|
||
contentType: normalizeVideoContentType(input.contentType, extension),
|
||
source: input.source,
|
||
userId: input.userId,
|
||
mediaType: 'video'
|
||
})
|
||
|
||
// 生成封面缩略图;失败不影响上传结果
|
||
if ('item' in result) {
|
||
const thumbKey = await generateVideoThumbnail(key).catch(() => null)
|
||
if (thumbKey) {
|
||
updateImageThumb(key, thumbKey)
|
||
result.item.thumbUrl = resolveImageUrl(event, thumbKey)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|