Files
lehuo/apps/api/src/participation/media.service.ts
T

242 lines
7.7 KiB
TypeScript

import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
type CreateMediaUploadIntentRequest,
type ServiceRecordAttachment,
} from '@lehuo/contracts';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { FilesRepository } from '../files/files.repository';
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
import type { File, FileBusinessType } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
const EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
@Injectable()
export class MediaService {
constructor(
@Inject(FilesRepository) private readonly files: FilesRepository,
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
) {}
async createIntent(
input: CreateMediaUploadIntentRequest,
actor: AuthContext,
request: RequestContext,
): Promise<CreateUploadIntentResponse> {
this.assertConfigured();
const filename = this.safeFilename(input.filename);
const extension = EXTENSIONS[input.mimeType];
if (!extension || !this.extensionMatches(filename, extension)) {
throw this.validation('文件扩展名与图片类型不一致');
}
const fingerprint = this.sensitive.lookupHash(
'cover-upload-intent',
JSON.stringify({ ...input, filename }),
);
const previous = await this.files.findByIdempotency(actor.userId, input.clientRequestId);
if (previous) {
if (
previous.requestFingerprint !== fingerprint ||
previous.businessType !== input.businessType
) {
throw this.duplicate();
}
return this.uploadResponse(previous, true);
}
const now = new Date();
const prefix =
input.businessType === 'OLD_ITEM_DONATION_IMAGE' ? 'private-old-items' : 'public-covers';
const objectKey = `${prefix}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
const file = await this.files.createPending(
{
publicId: randomUUID(),
businessType: input.businessType,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
provider: this.storage.providerName,
bucket: this.storage.bucket ?? '',
objectKey,
filenameEncrypted: this.sensitive.encrypt(filename),
mimeType: input.mimeType,
size: BigInt(input.size),
sha256: input.sha256,
},
actor,
request,
);
return this.uploadResponse(file, false);
}
async confirm(
publicId: string,
actor: AuthContext,
request: RequestContext,
): Promise<ConfirmUploadResponse> {
this.assertConfigured();
const file = await this.files.findByPublicId(publicId);
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
throw this.notFound();
if (
file.businessType !== 'PROJECT_COVER' &&
file.businessType !== 'ACTIVITY_COVER' &&
file.businessType !== 'OLD_ITEM_DONATION_IMAGE'
) {
throw this.notFound();
}
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
if (file.status !== 'UPLOAD_PENDING') throw this.notReady();
let metadata;
try {
metadata = await this.storage.headObject(file.objectKey);
} catch {
throw this.storageUnavailable();
}
if (!metadata) throw this.notReady();
if (
metadata.contentLength !== Number(file.size) ||
metadata.contentType !== file.mimeType ||
(file.sha256 !== null && metadata.sha256 !== file.sha256)
) {
await this.files.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH');
throw this.validation('图片校验未通过,请重新选择文件');
}
return { file: this.attachment(await this.files.markAvailable(publicId, actor, request)) };
}
async requireCover(
publicId: string | undefined,
businessType: FileBusinessType,
actor: AuthContext,
): Promise<File | null> {
if (!publicId) return null;
const file = await this.files.findByPublicId(publicId);
if (
!file ||
file.status !== 'AVAILABLE' ||
file.businessType !== businessType ||
file.uploaderUserId !== actor.userId ||
file.caseId !== null
) {
throw this.validation('封面图片无效或尚未确认上传');
}
return file;
}
async publicUrl(file: Pick<File, 'objectKey' | 'status'> | null): Promise<string | null> {
if (!file || file.status !== 'AVAILABLE' || !this.storage.configured) return null;
try {
return await this.storage.createDownloadUrl(file.objectKey, 600);
} catch {
return null;
}
}
private async uploadResponse(
file: File,
idempotentReplay: boolean,
): Promise<CreateUploadIntentResponse> {
// 缩短上传 URL 有效期至 120 秒,降低截获后滥用的风险窗口。
const expiresIn = 120;
let uploadUrl: string;
try {
uploadUrl = await this.storage.createUploadUrl(
file.objectKey,
file.mimeType,
file.sha256 ?? undefined,
expiresIn,
);
} catch {
throw this.storageUnavailable();
}
return {
file: this.attachment(file),
uploadUrl,
uploadHeaders: {
'Content-Type': file.mimeType,
...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}),
},
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
idempotentReplay,
};
}
private attachment(file: File): ServiceRecordAttachment {
return {
publicId: file.publicId,
filename: this.sensitive.decrypt(file.originalFilenameEncrypted),
mimeType: file.mimeType,
size: Number(file.size),
};
}
private safeFilename(value: string): string {
const filename = value.trim();
if (
!filename ||
filename.includes('/') ||
filename.includes('\\') ||
Array.from(filename).some((character) => character.charCodeAt(0) <= 31)
) {
throw this.validation('文件名不合法');
}
return filename;
}
private extensionMatches(filename: string, extension: string): boolean {
const lower = filename.toLowerCase();
return lower.endsWith(`.${extension}`) || (extension === 'jpg' && lower.endsWith('.jpeg'));
}
private assertConfigured(): void {
if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable();
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicate(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他文件',
HttpStatus.CONFLICT,
);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.FILE_NOT_FOUND,
'未找到该图片',
HttpStatus.NOT_FOUND,
);
}
private notReady(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.UPLOAD_NOT_READY,
'尚未收到上传文件,请完成上传后重试',
HttpStatus.CONFLICT,
);
}
private storageUnavailable(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'图片服务尚未配置或暂时不可用',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}