Initial AI manga platform
This commit is contained in:
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AssetType } from './asset.types';
|
||||
|
||||
export class UploadAssetDto {
|
||||
asset_type?: AssetType;
|
||||
project_id?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user