Files
FileHost/server/utils/file-bed.ts
T
2026-10-17 16:09:31 +08:00

310 lines
8.4 KiB
TypeScript

import type { H3Event } from 'h3'
import type { UploadErrorItem } from '~/types/image'
import type { FileItem } from '~/types/file'
import {
MAX_FILE_BED_FILE_SIZE,
MAX_VIDEO_FILE_SIZE
} from './constants'
import { contentTypeFromKey } from './content-type'
import {
extensionFromFilename,
generateFileKey,
generateVideoKey
} 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'
import {
getAllowedVideoExtensions,
isForbiddenFileExtension,
isVideoExtensionAllowed
} from './upload-policy'
export { isForbiddenFileExtension }
/** 下载文件名清洗:保留中文等字符,去掉路径与控制字符 */
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: `不允许上传 ${extension.toUpperCase()} 文件`
}
}
}
}
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 (!isVideoExtensionAllowed(extension)) {
return {
error: {
name: originalName,
error: {
code: 'UNSUPPORTED_FILE_TYPE',
message: `只支持 ${getAllowedVideoExtensions().map(item => item.toUpperCase()).join(' / ')} 视频格式`
}
}
}
}
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
}