mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
✨ feat(auth): add avatar upload with S3/MinIO and subscription plans seed
- Add StorageModule for avatar uploads via S3/MinIO - Create presigned URL endpoint for direct browser uploads - Create direct upload endpoint (multipart/form-data) - Add manacore-storage bucket to shared-storage package - Add manacore-storage bucket to docker-compose.dev.yml - Create subscription plans seed script (pnpm db:seed:plans) - Plans: Free (150 credits), Pro (2000/€9.99/mo), Enterprise (10000/€49/mo) - Update TODO list with completed tasks
This commit is contained in:
parent
1e025b7e72
commit
c2842e2546
15 changed files with 756 additions and 102 deletions
|
|
@ -21,10 +21,12 @@
|
|||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed:dev": "tsx src/db/seed-dev-user.ts",
|
||||
"db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts"
|
||||
"db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts",
|
||||
"db:seed:plans": "tsx src/db/seeds/seed-subscription-plans.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
|
|
@ -33,6 +35,7 @@
|
|||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"axios": "^1.7.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-auth": "^1.4.3",
|
||||
|
|
@ -47,6 +50,7 @@
|
|||
"helmet": "^8.0.0",
|
||||
"jose": "^6.1.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"nodemailer": "^7.0.12",
|
||||
"postgres": "^3.4.5",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { FeedbackModule } from './feedback/feedback.module';
|
|||
import { HealthModule } from './health/health.module';
|
||||
import { ReferralsModule } from './referrals/referrals.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { MeModule } from './me/me.module';
|
||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
||||
|
|
@ -45,6 +46,7 @@ import { LoggerModule } from './common/logger';
|
|||
HealthModule,
|
||||
ReferralsModule,
|
||||
SettingsModule,
|
||||
StorageModule,
|
||||
TagsModule,
|
||||
MeModule,
|
||||
StripeModule,
|
||||
|
|
|
|||
|
|
@ -72,5 +72,9 @@ export default () => ({
|
|||
geminiApiKey: env.GOOGLE_GENAI_API_KEY || '',
|
||||
},
|
||||
|
||||
storage: {
|
||||
publicUrl: env.MANACORE_STORAGE_PUBLIC_URL || '',
|
||||
},
|
||||
|
||||
baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ const envSchema = z.object({
|
|||
// AI
|
||||
GOOGLE_GENAI_API_KEY: z.string().optional(),
|
||||
|
||||
// Storage
|
||||
MANACORE_STORAGE_PUBLIC_URL: z.string().optional(),
|
||||
|
||||
// Base URL for callbacks
|
||||
BASE_URL: z.string().url().optional(),
|
||||
|
||||
|
|
|
|||
184
services/mana-core-auth/src/db/seeds/seed-subscription-plans.ts
Normal file
184
services/mana-core-auth/src/db/seeds/seed-subscription-plans.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Seed subscription plans with Stripe Price IDs
|
||||
*
|
||||
* This script creates/updates the default subscription plans in the database.
|
||||
* Plans are idempotent - running multiple times won't create duplicates.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm db:seed:plans
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. Create products and prices in Stripe Dashboard
|
||||
* 2. Set STRIPE_* environment variables with the price IDs
|
||||
*
|
||||
* Stripe Products to create:
|
||||
* - Mana Free (price: 0 EUR)
|
||||
* - Mana Pro (prices: 9.99 EUR/month, 99 EUR/year)
|
||||
* - Mana Enterprise (contact sales)
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import postgres from 'postgres';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { plans } from '../schema/subscriptions.schema';
|
||||
|
||||
// Environment configuration
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL || 'postgresql://manacore:manacore@localhost:5432/manacore_auth';
|
||||
|
||||
// Stripe Price IDs from environment (or defaults for development)
|
||||
const STRIPE_CONFIG = {
|
||||
// Free plan (no Stripe price needed)
|
||||
FREE_PRODUCT_ID: process.env.STRIPE_FREE_PRODUCT_ID || '',
|
||||
|
||||
// Pro plan
|
||||
PRO_PRODUCT_ID: process.env.STRIPE_PRO_PRODUCT_ID || '',
|
||||
PRO_PRICE_MONTHLY: process.env.STRIPE_PRO_PRICE_MONTHLY || '', // e.g., price_xxx
|
||||
PRO_PRICE_YEARLY: process.env.STRIPE_PRO_PRICE_YEARLY || '', // e.g., price_xxx
|
||||
|
||||
// Enterprise plan
|
||||
ENTERPRISE_PRODUCT_ID: process.env.STRIPE_ENTERPRISE_PRODUCT_ID || '',
|
||||
ENTERPRISE_PRICE_MONTHLY: process.env.STRIPE_ENTERPRISE_PRICE_MONTHLY || '',
|
||||
ENTERPRISE_PRICE_YEARLY: process.env.STRIPE_ENTERPRISE_PRICE_YEARLY || '',
|
||||
};
|
||||
|
||||
// Plan definitions
|
||||
const PLANS = [
|
||||
{
|
||||
name: 'Free',
|
||||
description: 'Kostenlos starten mit grundlegenden Features',
|
||||
monthlyCredits: 150,
|
||||
priceMonthlyEuroCents: 0,
|
||||
priceYearlyEuroCents: 0,
|
||||
stripePriceIdMonthly: null,
|
||||
stripePriceIdYearly: null,
|
||||
stripeProductId: STRIPE_CONFIG.FREE_PRODUCT_ID || null,
|
||||
features: [
|
||||
'150 Credits pro Monat',
|
||||
'5 tägliche Gratis-Credits',
|
||||
'Zugang zu allen Apps',
|
||||
'Basis-Support',
|
||||
],
|
||||
maxTeamMembers: null,
|
||||
maxOrganizations: null,
|
||||
isDefault: true,
|
||||
isEnterprise: false,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
description: 'Für Power-User mit mehr Credits und Features',
|
||||
monthlyCredits: 2000,
|
||||
priceMonthlyEuroCents: 999, // 9.99 EUR
|
||||
priceYearlyEuroCents: 9900, // 99 EUR (2 months free)
|
||||
stripePriceIdMonthly: STRIPE_CONFIG.PRO_PRICE_MONTHLY || null,
|
||||
stripePriceIdYearly: STRIPE_CONFIG.PRO_PRICE_YEARLY || null,
|
||||
stripeProductId: STRIPE_CONFIG.PRO_PRODUCT_ID || null,
|
||||
features: [
|
||||
'2.000 Credits pro Monat',
|
||||
'20 tägliche Gratis-Credits',
|
||||
'Prioritäts-Support',
|
||||
'Erweiterte AI-Modelle',
|
||||
'API-Zugang',
|
||||
],
|
||||
maxTeamMembers: 5,
|
||||
maxOrganizations: 3,
|
||||
isDefault: false,
|
||||
isEnterprise: false,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
description: 'Für Teams und Unternehmen mit individuellen Anforderungen',
|
||||
monthlyCredits: 10000,
|
||||
priceMonthlyEuroCents: 4900, // 49 EUR
|
||||
priceYearlyEuroCents: 49000, // 490 EUR (2 months free)
|
||||
stripePriceIdMonthly: STRIPE_CONFIG.ENTERPRISE_PRICE_MONTHLY || null,
|
||||
stripePriceIdYearly: STRIPE_CONFIG.ENTERPRISE_PRICE_YEARLY || null,
|
||||
stripeProductId: STRIPE_CONFIG.ENTERPRISE_PRODUCT_ID || null,
|
||||
features: [
|
||||
'10.000 Credits pro Monat',
|
||||
'Unbegrenzte tägliche Credits',
|
||||
'Dedizierter Account Manager',
|
||||
'SLA-garantierte Verfügbarkeit',
|
||||
'Custom AI-Modelle',
|
||||
'SSO / SAML Integration',
|
||||
'Admin-Dashboard',
|
||||
],
|
||||
maxTeamMembers: null, // Unlimited
|
||||
maxOrganizations: null, // Unlimited
|
||||
isDefault: false,
|
||||
isEnterprise: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
];
|
||||
|
||||
async function seedPlans() {
|
||||
console.log('Seeding subscription plans...');
|
||||
console.log(`Database: ${DATABASE_URL.replace(/:[^@]+@/, ':***@')}`);
|
||||
|
||||
const client = postgres(DATABASE_URL);
|
||||
const db = drizzle(client);
|
||||
|
||||
try {
|
||||
for (const plan of PLANS) {
|
||||
// Check if plan exists
|
||||
const [existing] = await db.select().from(plans).where(eq(plans.name, plan.name)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
// Update existing plan
|
||||
await db
|
||||
.update(plans)
|
||||
.set({
|
||||
...plan,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plans.id, existing.id));
|
||||
console.log(`✓ Updated plan: ${plan.name}`);
|
||||
} else {
|
||||
// Insert new plan
|
||||
await db.insert(plans).values({
|
||||
...plan,
|
||||
} as any);
|
||||
console.log(`✓ Created plan: ${plan.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// List all plans
|
||||
const allPlans = await db.select().from(plans).orderBy(plans.sortOrder);
|
||||
console.log('\nAll subscription plans:');
|
||||
console.table(
|
||||
allPlans.map((p) => ({
|
||||
name: p.name,
|
||||
credits: p.monthlyCredits,
|
||||
monthly: `€${(p.priceMonthlyEuroCents / 100).toFixed(2)}`,
|
||||
yearly: `€${(p.priceYearlyEuroCents / 100).toFixed(2)}`,
|
||||
stripeMonthly: p.stripePriceIdMonthly || '(not set)',
|
||||
stripeYearly: p.stripePriceIdYearly || '(not set)',
|
||||
default: p.isDefault,
|
||||
}))
|
||||
);
|
||||
|
||||
console.log('\n✅ Subscription plans seeded successfully!');
|
||||
|
||||
if (!STRIPE_CONFIG.PRO_PRICE_MONTHLY || !STRIPE_CONFIG.PRO_PRICE_YEARLY) {
|
||||
console.log('\n⚠️ Warning: Stripe Price IDs not configured.');
|
||||
console.log(' Set these environment variables:');
|
||||
console.log(' - STRIPE_PRO_PRODUCT_ID');
|
||||
console.log(' - STRIPE_PRO_PRICE_MONTHLY');
|
||||
console.log(' - STRIPE_PRO_PRICE_YEARLY');
|
||||
console.log(' - STRIPE_ENTERPRISE_PRODUCT_ID');
|
||||
console.log(' - STRIPE_ENTERPRISE_PRICE_MONTHLY');
|
||||
console.log(' - STRIPE_ENTERPRISE_PRICE_YEARLY');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error seeding plans:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
seedPlans();
|
||||
3
services/mana-core-auth/src/storage/index.ts
Normal file
3
services/mana-core-auth/src/storage/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { StorageModule } from './storage.module';
|
||||
export { StorageService } from './storage.service';
|
||||
export { StorageController } from './storage.controller';
|
||||
113
services/mana-core-auth/src/storage/storage.controller.ts
Normal file
113
services/mana-core-auth/src/storage/storage.controller.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { StorageService } from './storage.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
interface GetUploadUrlDto {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
@ApiTags('storage')
|
||||
@Controller('storage')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class StorageController {
|
||||
constructor(private readonly storageService: StorageService) {}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for avatar upload
|
||||
*
|
||||
* Returns a presigned URL that the client can use to upload
|
||||
* the avatar directly to S3/MinIO. This is the recommended approach
|
||||
* for frontend uploads as it's more efficient.
|
||||
*/
|
||||
@Post('avatar/upload-url')
|
||||
@ApiOperation({
|
||||
summary: 'Get presigned URL for avatar upload',
|
||||
description:
|
||||
'Returns a presigned URL for direct upload to storage. Use this URL to PUT the file.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Returns presigned upload URL',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uploadUrl: { type: 'string', description: 'PUT this URL with the file' },
|
||||
fileUrl: { type: 'string', description: 'Public URL after upload' },
|
||||
key: { type: 'string', description: 'Storage key' },
|
||||
expiresIn: { type: 'number', description: 'URL expires in seconds' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid file type or storage not configured' })
|
||||
async getAvatarUploadUrl(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: GetUploadUrlDto
|
||||
): Promise<{
|
||||
uploadUrl: string;
|
||||
fileUrl: string;
|
||||
key: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
if (!dto.filename) {
|
||||
throw new BadRequestException('filename is required');
|
||||
}
|
||||
|
||||
return this.storageService.getAvatarUploadUrl(user.userId, dto.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload avatar directly (multipart/form-data)
|
||||
*
|
||||
* Alternative to presigned URLs. The file is uploaded to the backend
|
||||
* which then uploads it to S3/MinIO. Simpler but less efficient for
|
||||
* large files.
|
||||
*/
|
||||
@Post('avatar')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
},
|
||||
})
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Upload avatar directly',
|
||||
description: 'Upload avatar file directly to the server',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Avatar uploaded successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'Public URL of the uploaded avatar' },
|
||||
key: { type: 'string', description: 'Storage key' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid file type or size' })
|
||||
async uploadAvatar(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
): Promise<{ url: string; key: string }> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
|
||||
return this.storageService.uploadAvatar(user.userId, file.buffer, file.originalname);
|
||||
}
|
||||
}
|
||||
12
services/mana-core-auth/src/storage/storage.module.ts
Normal file
12
services/mana-core-auth/src/storage/storage.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { StorageService } from './storage.service';
|
||||
import { StorageController } from './storage.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [StorageController],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
176
services/mana-core-auth/src/storage/storage.service.ts
Normal file
176
services/mana-core-auth/src/storage/storage.service.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { Injectable, Logger, BadRequestException, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
createManaCoreStorage,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
validateFileSize,
|
||||
validateFileExtension,
|
||||
IMAGE_EXTENSIONS,
|
||||
} from '@manacore/shared-storage';
|
||||
import type { StorageClient } from '@manacore/shared-storage';
|
||||
|
||||
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
@Injectable()
|
||||
export class StorageService implements OnModuleInit {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private storage: StorageClient | null = null;
|
||||
private readonly publicUrl: string | undefined;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.publicUrl = this.configService.get<string>('storage.publicUrl');
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
this.storage = createManaCoreStorage(this.publicUrl);
|
||||
this.logger.log('Storage service initialized');
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Storage service not configured - avatar uploads will be disabled',
|
||||
error instanceof Error ? error.message : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is available
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.storage !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for avatar upload
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @param filename - Original filename
|
||||
* @returns Presigned upload URL and the final file URL
|
||||
*/
|
||||
async getAvatarUploadUrl(
|
||||
userId: string,
|
||||
filename: string
|
||||
): Promise<{
|
||||
uploadUrl: string;
|
||||
fileUrl: string;
|
||||
key: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
if (!this.storage) {
|
||||
throw new BadRequestException('Storage service is not configured');
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (!ext || !validateFileExtension(filename, IMAGE_EXTENSIONS)) {
|
||||
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
|
||||
}
|
||||
|
||||
// Generate unique key for avatar
|
||||
const key = `avatars/${userId}/${Date.now()}.${ext}`;
|
||||
const contentType = getContentType(filename);
|
||||
|
||||
// Get presigned upload URL (1 hour expiry)
|
||||
const expiresIn = 3600;
|
||||
const uploadUrl = await this.storage.getUploadUrl(key, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
// Construct the final public URL
|
||||
const fileUrl = await this.getPublicUrl(key);
|
||||
|
||||
this.logger.debug('Generated avatar upload URL', { userId, key });
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
fileUrl,
|
||||
key,
|
||||
expiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload avatar directly (for server-side uploads)
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @param buffer - File buffer
|
||||
* @param filename - Original filename
|
||||
* @returns Public URL of the uploaded avatar
|
||||
*/
|
||||
async uploadAvatar(
|
||||
userId: string,
|
||||
buffer: Buffer,
|
||||
filename: string
|
||||
): Promise<{ url: string; key: string }> {
|
||||
if (!this.storage) {
|
||||
throw new BadRequestException('Storage service is not configured');
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
if (!validateFileExtension(filename, IMAGE_EXTENSIONS)) {
|
||||
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (!validateFileSize(buffer.length, MAX_AVATAR_SIZE)) {
|
||||
throw new BadRequestException(
|
||||
`File too large. Maximum size: ${MAX_AVATAR_SIZE / 1024 / 1024}MB`
|
||||
);
|
||||
}
|
||||
|
||||
// Generate unique key for avatar
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
const key = `avatars/${userId}/${Date.now()}.${ext}`;
|
||||
|
||||
// Upload file
|
||||
const result = await this.storage.upload(key, buffer, {
|
||||
contentType: getContentType(filename),
|
||||
public: true,
|
||||
cacheControl: 'public, max-age=31536000', // 1 year cache
|
||||
});
|
||||
|
||||
const url = result.url || (await this.getPublicUrl(key));
|
||||
|
||||
this.logger.log('Avatar uploaded', { userId, key });
|
||||
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete avatar
|
||||
*
|
||||
* @param key - Storage key of the avatar
|
||||
*/
|
||||
async deleteAvatar(key: string): Promise<void> {
|
||||
if (!this.storage) {
|
||||
throw new BadRequestException('Storage service is not configured');
|
||||
}
|
||||
|
||||
await this.storage.delete(key);
|
||||
this.logger.log('Avatar deleted', { key });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for a key
|
||||
*/
|
||||
private async getPublicUrl(key: string): Promise<string> {
|
||||
if (!this.storage) {
|
||||
throw new BadRequestException('Storage service is not configured');
|
||||
}
|
||||
|
||||
// If we have a configured public URL, use it
|
||||
if (this.publicUrl) {
|
||||
return `${this.publicUrl}/${key}`;
|
||||
}
|
||||
|
||||
// Check if the storage has a public URL configured
|
||||
const publicUrl = this.storage.getPublicUrl(key);
|
||||
if (publicUrl) {
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
// Otherwise, get a presigned URL for reading
|
||||
return this.storage.getDownloadUrl(key, { expiresIn: 86400 * 365 }); // 1 year
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue