Files
FileHost/server/utils/image-key.ts
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

219 lines
7.0 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 { VIDEO_EXTENSIONS } from './constants'
import type { AllowedMimeType } from './constants'
const EXT_MAP: Record<AllowedMimeType, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/svg+xml': 'svg',
'image/x-icon': 'ico'
}
export const DEFAULT_FOLDER = 'images'
export const FILE_FOLDER = 'files'
export const VIDEO_FOLDER = 'videos'
export type StorageLayout = 'date' | 'flat'
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
/** 文件床文件名:字母数字开头,允许 - _ .,最长 64(含扩展名) */
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
function generateRandomId(length = 12): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const bytes = new Uint8Array(length)
crypto.getRandomValues(bytes)
return Array.from(bytes, byte => alphabet[byte % alphabet.length]).join('')
}
export function mimeToExtension(mime: AllowedMimeType): string {
return EXT_MAP[mime]
}
/** 路径段:字母数字开头,允许 - _ .,最长 64 */
function isValidPathSegment(segment: string): boolean {
if (!segment || segment.length > 64) return false
if (segment === '.' || segment === '..') return false
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment)
}
export function generateImageKey(
contentType: AllowedMimeType,
date = new Date(),
layout: StorageLayout = 'date'
): string {
const id = generateRandomId(12)
const ext = mimeToExtension(contentType)
if (layout === 'flat') {
return `${DEFAULT_FOLDER}/${id}.${ext}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${DEFAULT_FOLDER}/${year}/${month}/${id}.${ext}`
}
export function validateImageKey(key: string): boolean {
if (!key || typeof key !== 'string') return false
if (key.startsWith('/')) return false
if (key.includes('..')) return false
if (key.includes('//')) return false
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
if (parts[0] !== DEFAULT_FOLDER) return false
const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return false
for (let i = 1; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return false
}
return true
}
/** 扩展名清洗:只保留 1–10 位字母数字 */
export function sanitizeFileExtension(raw: string): string {
const ext = raw.trim().toLowerCase().replace(/^\.+/, '')
return /^[a-z0-9]{1,10}$/.test(ext) ? ext : ''
}
/** 从原始文件名取安全扩展名;无扩展名时返回空串 */
export function extensionFromFilename(filename: string): string {
const base = filename.split(/[/\\]/).pop() ?? ''
const dot = base.lastIndexOf('.')
if (dot <= 0 || dot === base.length - 1) return ''
return sanitizeFileExtension(base.slice(dot + 1))
}
export function generateFileKey(
extension: string,
date = new Date(),
layout: StorageLayout = 'date'
): string {
const id = generateRandomId(12)
const ext = sanitizeFileExtension(extension)
const suffix = ext ? `.${ext}` : ''
if (layout === 'flat') {
return `${FILE_FOLDER}/${id}${suffix}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${FILE_FOLDER}/${year}/${month}/${id}${suffix}`
}
/** 文件床 key(files/…);图片 key 走 validateImageKey */
export function validateFileKey(key: string): boolean {
if (!key || typeof key !== 'string') return false
if (key.startsWith('/')) return false
if (key.includes('..')) return false
if (key.includes('//')) return false
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
if (parts[0] !== FILE_FOLDER) return false
const filename = parts[parts.length - 1]!
if (!FILE_NAME_PATTERN.test(filename)) return false
for (let i = 1; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return false
}
return true
}
/** 图片或文件床的存储 key */
export function validateStorageKey(key: string): boolean {
return validateImageKey(key) || validateFileKey(key) || validateVideoKey(key)
}
export function mediaTypeForKey(key: string): 'image' | 'video' | 'file' {
if (key.startsWith(`${FILE_FOLDER}/`)) return 'file'
if (key.startsWith(`${VIDEO_FOLDER}/`)) return 'video'
return 'image'
}
/** videos/ 下除视频本体外的附属资源:播放列表、分片、封面缩略图 */
const VIDEO_SIDECAR_EXTENSIONS = ['m3u8', 'ts', 'webp', 'jpg', 'jpeg', 'png']
function isVideoKeyExtension(extension: string): boolean {
if (!extension) return false
const value = extension.toLowerCase()
return (VIDEO_EXTENSIONS as readonly string[]).includes(value)
|| VIDEO_SIDECAR_EXTENSIONS.includes(value)
}
/** 附属资源不进索引,也不出现在列表里 */
export function isVideoSidecarKey(key: string): boolean {
return validateVideoKey(key)
&& VIDEO_SIDECAR_EXTENSIONS.includes(extensionFromFilename(key))
}
export function isVideoExtension(extension: string): boolean {
if (!extension) return false
return (VIDEO_EXTENSIONS as readonly string[]).includes(extension.toLowerCase())
}
export function generateVideoKey(
extension: string,
date = new Date(),
layout: StorageLayout = 'date'
): string {
const id = generateRandomId(12)
const ext = sanitizeFileExtension(extension) || 'mp4'
if (layout === 'flat') {
return `${VIDEO_FOLDER}/${id}.${ext}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${VIDEO_FOLDER}/${year}/${month}/${id}.${ext}`
}
/** 视频床 key(videos/…),扩展名必须在视频白名单内 */
export function validateVideoKey(key: string): boolean {
if (!key || typeof key !== 'string') return false
if (key.startsWith('/')) return false
if (key.includes('..')) return false
if (key.includes('//')) return false
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
if (parts[0] !== VIDEO_FOLDER) return false
const filename = parts[parts.length - 1]!
if (!FILE_NAME_PATTERN.test(filename)) return false
if (!isVideoKeyExtension(extensionFromFilename(filename))) return false
for (let i = 1; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return false
}
return true
}
/** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */
export function toCanonicalImageKey(key: string): string | null {
if (validateImageKey(key)) return key
if (!key || key.startsWith('/') || key.includes('..') || key.includes('//')) return null
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return null
if (parts[0] === DEFAULT_FOLDER) return null
const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return null
for (let i = 0; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return null
}
const canonical = `${DEFAULT_FOLDER}/${key}`
return validateImageKey(canonical) ? canonical : null
}