Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import type { Asset } from '@prisma/client';
export type AssetType = 'novel_text' | 'image' | 'audio' | 'video' | 'document';
export interface StoredObject {
file_path: string;
size: bigint;
hash: string;
backend: 'local' | 'minio';
}
export interface SafeAsset {
id: string;
user_id: string | null;
project_id: string | null;
asset_type: string;
file_path: string;
mime_type: string | null;
width: number | null;
height: number | null;
duration: string | null;
size: string | null;
hash: string | null;
visibility: string;
status: string;
created_at: string;
}
export function toSafeAsset(asset: Asset): SafeAsset {
return {
id: asset.id.toString(),
user_id: asset.user_id?.toString() ?? null,
project_id: asset.project_id?.toString() ?? null,
asset_type: asset.asset_type,
file_path: asset.file_path,
mime_type: asset.mime_type,
width: asset.width,
height: asset.height,
duration: asset.duration?.toString() ?? null,
size: asset.size?.toString() ?? null,
hash: asset.hash,
visibility: asset.visibility,
status: asset.status,
created_at: asset.created_at.toISOString()
};
}
+163
View File
@@ -0,0 +1,163 @@
import {
BadRequestException,
Body,
Controller,
Get,
Inject,
Param,
Post,
Req,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { CurrentUser } from '../auth/current-user.decorator';
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';
const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES);
function parseUploadLimitBytes(value: string | undefined, fallback: number) {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/.exec(normalized);
if (!match) return fallback;
const numberValue = Number(match[1]);
const unit = match[2] || 'b';
const multiplier =
unit === 'gb' ? 1024 * 1024 * 1024 :
unit === 'mb' ? 1024 * 1024 :
unit === 'kb' ? 1024 :
1;
return Number.isFinite(numberValue) && numberValue > 0
? Math.floor(numberValue * multiplier)
: fallback;
}
@Controller()
@UseGuards(JwtAuthGuard)
export class AssetsController {
constructor(@Inject(AssetsService) private readonly assetsService: AssetsService) {}
@Post('assets/upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES
}
})
)
uploadAsset(
@CurrentUser() user: AuthRequestUser,
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadAssetDto & Record<string, unknown>
) {
const uploadFile = file ?? this.fileFromEncryptedBody(dto);
return this.assetsService.uploadAsset(
user,
uploadFile,
dto.asset_type || 'document',
dto.project_id
);
}
@Post('projects/:projectId/novel/upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES
}
})
)
uploadNovel(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@UploadedFile() file: Express.Multer.File,
@Body() body: Record<string, unknown>
) {
return this.assetsService.uploadNovelFile(user, projectId, file ?? this.fileFromEncryptedBody(body));
}
@Get('assets/:assetId')
getAsset(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) {
return this.assetsService.getAssetForUser(user, assetId);
}
@Get('assets/:assetId/download')
async downloadAsset(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Req() request: RequestWithApiCrypto,
@Res({ passthrough: true }) response: Response
) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
if (request.apiCrypto) {
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return {
filename: result.filename,
mime_type: result.asset.mime_type || 'application/octet-stream',
size: result.buffer.length,
content_base64: result.buffer.toString('base64')
};
}
response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream');
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename.replace(/"/g, '')}"`
);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return new StreamableFile(result.buffer);
}
private fileFromEncryptedBody(body: Record<string, unknown> | undefined) {
const filePayload = body?.file;
if (typeof filePayload !== 'object' || filePayload === null) {
throw new BadRequestException('Uploaded file is required');
}
const fileRecord = filePayload as Record<string, unknown>;
const originalName = String(fileRecord.original_name || fileRecord.name || 'upload.bin');
const mimeType = String(fileRecord.mime_type || 'application/octet-stream');
const contentBase64 = fileRecord.content_base64;
if (typeof contentBase64 !== 'string') {
throw new BadRequestException('Encrypted uploaded file content is required');
}
const buffer = Buffer.from(contentBase64, 'base64');
if (buffer.length > MAX_UPLOAD_BYTES) {
throw new BadRequestException('Uploaded file is too large');
}
return {
fieldname: 'file',
originalname: originalName,
encoding: '7bit',
mimetype: mimeType,
size: buffer.length,
buffer
} as Express.Multer.File;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectsModule } from '../projects/projects.module';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
import { PublicTempAssetsController } from './public-temp-assets.controller';
import { StorageService } from './storage.service';
@Module({
imports: [AuthModule, ProjectsModule],
controllers: [AssetsController, PublicTempAssetsController],
providers: [AssetsService, StorageService],
exports: [AssetsService, StorageService]
})
export class AssetsModule {}
+151
View File
@@ -0,0 +1,151 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AssetsService } from './assets.service';
import type { PrismaService } from '../prisma/prisma.service';
import type { ProjectsService } from '../projects/projects.service';
import type { StorageService } from './storage.service';
import type { AuthRequestUser } from '../auth/auth.types';
const user: AuthRequestUser = {
id: '1',
email: 'user@example.com',
role: 'user'
};
function createFile(overrides: Partial<Express.Multer.File> = {}): Express.Multer.File {
return {
fieldname: 'file',
originalname: 'novel.txt',
encoding: '7bit',
mimetype: 'text/plain',
size: 12,
buffer: Buffer.from('hello novel'),
destination: '',
filename: '',
path: '',
stream: undefined as never,
...overrides
};
}
describe('AssetsService', () => {
let prisma: {
project: { findUnique: ReturnType<typeof vi.fn> };
asset: { create: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> };
};
let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>;
let projectsService: Pick<ProjectsService, 'assertProjectOwner'>;
let service: AssetsService;
beforeEach(() => {
prisma = {
project: {
findUnique: vi.fn()
},
asset: {
create: vi.fn(),
findUnique: vi.fn()
}
};
storage = {
storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://novels/test.txt',
size: 12n,
hash: 'hash',
backend: 'local'
}),
readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('video bytes'))
};
projectsService = {
assertProjectOwner: vi.fn().mockResolvedValue(100n)
};
service = new AssetsService(
prisma as unknown as PrismaService,
storage as StorageService,
projectsService as ProjectsService
);
});
it('stores novel uploads as private assets', async () => {
prisma.asset.create.mockResolvedValue({
id: 200n,
user_id: 1n,
project_id: 100n,
asset_type: 'novel_text',
file_path: 'local://novels/test.txt',
file_url: null,
mime_type: 'text/plain',
width: null,
height: null,
duration: null,
size: 12n,
hash: 'hash',
visibility: 'private',
status: 'active',
created_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.uploadNovelFile(user, '100', createFile());
expect(storage.storePrivateFile).toHaveBeenCalledWith(
expect.objectContaining({ originalname: 'novel.txt' }),
'novels'
);
expect(prisma.asset.create).toHaveBeenCalledWith({
data: expect.objectContaining({
asset_type: 'novel_text',
visibility: 'private',
file_url: null
})
});
expect(result.asset.visibility).toBe('private');
expect(result.next_step).toBe('copyright_confirm');
});
it('rejects unsupported novel file types', async () => {
await expect(
service.uploadNovelFile(
user,
'100',
createFile({ originalname: 'novel.exe', mimetype: 'application/octet-stream' })
)
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects uploads to projects owned by others', async () => {
vi.mocked(projectsService.assertProjectOwner).mockRejectedValue(
new ForbiddenException('Project is private')
);
await expect(service.uploadNovelFile(user, '100', createFile())).rejects.toBeInstanceOf(
ForbiddenException
);
});
it('returns a private file buffer for owned assets', 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')
});
const result = await service.downloadAssetForUser(user, '300');
expect(storage.readPrivateFile).toHaveBeenCalledWith('local://videos/final.mp4');
expect(result.asset.id).toBe('300');
expect(result.filename).toBe('video-300.mp4');
expect(result.buffer.toString()).toBe('video bytes');
});
});
+192
View File
@@ -0,0 +1,192 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import type { Asset } 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 { StorageService } from './storage.service';
const ALLOWED_NOVEL_MIME_TYPES = new Set([
'text/plain',
'text/markdown',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/octet-stream'
]);
@Injectable()
export class AssetsService {
constructor(
@Inject(PrismaService)
private readonly prisma: PrismaService,
@Inject(StorageService)
private readonly storage: StorageService,
@Inject(ProjectsService)
private readonly projectsService: ProjectsService
) {}
async uploadAsset(
user: AuthRequestUser,
file: Express.Multer.File,
assetType: AssetType = 'document',
projectId?: string
) {
const projectBigInt = projectId
? await this.projectsService.assertProjectOwner(projectId, user)
: null;
const stored = await this.storage.storePrivateFile(file, assetType);
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(user.id),
project_id: projectBigInt,
asset_type: assetType,
file_path: stored.file_path,
file_url: null,
mime_type: file.mimetype || null,
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: 'active'
}
});
return {
asset: toSafeAsset(asset),
storage_backend: stored.backend
};
}
async uploadNovelFile(
user: AuthRequestUser,
projectId: string,
file: Express.Multer.File
) {
this.validateNovelFile(file);
const projectBigInt = await this.projectsService.assertProjectOwner(projectId, user);
const stored = await this.storage.storePrivateFile(file, 'novels');
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(user.id),
project_id: projectBigInt,
asset_type: 'novel_text',
file_path: stored.file_path,
file_url: null,
mime_type: file.mimetype || 'text/plain',
size: stored.size,
hash: stored.hash,
visibility: 'private',
status: 'active'
}
});
return {
asset: toSafeAsset(asset),
storage_backend: stored.backend,
next_step: 'copyright_confirm'
};
}
async getAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
return toSafeAsset(asset);
}
async downloadAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const buffer = await this.storage.readPrivateFile(asset.file_path);
return {
asset: toSafeAsset(asset),
buffer,
filename: this.buildDownloadFilename(asset)
};
}
private async findAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) }
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
throw new NotFoundException('Asset not found');
}
return asset;
}
private buildDownloadFilename(asset: Asset) {
const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path);
const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset';
return `${safeType}-${asset.id.toString()}${extension}`;
}
private extensionFromPath(filePath: string) {
const match = /\.([a-z0-9]+)$/i.exec(filePath);
return match ? `.${match[1].toLowerCase()}` : '';
}
private validateNovelFile(file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('Novel file is required');
}
const lowerName = file.originalname.toLowerCase();
const hasAllowedExtension =
lowerName.endsWith('.txt') ||
lowerName.endsWith('.md') ||
lowerName.endsWith('.docx') ||
lowerName.endsWith('.pdf');
if (!hasAllowedExtension || !ALLOWED_NOVEL_MIME_TYPES.has(file.mimetype)) {
throw new BadRequestException('Only txt, md, docx, and text pdf novel files are supported now');
}
}
private parseId(id: string) {
try {
return BigInt(id);
} catch {
throw new BadRequestException('Invalid id');
}
}
private extensionFromMime(mimeType: string | null | undefined) {
switch (mimeType) {
case 'video/mp4':
return '.mp4';
case 'audio/wav':
case 'audio/x-wav':
return '.wav';
case 'audio/mpeg':
return '.mp3';
case 'application/x-subrip':
return '.srt';
case 'image/svg+xml':
return '.svg';
case 'image/png':
return '.png';
case 'image/jpeg':
return '.jpg';
case 'text/plain':
return '.txt';
case 'text/markdown':
return '.md';
case 'application/pdf':
return '.pdf';
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return '.docx';
default:
return '';
}
}
}
@@ -0,0 +1,24 @@
import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import type { Response } from 'express';
import { StorageService } from './storage.service';
@Controller('public-temp-assets')
export class PublicTempAssetsController {
constructor(@Inject(StorageService) private readonly storage: StorageService) {}
@Get(':token')
async downloadTemporaryAsset(
@Param('token') token: string,
@Res({ passthrough: true }) response: Response
) {
const result = await this.storage.readTemporaryPublicFile(token);
response.setHeader('Content-Type', result.mimeType);
response.setHeader('Content-Length', result.buffer.length.toString());
response.setHeader('Content-Disposition', 'inline');
response.setHeader('Cache-Control', 'private, max-age=0, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer);
}
}
@@ -0,0 +1,43 @@
import { BadRequestException } from '@nestjs/common';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StorageService } from './storage.service';
describe('StorageService temporary public URLs', () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.PUBLIC_ASSET_BASE_URL;
delete process.env.PUBLIC_ASSET_SIGNING_SECRET;
});
it('creates a signed temporary URL and reads the private object through the token', async () => {
process.env.PUBLIC_ASSET_BASE_URL = 'https://api.example.com';
process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret';
const service = new StorageService();
const readSpy = vi.spyOn(service, 'readPrivateFile').mockResolvedValue(Buffer.from('video-bytes'));
const url = service.createTemporaryPublicUrl({
filePath: 'local://live-action-video-clips/source.mp4',
mimeType: 'video/mp4',
expiresInSeconds: 600
});
const token = new URL(url).pathname.split('/').pop() || '';
const result = await service.readTemporaryPublicFile(decodeURIComponent(token));
expect(url).toMatch(/^https:\/\/api\.example\.com\/api\/public-temp-assets\//);
expect(readSpy).toHaveBeenCalledWith('local://live-action-video-clips/source.mp4');
expect(result.mimeType).toBe('video/mp4');
expect(result.buffer.toString()).toBe('video-bytes');
});
it('requires a public base URL before minting temporary links', () => {
process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret';
const service = new StorageService();
expect(() =>
service.createTemporaryPublicUrl({
filePath: 'local://live-action-video-clips/source.mp4',
mimeType: 'video/mp4'
})
).toThrow(BadRequestException);
});
});
+284
View File
@@ -0,0 +1,284 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, 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';
import { Client } from 'minio';
import type { StoredObject } from './asset.types';
type TemporaryPublicFilePayload = {
file_path: string;
mime_type: string;
expires_at: number;
nonce: string;
};
@Injectable()
export class StorageService {
private readonly root = this.resolveLocalRoot(process.env.LOCAL_STORAGE_ROOT || '../storage');
private readonly privateBucket =
process.env.MINIO_BUCKET_PRIVATE || 'ai-manga-private';
async storePrivateFile(file: Express.Multer.File, prefix: string): Promise<StoredObject> {
if (!file?.buffer?.length) {
throw new BadRequestException('Uploaded file is empty');
}
if (this.shouldUseMinio()) {
return this.storeWithMinio(file, prefix);
}
return this.storeLocally(file, prefix);
}
async readPrivateFile(filePath: string): Promise<Buffer> {
if (filePath.startsWith('local://')) {
return this.readLocalObject(filePath);
}
if (filePath.startsWith('minio://')) {
return this.readMinioObject(filePath);
}
throw new BadRequestException('Unsupported storage path');
}
createTemporaryPublicUrl(input: {
filePath: string;
mimeType?: string | null;
expiresInSeconds?: number | null;
}) {
const baseUrl = this.resolvePublicAssetBaseUrl();
const expiresInSeconds = this.normalizeTemporaryUrlExpires(input.expiresInSeconds);
const payload: TemporaryPublicFilePayload = {
file_path: input.filePath,
mime_type: input.mimeType || 'application/octet-stream',
expires_at: Math.floor(Date.now() / 1000) + expiresInSeconds,
nonce: randomUUID()
};
const payloadPart = this.base64UrlEncode(Buffer.from(JSON.stringify(payload), 'utf8'));
const signature = this.signTemporaryPublicPayload(payloadPart);
const token = `${payloadPart}.${signature}`;
return `${baseUrl}/public-temp-assets/${encodeURIComponent(token)}`;
}
async readTemporaryPublicFile(token: string) {
const payload = this.verifyTemporaryPublicToken(token);
return {
buffer: await this.readPrivateFile(payload.file_path),
mimeType: payload.mime_type || 'application/octet-stream',
filePath: payload.file_path,
expiresAt: payload.expires_at
};
}
private async storeLocally(
file: Express.Multer.File,
prefix: string
): Promise<StoredObject> {
const safePrefix = prefix.replace(/[^a-z0-9/_-]/gi, '_');
const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype);
const hash = createHash('sha256').update(file.buffer).digest('hex');
const objectName = `${safePrefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`;
const fullPath = join(this.root, 'private', objectName);
await mkdir(join(this.root, 'private', safePrefix), { recursive: true });
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, file.buffer);
return {
file_path: `local://${objectName}`,
size: BigInt(file.size),
hash,
backend: 'local'
};
}
private async storeWithMinio(
file: Express.Multer.File,
prefix: string
): Promise<StoredObject> {
const client = new Client({
endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1',
port: Number(process.env.MINIO_PORT || 9000),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const exists = await client.bucketExists(this.privateBucket).catch(() => false);
if (!exists) {
await client.makeBucket(this.privateBucket);
}
const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype);
const hash = createHash('sha256').update(file.buffer).digest('hex');
const objectName = `${prefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`;
await client.putObject(this.privateBucket, objectName, file.buffer, file.size, {
'Content-Type': file.mimetype
});
return {
file_path: `minio://${this.privateBucket}/${objectName}`,
size: BigInt(file.size),
hash,
backend: 'minio'
};
}
private async readLocalObject(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));
}
private async readMinioObject(filePath: string) {
const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath);
if (!match) {
throw new BadRequestException('Invalid MinIO storage path');
}
const [, bucket, objectName] = match;
const client = new Client({
endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1',
port: Number(process.env.MINIO_PORT || 9000),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || ''
});
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
}
private async streamToBuffer(stream: Readable) {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
private shouldUseMinio() {
return process.env.STORAGE_DRIVER === 'minio';
}
private resolvePublicAssetBaseUrl() {
const configured =
process.env.PUBLIC_ASSET_BASE_URL ||
process.env.PUBLIC_API_BASE_URL ||
process.env.API_PUBLIC_BASE_URL ||
process.env.APP_PUBLIC_URL ||
process.env.PUBLIC_BASE_URL ||
'';
const normalized = configured.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(normalized)) {
throw new BadRequestException('PUBLIC_ASSET_BASE_URL_REQUIRED');
}
return normalized.endsWith('/api') ? normalized : `${normalized}/api`;
}
private normalizeTemporaryUrlExpires(value: number | null | undefined) {
const numeric = Number(value ?? process.env.PUBLIC_ASSET_URL_EXPIRES_SECONDS ?? 3600);
if (!Number.isFinite(numeric)) return 3600;
return Math.min(Math.max(Math.round(numeric), 60), 24 * 60 * 60);
}
private verifyTemporaryPublicToken(token: string): TemporaryPublicFilePayload {
const [payloadPart, signature] = String(token || '').split('.');
if (!payloadPart || !signature) {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_TOKEN');
}
const expected = this.signTemporaryPublicPayload(payloadPart);
if (!this.safeEqualBase64Url(signature, expected)) {
throw new ForbiddenException('INVALID_TEMP_PUBLIC_ASSET_SIGNATURE');
}
let payload: TemporaryPublicFilePayload;
try {
payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')) as TemporaryPublicFilePayload;
} catch {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PAYLOAD');
}
if (!payload.file_path || typeof payload.file_path !== 'string') {
throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PATH');
}
if (!Number.isFinite(payload.expires_at) || payload.expires_at < Math.floor(Date.now() / 1000)) {
throw new ForbiddenException('TEMP_PUBLIC_ASSET_EXPIRED');
}
return payload;
}
private signTemporaryPublicPayload(payloadPart: string) {
return this.base64UrlEncode(
createHmac('sha256', this.resolveTemporaryPublicAssetSecret())
.update(payloadPart)
.digest()
);
}
private resolveTemporaryPublicAssetSecret() {
const secret =
process.env.PUBLIC_ASSET_SIGNING_SECRET ||
process.env.TEMP_PUBLIC_ASSET_SECRET ||
process.env.JWT_SECRET ||
'';
if (!secret || secret.length < 16) {
throw new BadRequestException('PUBLIC_ASSET_SIGNING_SECRET_REQUIRED');
}
return secret;
}
private safeEqualBase64Url(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
private base64UrlEncode(buffer: Buffer) {
return buffer.toString('base64url');
}
private resolveLocalRoot(root: string) {
if (isAbsolute(root)) return root;
// Resolve relative storage roots from the backend package directory so
// starting the server from repo root or backend/ cannot split local files.
return resolve(__dirname, '../..', root);
}
private extensionFromMime(mimeType: string | undefined) {
switch (mimeType) {
case 'text/plain':
return '.txt';
case 'text/markdown':
return '.md';
case 'application/pdf':
return '.pdf';
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return '.docx';
default:
return '';
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { AssetType } from './asset.types';
export class UploadAssetDto {
asset_type?: AssetType;
project_id?: string;
}