mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 00:46:42 +02:00
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:
parent
6239cc7749
commit
3fa7b027aa
17 changed files with 1225 additions and 78 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
3
apps/chat/apps/backend/src/storage/index.ts
Normal file
3
apps/chat/apps/backend/src/storage/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './storage.module';
|
||||
export * from './storage.service';
|
||||
export * from './storage.controller';
|
||||
137
apps/chat/apps/backend/src/storage/storage.controller.ts
Normal file
137
apps/chat/apps/backend/src/storage/storage.controller.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/backend/src/storage/storage.module.ts
Normal file
10
apps/chat/apps/backend/src/storage/storage.module.ts
Normal 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 {}
|
||||
152
apps/chat/apps/backend/src/storage/storage.service.ts
Normal file
152
apps/chat/apps/backend/src/storage/storage.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue