feat: expand novel IP and production workflows

This commit is contained in:
www
2026-09-18 08:14:05 +02:00
parent b2ae4600b4
commit d9c81a3ac0
235 changed files with 117971 additions and 2721 deletions
+29 -2
View File
@@ -9,6 +9,23 @@ export interface StoredObject {
backend: 'local' | 'minio';
}
export interface SafeAssetGeneration {
source: string;
display_name: string | null;
provider_id: string | null;
provider_type: string | null;
provider_code: string | null;
provider_name: string | null;
model_name: string | null;
task_id: string | null;
task_type: string | null;
provider_request_id: string | null;
clip_id: string | null;
status: string | null;
cost_actual: string | null;
created_at: string | null;
}
export interface SafeAsset {
id: string;
user_id: string | null;
@@ -21,12 +38,17 @@ export interface SafeAsset {
duration: string | null;
size: string | null;
hash: string | null;
display_name: string | null;
selection_status: string;
selection_note: string | null;
metadata_json: unknown;
visibility: string;
status: string;
created_at: string;
generation: SafeAssetGeneration | null;
}
export function toSafeAsset(asset: Asset): SafeAsset {
export function toSafeAsset(asset: Asset, generation: SafeAssetGeneration | null = null): SafeAsset {
return {
id: asset.id.toString(),
user_id: asset.user_id?.toString() ?? null,
@@ -39,8 +61,13 @@ export function toSafeAsset(asset: Asset): SafeAsset {
duration: asset.duration?.toString() ?? null,
size: asset.size?.toString() ?? null,
hash: asset.hash,
display_name: asset.display_name ?? generation?.display_name ?? null,
selection_status: asset.selection_status,
selection_note: asset.selection_note,
metadata_json: asset.metadata_json,
visibility: asset.visibility,
status: asset.status,
created_at: asset.created_at.toISOString()
created_at: asset.created_at.toISOString(),
generation
};
}
+36 -8
View File
@@ -3,8 +3,10 @@ import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Patch,
Post,
Req,
Res,
@@ -21,7 +23,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types';
import type { RequestWithApiCrypto } from '../common/api-crypto.service';
import { AssetsService } from './assets.service';
import { UploadAssetDto } from './upload.dto';
import { UpdateAssetReviewStateDto, UploadAssetDto } from './upload.dto';
const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES);
@@ -99,16 +101,30 @@ export class AssetsController {
return this.assetsService.getAssetForUser(user, assetId);
}
@Get('assets/:assetId/preview-url')
getAssetPreviewUrl(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) {
return this.assetsService.createPreviewUrlForUser(user, assetId);
}
@Patch('assets/:assetId/review-state')
updateAssetReviewState(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Body() dto: UpdateAssetReviewStateDto
) {
return this.assetsService.updateAssetReviewState(user, assetId, dto);
}
@Get('assets/:assetId/download')
async downloadAsset(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Headers('range') range: string | undefined,
@Req() request: RequestWithApiCrypto,
@Res({ passthrough: true }) response: Response
) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
if (request.apiCrypto) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return {
filename: result.filename,
@@ -118,15 +134,27 @@ export class AssetsController {
};
}
response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream');
response.setHeader('Content-Length', result.buffer.length.toString());
const result = await this.assetsService.streamAssetForUser(user, assetId, range);
const mimeType = result.asset.mime_type || 'application/octet-stream';
const stream = result.stream;
const asciiFilename = result.filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
const dispositionType = mimeType.startsWith('video/') || mimeType.startsWith('audio/') ? 'inline' : 'attachment';
response.setHeader('Content-Type', mimeType);
response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', stream.contentLength.toString());
response.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename.replace(/"/g, '')}"`
`${dispositionType}; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(result.filename)}`
);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
if (stream.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${stream.start}-${stream.end}/${stream.size}`);
}
response.setHeader('Cache-Control', 'private, max-age=3600');
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
return new StreamableFile(stream.stream);
}
private fileFromEncryptedBody(body: Record<string, unknown> | undefined) {
+109 -4
View File
@@ -29,10 +29,7 @@ function createFile(overrides: Partial<Express.Multer.File> = {}): Express.Multe
}
describe('AssetsService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn> };
asset: { create: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> };
};
let prisma: any;
let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>;
let projectsService: Pick<ProjectsService, 'assertProjectOwner'>;
let service: AssetsService;
@@ -45,6 +42,18 @@ describe('AssetsService', () => {
asset: {
create: vi.fn(),
findUnique: vi.fn()
},
renderTask: {
findMany: vi.fn().mockResolvedValue([])
},
providerLog: {
findMany: vi.fn().mockResolvedValue([])
},
providerConfig: {
findUnique: vi.fn().mockResolvedValue(null)
},
videoClip: {
findMany: vi.fn().mockResolvedValue([])
}
};
storage = {
@@ -148,4 +157,100 @@ describe('AssetsService', () => {
expect(result.filename).toBe('video-300.mp4');
expect(result.buffer.toString()).toBe('video bytes');
});
it('returns generation provider and model for direct asset detail', async () => {
prisma.asset.findUnique.mockResolvedValue({
id: 300n,
user_id: 1n,
project_id: 100n,
asset_type: 'video',
file_path: 'local://videos/final.mp4',
file_url: null,
mime_type: 'video/mp4',
width: 1080,
height: 1920,
duration: 4,
size: 11n,
hash: 'video-hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
prisma.renderTask.findMany.mockResolvedValue([
{
id: 20n,
project_id: 100n,
episode_id: 10n,
shot_id: 8n,
task_type: 'live_action_video_clip_generate',
provider_id: 13n,
status: 'success',
input_json: {},
input_hash: null,
idempotency_key: null,
output_asset_id: 300n,
provider_request_id: 'task-external-1',
retry_count: 0,
max_retry: 0,
cost_estimate: null,
cost_actual: { toString: () => '0.35' },
error_code: null,
error_message: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
started_at: null,
finished_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerLog.findMany.mockResolvedValue([
{
id: 40n,
provider_id: 13n,
task_id: 20n,
project_id: 100n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
request_json: {},
response_json: {},
input_size: null,
output_size: null,
cost_estimate: null,
cost_actual: null,
status: 'success',
error_code: null,
error_message: null,
started_at: null,
finished_at: null,
created_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerConfig.findUnique.mockResolvedValue({
id: 13n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
display_name: '可灵图生视频',
mode: 'real',
model_name: 'kling-v2-1',
config_json: {},
fallback_provider_id: null,
is_enabled: true,
priority: 10,
rate_limit_json: {},
cost_rule_json: {},
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.getAssetForUser(user, '300');
expect(result.generation).toEqual(
expect.objectContaining({
provider_name: '可灵图生视频',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
task_id: '20',
provider_request_id: 'task-external-1'
})
);
});
});
+301 -8
View File
@@ -4,12 +4,13 @@ import {
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset } from '@prisma/client';
import { Prisma, type Asset, type ProviderConfig, type ProviderLog, type RenderTask, type VideoClip } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { AuthRequestUser } from '../auth/auth.types';
import { ProjectsService } from '../projects/projects.service';
import { toSafeAsset, type AssetType } from './asset.types';
import { toSafeAsset, type AssetType, type SafeAssetGeneration } from './asset.types';
import { StorageService } from './storage.service';
import type { UpdateAssetReviewStateDto } from './upload.dto';
const ALLOWED_NOVEL_MIME_TYPES = new Set([
'text/plain',
@@ -93,20 +94,97 @@ export class AssetsService {
async getAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
return toSafeAsset(asset);
const generation = await this.findAssetGeneration(asset);
return toSafeAsset(asset, generation);
}
async downloadAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const buffer = await this.storage.readPrivateFile(asset.file_path);
return {
asset: toSafeAsset(asset),
asset: toSafeAsset(asset, generation),
buffer,
filename: this.buildDownloadFilename(asset)
filename: this.buildDownloadFilename(asset, generation)
};
}
async streamAssetForUser(user: AuthRequestUser, assetId: string, rangeHeader?: string) {
const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const stream = await this.storage.streamPrivateFile(
asset.file_path,
asset.mime_type || 'application/octet-stream',
rangeHeader
);
return {
asset: toSafeAsset(asset, generation),
stream,
filename: this.buildDownloadFilename(asset, generation)
};
}
async createPreviewUrlForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const expiresInSeconds = this.previewUrlExpiresInSeconds(asset);
return {
asset: toSafeAsset(asset),
url: this.storage.createTemporaryPublicUrl({
filePath: asset.file_path,
mimeType: asset.mime_type || 'application/octet-stream',
expiresInSeconds
}),
expires_in_seconds: expiresInSeconds
};
}
async updateAssetReviewState(user: AuthRequestUser, assetId: string, dto: UpdateAssetReviewStateDto) {
const asset = await this.findEditableAssetForUser(user, assetId);
const data: Prisma.AssetUpdateInput = {};
if ('display_name' in dto) {
data.display_name = this.normalizeNullableText(dto.display_name, 255);
}
if ('selection_status' in dto) {
data.selection_status = this.normalizeSelectionStatus(dto.selection_status);
}
if ('selection_note' in dto) {
data.selection_note = this.normalizeNullableText(dto.selection_note, 1000);
}
if ('metadata_json' in dto) {
data.metadata_json = this.normalizeJsonObject(dto.metadata_json);
}
if (Object.keys(data).length === 0) {
return { asset: toSafeAsset(asset, await this.findAssetGeneration(asset)) };
}
const updated = await this.prisma.asset.update({
where: { id: asset.id },
data
});
const generation = await this.findAssetGeneration(updated);
return {
asset: toSafeAsset(updated, generation),
next_step: 'asset_review_state_saved'
};
}
private previewUrlExpiresInSeconds(asset: Asset) {
const mimeType = asset.mime_type || '';
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
return 24 * 60 * 60;
}
return 15 * 60;
}
private async findAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
@@ -116,20 +194,235 @@ export class AssetsService {
throw new NotFoundException('Asset not found');
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
if (user.role === 'admin' || asset.user_id?.toString() === user.id) {
return asset;
}
if (asset.project_id) {
const project = await this.prisma.project.findUnique({
where: { id: asset.project_id },
select: { user_id: true }
});
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private async findEditableAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
return asset;
if (user.role === 'admin' || asset.user_id?.toString() === user.id) {
return asset;
}
if (asset.project_id) {
const project = await this.prisma.project.findUnique({ where: { id: asset.project_id } });
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private buildDownloadFilename(asset: Asset) {
private async findAssetGeneration(asset: Asset): Promise<SafeAssetGeneration | null> {
const tasks = await this.prisma.renderTask.findMany({
where: { output_asset_id: asset.id }
});
const task = tasks.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (task) {
const logs = await this.prisma.providerLog.findMany({
where: { task_id: task.id }
});
const log = logs.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
const provider = await this.findGenerationProvider(task.provider_id ?? log?.provider_id ?? null);
return this.createAssetGenerationFromTask(task, provider, log);
}
const clips = await this.prisma.videoClip.findMany({
where: { output_asset_id: asset.id }
});
const clip = clips.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (!clip) {
return null;
}
const provider = await this.findGenerationProvider(clip.provider_id);
return this.createAssetGenerationFromClip(clip, provider);
}
private async findGenerationProvider(providerId: bigint | null | undefined) {
if (!providerId) return null;
return this.prisma.providerConfig.findUnique({ where: { id: providerId } });
}
private createAssetGenerationFromTask(
task: RenderTask,
provider: ProviderConfig | null,
log: ProviderLog | null
): SafeAssetGeneration {
const response = this.jsonObject(log?.response_json ?? null);
const responseProviderRequestId =
this.stringifyJsonText(response.provider_request_id) ||
this.stringifyJsonText(response.task_id) ||
this.stringifyJsonText(response.id) ||
null;
const providerCode =
log?.provider_code ??
this.providerCodeFromTask(task) ??
provider?.provider_code ??
null;
return {
source: 'render_task',
display_name: this.displayNameFromTaskInput(task),
provider_id: provider?.id.toString() ?? task.provider_id?.toString() ?? log?.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? log?.provider_type ?? null,
provider_code: providerCode,
provider_name: provider?.display_name ?? providerCode,
model_name: provider?.model_name ?? log?.model_name ?? this.modelNameFromTaskInput(task),
task_id: task.id.toString(),
task_type: task.task_type,
provider_request_id: task.provider_request_id ?? responseProviderRequestId,
clip_id: null,
status: task.status,
cost_actual: task.cost_actual?.toString() ?? log?.cost_actual?.toString() ?? null,
created_at: task.created_at.toISOString()
};
}
private createAssetGenerationFromClip(
clip: VideoClip,
provider: ProviderConfig | null
): SafeAssetGeneration {
return {
source: 'video_clip',
display_name: null,
provider_id: provider?.id.toString() ?? clip.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? 'VideoProvider',
provider_code: provider?.provider_code ?? null,
provider_name: provider?.display_name ?? provider?.provider_code ?? null,
model_name: provider?.model_name ?? null,
task_id: null,
task_type: 'video_clip',
provider_request_id: null,
clip_id: clip.id.toString(),
status: clip.status,
cost_actual: clip.cost_actual?.toString() ?? null,
created_at: clip.created_at.toISOString()
};
}
private providerCodeFromTask(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
const routerDecision = this.jsonObject(inputJson.router_decision ?? null);
const repairContext = this.jsonObject(inputJson.repair_context ?? null);
return (
this.stringifyJsonText(repairContext.provider_code) ||
this.stringifyJsonText(routerDecision.provider_code) ||
this.stringifyJsonText(inputJson.provider) ||
null
);
}
private modelNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
const routerDecision = this.jsonObject(inputJson.router_decision ?? null);
return (
this.stringifyJsonText(inputJson.model_name) ||
this.stringifyJsonText(inputJson.model) ||
this.stringifyJsonText(routerDecision.model_name) ||
this.stringifyJsonText(routerDecision.model) ||
null
);
}
private displayNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
return (
this.stringifyJsonText(inputJson.render_title) ||
this.stringifyJsonText(inputJson.display_name) ||
this.stringifyJsonText(inputJson.title) ||
null
);
}
private jsonObject(value: Prisma.InputJsonValue | Prisma.JsonValue | null | undefined) {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, Prisma.InputJsonValue | Prisma.JsonValue>
: {};
}
private stringifyJsonText(value: unknown) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
private buildDownloadFilename(asset: Asset, generation: SafeAssetGeneration | null = null) {
const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path);
const displayName = this.safeDownloadFilenameStem(asset.display_name ?? generation?.display_name ?? '');
if (displayName) {
return `${displayName}${extension}`;
}
const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset';
return `${safeType}-${asset.id.toString()}${extension}`;
}
private safeDownloadFilenameStem(value: string) {
return value
.replace(/[\\/:*?"<>|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
}
private normalizeSelectionStatus(value: unknown) {
const normalized = this.normalizeNullableText(value, 30) ?? 'candidate';
if (!['candidate', 'selected', 'rejected'].includes(normalized)) {
throw new BadRequestException('Invalid selection_status');
}
return normalized;
}
private normalizeNullableText(value: unknown, maxLength: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text) return null;
return text.slice(0, maxLength);
}
private normalizeJsonObject(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return Prisma.JsonNull;
}
return value as Prisma.InputJsonObject;
}
private extensionFromPath(filePath: string) {
const match = /\.([a-z0-9]+)$/i.exec(filePath);
return match ? `.${match[1].toLowerCase()}` : '';
@@ -1,4 +1,4 @@
import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import { Controller, Get, Headers, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import type { Response } from 'express';
import { StorageService } from './storage.service';
@@ -9,16 +9,23 @@ export class PublicTempAssetsController {
@Get(':token')
async downloadTemporaryAsset(
@Param('token') token: string,
@Headers('range') range: string | undefined,
@Res({ passthrough: true }) response: Response
) {
const result = await this.storage.readTemporaryPublicFile(token);
const result = await this.storage.streamTemporaryPublicFile(token, range);
response.setHeader('Content-Type', result.mimeType);
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', result.contentLength.toString());
if (result.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${result.start}-${result.end}/${result.size}`);
}
const cacheMaxAge = Math.max(0, Math.min(result.expiresAt - Math.floor(Date.now() / 1000), 24 * 60 * 60));
response.setHeader('Content-Disposition', 'inline');
response.setHeader('Cache-Control', 'private, max-age=0, no-store');
response.setHeader('Cache-Control', `private, max-age=${cacheMaxAge}, immutable`);
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
return new StreamableFile(result.stream);
}
}
+117 -5
View File
@@ -1,5 +1,6 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
@@ -12,6 +13,12 @@ type TemporaryPublicFilePayload = {
expires_at: number;
nonce: string;
};
type ByteRange = {
start: number;
end: number;
contentLength: number;
statusCode: 200 | 206;
};
@Injectable()
export class StorageService {
@@ -43,6 +50,10 @@ export class StorageService {
throw new BadRequestException('Unsupported storage path');
}
async streamPrivateFile(filePath: string, mimeType?: string | null, rangeHeader?: string) {
return this.streamStoredFile(filePath, mimeType || 'application/octet-stream', rangeHeader);
}
createTemporaryPublicUrl(input: {
filePath: string;
mimeType?: string | null;
@@ -74,6 +85,60 @@ export class StorageService {
};
}
async streamTemporaryPublicFile(token: string, rangeHeader?: string) {
const payload = this.verifyTemporaryPublicToken(token);
const result = await this.streamStoredFile(payload.file_path, payload.mime_type, rangeHeader);
return {
...result,
filePath: payload.file_path,
expiresAt: payload.expires_at
};
}
private async streamStoredFile(filePath: string, mimeType: string, rangeHeader?: string) {
if (filePath.startsWith('local://')) {
const fullPath = this.localObjectFullPath(filePath);
const stats = await stat(fullPath);
const range = this.resolveByteRange(rangeHeader, stats.size);
return {
stream: createReadStream(fullPath, { start: range.start, end: range.end }),
mimeType: mimeType || 'application/octet-stream',
size: stats.size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
if (filePath.startsWith('minio://')) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stats = await client.statObject(bucket, objectName);
const size = Number(stats.size);
const range = this.resolveByteRange(rangeHeader, size);
const stream = range.statusCode === 206
? await (client as unknown as {
getPartialObject: (bucketName: string, object: string, offset: number, length: number) => Promise<Readable>;
}).getPartialObject(bucket, objectName, range.start, range.contentLength)
: await client.getObject(bucket, objectName);
return {
stream,
mimeType: mimeType || 'application/octet-stream',
size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
throw new BadRequestException('Unsupported storage path');
}
private async storeLocally(
file: Express.Multer.File,
prefix: string
@@ -130,15 +195,25 @@ export class StorageService {
}
private async readLocalObject(filePath: string) {
return readFile(this.localObjectFullPath(filePath));
}
private async readMinioObject(filePath: string) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
}
private localObjectFullPath(filePath: string) {
const objectName = filePath.replace(/^local:\/\//, '');
if (!objectName || objectName.includes('..')) {
throw new BadRequestException('Invalid local storage path');
}
return readFile(join(this.root, 'private', objectName));
return join(this.root, 'private', objectName);
}
private async readMinioObject(filePath: string) {
private minioObject(filePath: string) {
const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath);
if (!match) {
throw new BadRequestException('Invalid MinIO storage path');
@@ -152,8 +227,45 @@ export class StorageService {
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
return { client, bucket, objectName };
}
private resolveByteRange(rangeHeader: string | undefined, size: number): ByteRange {
if (!rangeHeader) {
return {
start: 0,
end: Math.max(0, size - 1),
contentLength: size,
statusCode: 200
};
}
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!match || size <= 0) {
throw new BadRequestException('Invalid byte range');
}
const [, rawStart, rawEnd] = match;
const suffixLength = rawStart === '' ? Number(rawEnd) : null;
const start = suffixLength !== null
? Math.max(0, size - suffixLength)
: Number(rawStart);
const end = rawEnd && suffixLength === null
? Math.min(size - 1, Number(rawEnd))
: size - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) {
throw new BadRequestException('Invalid byte range');
}
return {
start,
end,
contentLength: end - start + 1,
statusCode: 206
};
}
private async streamToBuffer(stream: Readable) {
+7
View File
@@ -4,3 +4,10 @@ export class UploadAssetDto {
asset_type?: AssetType;
project_id?: string;
}
export class UpdateAssetReviewStateDto {
display_name?: string | null;
selection_status?: 'candidate' | 'selected' | 'rejected';
selection_note?: string | null;
metadata_json?: unknown;
}