310 lines
8.4 KiB
TypeScript
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
|
|
}
|