feat: 新增视频床与文件床,视频支持 HLS 转码
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
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 指向内网仓库
This commit is contained in:
1 parent
05cc3895f1
commit
89ea2fb25d
61 files changed
+6637
-154
No files matched your search
@@ -0,0 +1,310 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user