feat: expand novel IP and production workflows
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user