feat: add email service and storage module + fix runtime env vars

## Runtime Environment Fix
- Updated all web app hooks.server.ts to use $env/dynamic/private
- This allows Docker containers to inject env vars at runtime
- Updated docker-compose.staging.yml with HTTPS staging domains
- Fixes Mixed Content errors when accessing staging via domains

## New Features
- Added email service to mana-core-auth for sending emails
- Added storage module to chat backend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-10 02:22:34 +01:00
parent 6239cc7749
commit 3fa7b027aa
17 changed files with 1225 additions and 78 deletions

View file

@ -27,15 +27,18 @@
"@google/generative-ai": "^0.24.1",
"@manacore/shared-errors": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@types/multer": "^1.4.11",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"multer": "^1.4.5-lts.1",
"openai": "^4.77.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",

View file

@ -8,6 +8,7 @@ import { SpaceModule } from './space/space.module';
import { DocumentModule } from './document/document.module';
import { ModelModule } from './model/model.module';
import { HealthModule } from './health/health.module';
import { StorageModule } from './storage/storage.module';
@Module({
imports: [
@ -23,6 +24,7 @@ import { HealthModule } from './health/health.module';
DocumentModule,
ModelModule,
HealthModule,
StorageModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,3 @@
export * from './storage.module';
export * from './storage.service';
export * from './storage.controller';

View file

@ -0,0 +1,137 @@
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
UseInterceptors,
UploadedFile,
BadRequestException,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { StorageService } from './storage.service';
interface PresignedUploadRequest {
filename: string;
folder?: string;
}
@Controller('api/storage')
@UseGuards(JwtAuthGuard)
export class StorageController {
constructor(private readonly storageService: StorageService) {}
/**
* Upload a file directly
*/
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File,
@Body('folder') folder?: string
) {
if (!file) {
throw new BadRequestException('No file provided');
}
const result = await this.storageService.uploadFile(
user.userId,
file.originalname,
file.buffer,
{
folder,
}
);
return {
success: true,
data: result,
};
}
/**
* Get a presigned URL for client-side upload
*/
@Post('presigned-upload')
async getPresignedUpload(
@CurrentUser() user: CurrentUserData,
@Body() body: PresignedUploadRequest
) {
if (!body.filename) {
throw new BadRequestException('Filename is required');
}
const result = await this.storageService.getPresignedUploadUrl(user.userId, body.filename, {
folder: body.folder,
});
return {
success: true,
data: result,
};
}
/**
* Get a presigned URL for downloading
*/
@Get('download/:key(*)')
async getDownloadUrl(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
// Ensure user can only access their own files
if (!key.startsWith(`users/${user.userId}/`)) {
throw new NotFoundException('File not found');
}
const exists = await this.storageService.fileExists(key);
if (!exists) {
throw new NotFoundException('File not found');
}
const url = await this.storageService.getPresignedDownloadUrl(key);
return {
success: true,
data: { url },
};
}
/**
* Delete a file
*/
@Delete(':key(*)')
async deleteFile(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
// Ensure user can only delete their own files
if (!key.startsWith(`users/${user.userId}/`)) {
throw new NotFoundException('File not found');
}
const exists = await this.storageService.fileExists(key);
if (!exists) {
throw new NotFoundException('File not found');
}
await this.storageService.deleteFile(key);
return {
success: true,
message: 'File deleted',
};
}
/**
* List user's files
*/
@Get('list')
async listFiles(@CurrentUser() user: CurrentUserData, @Body('folder') folder?: string) {
const files = await this.storageService.listUserFiles(user.userId, folder);
return {
success: true,
data: { files },
};
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { StorageController } from './storage.controller';
@Module({
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -0,0 +1,152 @@
import { Injectable, Logger } from '@nestjs/common';
import {
createChatStorage,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
DOCUMENT_EXTENSIONS,
AUDIO_EXTENSIONS,
} from '@manacore/shared-storage';
import type { StorageClient, UploadResult } from '@manacore/shared-storage';
export interface FileUploadResult {
key: string;
url?: string;
contentType: string;
size: number;
}
export interface PresignedUploadData {
uploadUrl: string;
key: string;
expiresIn: number;
}
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...AUDIO_EXTENSIONS];
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private storage: StorageClient | null = null;
private getStorage(): StorageClient {
if (!this.storage) {
this.storage = createChatStorage();
}
return this.storage;
}
/**
* Upload a file to storage
*/
async uploadFile(
userId: string,
filename: string,
data: Buffer,
options?: { folder?: string; public?: boolean }
): Promise<FileUploadResult> {
// Validate file size (MAX_FILE_SIZE is in bytes)
if (!validateFileSize(data.length, MAX_FILE_SIZE / (1024 * 1024))) {
throw new Error(`File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`);
}
// Validate file extension
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
throw new Error(
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
);
}
const contentType = getContentType(filename);
const key = generateUserFileKey(userId, filename, options?.folder);
const storage = this.getStorage();
const result: UploadResult = await storage.upload(key, data, {
contentType,
public: options?.public ?? false,
});
this.logger.log(`File uploaded: ${key} (${data.length} bytes)`);
return {
key: result.key,
url: result.url,
contentType,
size: data.length,
};
}
/**
* Get a presigned URL for uploading (client-side upload)
*/
async getPresignedUploadUrl(
userId: string,
filename: string,
options?: { folder?: string; expiresIn?: number }
): Promise<PresignedUploadData> {
// Validate file extension
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
throw new Error(
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
);
}
const key = generateUserFileKey(userId, filename, options?.folder);
const expiresIn = options?.expiresIn ?? 3600; // 1 hour default
const storage = this.getStorage();
const uploadUrl = await storage.getUploadUrl(key, { expiresIn });
return {
uploadUrl,
key,
expiresIn,
};
}
/**
* Get a presigned URL for downloading
*/
async getPresignedDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
const storage = this.getStorage();
return storage.getDownloadUrl(key, { expiresIn });
}
/**
* Download a file from storage
*/
async downloadFile(key: string): Promise<Buffer> {
const storage = this.getStorage();
return storage.download(key);
}
/**
* Delete a file from storage
*/
async deleteFile(key: string): Promise<void> {
const storage = this.getStorage();
await storage.delete(key);
this.logger.log(`File deleted: ${key}`);
}
/**
* Check if a file exists
*/
async fileExists(key: string): Promise<boolean> {
const storage = this.getStorage();
return storage.exists(key);
}
/**
* List files for a user
*/
async listUserFiles(userId: string, folder?: string): Promise<string[]> {
const storage = this.getStorage();
const prefix = folder ? `users/${userId}/${folder}/` : `users/${userId}/`;
const files = await storage.list(prefix);
return files.map((f) => f.key);
}
}