feat: Manage Images using Minio Service
This commit is contained in:
parent
d3fb5b7867
commit
4ceba378f0
2012
package-lock.json
generated
2012
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -22,21 +22,26 @@
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/bull": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/core": "^11.1.11",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/swagger": "^11.2.1",
|
||||
"@prisma/client": "^6.17.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-store": "^3.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"joi": "^18.0.2",
|
||||
"keycloak-connect": "^26.1.1",
|
||||
"minio": "^8.0.6",
|
||||
"nest-keycloak-connect": "^1.10.1",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@ -50,6 +55,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
|
||||
@ -6,6 +6,20 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
|
||||
import {
|
||||
KeycloakConnectModule,
|
||||
ResourceGuard,
|
||||
RoleGuard,
|
||||
AuthGuard,
|
||||
KeycloakConnectConfig,
|
||||
TokenValidation,
|
||||
} from 'nest-keycloak-connect';
|
||||
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
|
||||
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { MinioModule } from './minio/minio.module';
|
||||
import { ImageModule } from './image/image.module';
|
||||
|
||||
// Import des configurations
|
||||
|
||||
// Import des modules
|
||||
@ -14,11 +28,49 @@ import { MerchantModule } from './merchant/merchant.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration Module
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
//load: [],
|
||||
load: [keycloakConfig],
|
||||
validationSchema: keycloakConfigValidationSchema,
|
||||
validationOptions: {
|
||||
allowUnknown: false,
|
||||
abortEarly: true,
|
||||
},
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// Keycloak Connect Module (Async configuration)
|
||||
KeycloakConnectModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService): KeycloakConnectConfig => {
|
||||
const keycloakConfig = configService.get('keycloak');
|
||||
|
||||
return {
|
||||
authServerUrl: keycloakConfig.serverUrl,
|
||||
realm: keycloakConfig.realm,
|
||||
clientId: keycloakConfig.authClientId,
|
||||
secret: keycloakConfig.authClientSecret,
|
||||
useNestLogger: true,
|
||||
|
||||
/**
|
||||
* Validation OFFLINE :
|
||||
* Le token JWT est validé localement (RS256 signature)
|
||||
* Aucun appel réseau vers Keycloak pour introspection.
|
||||
*/
|
||||
tokenValidation: TokenValidation.OFFLINE,
|
||||
|
||||
// Optional: Add more Keycloak options as needed
|
||||
// publicClient: false,
|
||||
// verifyTokenAudience: true,
|
||||
// confidentialPort: 0,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
MinioModule,
|
||||
ImageModule,
|
||||
|
||||
CacheModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
@ -35,7 +87,24 @@ import { MerchantModule } from './merchant/merchant.module';
|
||||
}),
|
||||
MerchantModule
|
||||
],
|
||||
providers: [PrismaService],
|
||||
providers: [
|
||||
PrismaService,
|
||||
// Global Authentication Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
// Global Resource Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ResourceGuard,
|
||||
},
|
||||
// Global Role Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RoleGuard,
|
||||
},
|
||||
],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
87
src/config/keycloak.config.ts
Normal file
87
src/config/keycloak.config.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
export interface KeycloakConfig {
|
||||
serverUrl: string;
|
||||
realm: string;
|
||||
publicKey?: string;
|
||||
authClientId: string;
|
||||
authClientSecret: string;
|
||||
validationMode: string;
|
||||
tokenBufferSeconds: number;
|
||||
}
|
||||
|
||||
export default registerAs('keycloak', (): KeycloakConfig => ({
|
||||
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://iam.dcb.pixpay.sn',
|
||||
realm: process.env.KEYCLOAK_REALM || 'dcb-prod',
|
||||
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
|
||||
authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-cc-app',
|
||||
authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
|
||||
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
|
||||
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
|
||||
}));
|
||||
|
||||
|
||||
export const keycloakConfigValidationSchema = Joi.object({
|
||||
KEYCLOAK_SERVER_URL: Joi.string()
|
||||
.uri()
|
||||
.required()
|
||||
.messages({
|
||||
'string.uri': 'KEYCLOAK_SERVER_URL must be a valid URL',
|
||||
'any.required': 'KEYCLOAK_SERVER_URL is required'
|
||||
}),
|
||||
|
||||
KEYCLOAK_REALM: Joi.string()
|
||||
.required()
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_REALM is required',
|
||||
'string.pattern.base': 'KEYCLOAK_REALM can only contain letters, numbers, underscores and hyphens'
|
||||
}),
|
||||
|
||||
KEYCLOAK_PUBLIC_KEY: Joi.string()
|
||||
.required()
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_PUBLIC_KEY is required'
|
||||
}),
|
||||
|
||||
KEYCLOAK_CLIENT_ID: Joi.string()
|
||||
.required()
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_CLIENT_ID is required'
|
||||
}),
|
||||
|
||||
KEYCLOAK_CLIENT_SECRET: Joi.string()
|
||||
.required()
|
||||
.min(1)
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_CLIENT_SECRET is required',
|
||||
'string.min': 'KEYCLOAK_CLIENT_SECRET cannot be empty'
|
||||
}),
|
||||
|
||||
KEYCLOAK_VALIDATION_MODE: Joi.string()
|
||||
.required()
|
||||
.min(1)
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_VALIDATION_MODE is required',
|
||||
'string.min': 'KEYCLOAK_VALIDATION_MODE cannot be empty'
|
||||
}),
|
||||
|
||||
KEYCLOAK_TOKEN_BUFFER_SECONDS: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(300)
|
||||
.default(30)
|
||||
.messages({
|
||||
'number.max': 'KEYCLOAK_TOKEN_BUFFER_SECONDS cannot exceed 300 seconds'
|
||||
}),
|
||||
|
||||
// Variables d'environnement générales
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test', 'staging')
|
||||
.default('development'),
|
||||
|
||||
PORT: Joi.number()
|
||||
.port()
|
||||
.default(3000),
|
||||
}).unknown(true);
|
||||
230
src/image/image.controller.ts
Normal file
230
src/image/image.controller.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
Request,
|
||||
Query,
|
||||
Body,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ImageService } from './service/image.service';
|
||||
|
||||
export interface ImageUploadResponse {
|
||||
message : string;
|
||||
success: boolean;
|
||||
merchant: {
|
||||
id: string,
|
||||
name: string
|
||||
};
|
||||
data: {
|
||||
fileName: string;
|
||||
url: string;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadedAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('images')
|
||||
export class ImageController {
|
||||
constructor(private readonly imageService: ImageService) {}
|
||||
|
||||
/**
|
||||
* POST /merchants/:merchantId/logos/upload
|
||||
* Upload un logo pour un marchand
|
||||
*/
|
||||
@Post('merchants/:merchantId/logos/upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadLogo(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Body() body: { merchantName: string },
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Request() req,
|
||||
@Query('maxSizeMB') maxSizeMB?: string
|
||||
): Promise<ImageUploadResponse> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Aucun fichier fourni');
|
||||
}
|
||||
|
||||
const userId = req.user?.sub || req.user?.userId || 'anonymous';
|
||||
|
||||
const merchantName = body.merchantName;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
maxSizeMB: maxSizeMB ? parseInt(maxSizeMB) : undefined,
|
||||
validateType: true,
|
||||
metadata: {
|
||||
merchantName: merchantName
|
||||
}
|
||||
};
|
||||
|
||||
const result = await this.imageService.uploadMerchantLogo(
|
||||
file,
|
||||
userId,
|
||||
merchantId,
|
||||
merchantName,
|
||||
options
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logo uploadé avec succès',
|
||||
merchant: {
|
||||
id: merchantId,
|
||||
name: merchantName
|
||||
},
|
||||
data: result
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de l'upload: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /merchants/:merchantId/logos
|
||||
* Liste tous les logos d'un marchand
|
||||
*/
|
||||
@Get('merchants/:merchantId/logos')
|
||||
async listLogos(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Query('merchantName') merchantName: string
|
||||
) {
|
||||
try {
|
||||
const logos = await this.imageService.listMerchantLogos(merchantId, merchantName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
merchant: {
|
||||
id: merchantId,
|
||||
name: merchantName
|
||||
},
|
||||
count: logos.length,
|
||||
data: logos
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de la récupération des logos: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /merchants/:merchantId/logos/url
|
||||
* Récupère l'URL d'un logo (presigned ou non)
|
||||
*/
|
||||
@Get('merchants/:merchantId/logos/url')
|
||||
async getMerchantLogoUrl(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Query('fileName') fileName: string,
|
||||
@Query('signed') signed?: string,
|
||||
@Query('expiry') expiry?: string
|
||||
) {
|
||||
if (!fileName) {
|
||||
throw new BadRequestException('fileName requis');
|
||||
}
|
||||
|
||||
try {
|
||||
// Sécurité minimale : vérifier que le fichier appartient bien au merchant
|
||||
if (!fileName.startsWith(`merchants/${merchantId}_`)) {
|
||||
throw new BadRequestException('Accès non autorisé à ce fichier');
|
||||
}
|
||||
|
||||
const url =
|
||||
signed === 'true'
|
||||
? await this.imageService.getMerchantLogoSignedUrl(
|
||||
fileName,
|
||||
expiry ? parseInt(expiry, 10) : 3600
|
||||
)
|
||||
: await this.imageService.getMerchantLogoUrl(fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
merchantId,
|
||||
data: {
|
||||
fileName,
|
||||
url
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`Échec de la récupération de l'URL: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /merchants/:merchantId/logos/:fileName
|
||||
* Récupère l'URL d'un logo spécifique
|
||||
*/
|
||||
@Get('info/:fileName')
|
||||
async getLogoInfo(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Param('fileName') fileName: string,
|
||||
@Query('merchantName') merchantName: string,
|
||||
@Query('signed') signed?: string,
|
||||
@Query('expiry') expiry?: string
|
||||
) {
|
||||
try {
|
||||
|
||||
const info = await this.imageService.getLogoInfo(fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
merchant: {
|
||||
id: merchantId,
|
||||
name: merchantName
|
||||
},
|
||||
data:info
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de la récupération des infos: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /merchants/:merchantId/logos/:fileName
|
||||
* Supprime un logo
|
||||
*/
|
||||
@Delete(':fileName')
|
||||
async deleteLogo(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Param('fileName') fileName: string,
|
||||
@Body() body: { merchantName?: string },
|
||||
@Request() req
|
||||
) {
|
||||
const userId = req.user?.sub || req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Utilisateur non identifié');
|
||||
}
|
||||
|
||||
try {
|
||||
const merchantName = body.merchantName;
|
||||
|
||||
await this.imageService.deleteMerchantLogo(fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logo supprimé avec succès',
|
||||
merchant: {
|
||||
id: merchantId,
|
||||
name: merchantName
|
||||
},
|
||||
deletedFile: fileName
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de la suppression: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
9
src/image/image.module.ts
Normal file
9
src/image/image.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageController } from './image.controller';
|
||||
import { ImageService } from './service/image.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ImageController],
|
||||
providers: [ImageService],
|
||||
})
|
||||
export class ImageModule {}
|
||||
365
src/image/service/image.service.ts
Normal file
365
src/image/service/image.service.ts
Normal file
@ -0,0 +1,365 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MinioService, UploadResult, UploadOptions } from '../../minio/service/minio.service';
|
||||
|
||||
export interface ImageUploadOptions {
|
||||
maxSizeMB?: number;
|
||||
metadata?: Record<string, string>;
|
||||
validateType?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageUploadResponse {
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
url: string;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImageService {
|
||||
private readonly logger = new Logger(ImageService.name);
|
||||
|
||||
constructor(private readonly minioService: MinioService) {}
|
||||
|
||||
/**
|
||||
* Upload une image pour un marchand
|
||||
*/
|
||||
async uploadMerchantLogo(
|
||||
file: Express.Multer.File,
|
||||
userId: string,
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
options: ImageUploadOptions = {}
|
||||
): Promise<ImageUploadResponse> {
|
||||
try {
|
||||
const {
|
||||
maxSizeMB = 5,
|
||||
metadata = {},
|
||||
validateType = true
|
||||
} = options;
|
||||
|
||||
this.logger.log(`🖼️ Uploading logo for merchant : ${merchantName} ID: ${merchantId}`);
|
||||
this.logger.log(` File: ${file.originalname} (${file.size} bytes)`);
|
||||
this.logger.log(` Type: ${file.mimetype}`);
|
||||
this.logger.log(` User: ${userId}`);
|
||||
|
||||
// Validation de l'image
|
||||
this.validateImage(file, maxSizeMB, validateType);
|
||||
|
||||
// Générer un nom de fichier sécurisé
|
||||
const fileName = this.generateImageFileName(merchantId, merchantName, file.originalname);
|
||||
|
||||
// Préparer les métadonnées
|
||||
const customMetadata = this.createImageMetadata(userId, merchantId, merchantName, file, metadata);
|
||||
|
||||
// Options d'upload
|
||||
const uploadOptions: UploadOptions = {
|
||||
maxSizeMB,
|
||||
expirySeconds: 3600,
|
||||
customMetadata,
|
||||
validateContentType: validateType
|
||||
};
|
||||
|
||||
// Upload via MinIO
|
||||
const uploadResult = await this.minioService.uploadImage(
|
||||
file.buffer,
|
||||
fileName,
|
||||
file.mimetype,
|
||||
userId,
|
||||
merchantId,
|
||||
merchantName,
|
||||
uploadOptions
|
||||
);
|
||||
|
||||
// Convertir en format de réponse standard
|
||||
const response: ImageUploadResponse = {
|
||||
success: uploadResult.success,
|
||||
fileName: uploadResult.fileName,
|
||||
url: uploadResult.fileUrl,
|
||||
publicUrl: uploadResult.publicUrl,
|
||||
downloadUrl: uploadResult.downloadUrl,
|
||||
size: uploadResult.size,
|
||||
contentType: uploadResult.contentType,
|
||||
uploadedAt: uploadResult.uploadedAt
|
||||
};
|
||||
|
||||
this.logger.log(`✅ Logo uploaded successfully: ${fileName}`);
|
||||
this.logger.log(` Public URL: ${response.publicUrl}`);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to upload logo: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL publique d'un logo
|
||||
*/
|
||||
async getMerchantLogoUrl(fileName: string): Promise<string> {
|
||||
try {
|
||||
this.logger.log(`🔗 Getting logo URL: ${fileName}`);
|
||||
|
||||
const url = await this.minioService.getPublicUrl(fileName);
|
||||
|
||||
this.logger.log(`✅ Logo URL retrieved`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to get logo URL: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une URL signée temporaire pour un logo
|
||||
*/
|
||||
async getMerchantLogoSignedUrl(fileName: string, expirySeconds = 3600): Promise<string> {
|
||||
try {
|
||||
|
||||
this.logger.log(`🔗 Generating signed URL for logo: ${fileName}`);
|
||||
|
||||
const url = await this.minioService.getFileUrl(fileName, expirySeconds);
|
||||
|
||||
this.logger.log(`✅ Signed URL generated (valid for ${expirySeconds}s)`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to generate signed URL: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un logo
|
||||
*/
|
||||
async deleteMerchantLogo(fileName: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`🗑️ Deleting logo: ${fileName}`);
|
||||
|
||||
await this.minioService.deleteFile(fileName);
|
||||
|
||||
this.logger.log(`✅ Logo deleted: ${fileName}`);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to delete logo: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un logo existe
|
||||
*/
|
||||
async logoExists(fileName: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.minioService.fileExists(fileName);
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to check if logo exists: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations d'un logo
|
||||
*/
|
||||
async getLogoInfo(fileName: string): Promise<ImageUploadResponse> {
|
||||
try {
|
||||
this.logger.log(`📋 Getting logo info: ${fileName}`);
|
||||
|
||||
const fileInfo = await this.minioService.getFileInfo(fileName);
|
||||
const publicUrl = await this.minioService.getPublicUrl(fileName);
|
||||
const downloadUrl = await this.minioService.getFileUrl(fileName);
|
||||
|
||||
const response: ImageUploadResponse = {
|
||||
success: true,
|
||||
fileName,
|
||||
url: downloadUrl,
|
||||
publicUrl,
|
||||
downloadUrl,
|
||||
size: fileInfo.size,
|
||||
contentType: fileInfo.contentType || 'image/jpeg',
|
||||
uploadedAt: fileInfo.lastModified || new Date()
|
||||
};
|
||||
|
||||
this.logger.log(`✅ Logo info retrieved`);
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to get logo info: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les logos d'un marchand
|
||||
*/
|
||||
async listMerchantLogos(merchantId: string, merchantName: string): Promise<ImageUploadResponse[]> {
|
||||
try {
|
||||
this.logger.log(`📂 Listing logos for merchant: ${merchantName} ID: ${merchantId}`);
|
||||
|
||||
// Utiliser le pattern de chemin pour les logos du marchand
|
||||
const pathPrefix = `merchants/${merchantId}_${merchantName}/logos/`;
|
||||
|
||||
// Note: Vous devrez peut-être adapter listUserFiles ou créer une méthode spécifique
|
||||
// Pour l'instant, on utilise listUserFiles avec l'ID du marchand comme "utilisateur"
|
||||
const files = await this.minioService.listUserFiles(`merchants/${merchantId}_${merchantName}/`);
|
||||
|
||||
// Filtrer seulement les logos
|
||||
const logoFiles = files.filter(file =>
|
||||
file.includes('/logos/') &&
|
||||
this.isImageFile(file)
|
||||
);
|
||||
|
||||
// Récupérer les infos pour chaque logo
|
||||
const logos = await Promise.all(
|
||||
logoFiles.map(async (fileName) => {
|
||||
try {
|
||||
return await this.getLogoInfo(fileName);
|
||||
} catch (error) {
|
||||
this.logger.warn(`⚠️ Failed to get info for logo ${fileName}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filtrer les nulls et trier par date (plus récent d'abord)
|
||||
const validLogos = logos.filter((logo): logo is ImageUploadResponse => logo !== null);
|
||||
validLogos.sort((a, b) => b.uploadedAt.getTime() - a.uploadedAt.getTime());
|
||||
|
||||
this.logger.log(`✅ Found ${validLogos.length} logo(s) for merchant ${merchantId}`);
|
||||
return validLogos;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to list merchant logos: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Méthodes utilitaires
|
||||
*/
|
||||
|
||||
private validateImage(
|
||||
file: Express.Multer.File,
|
||||
maxSizeMB: number,
|
||||
validateType: boolean
|
||||
): void {
|
||||
// Validation de la taille
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
throw new Error(
|
||||
`Image too large. Maximum size: ${maxSizeMB}MB`
|
||||
);
|
||||
}
|
||||
|
||||
// Validation du type (optionnel)
|
||||
if (validateType) {
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.mimetype)) {
|
||||
throw new Error(
|
||||
`Invalid image type. Allowed types: ${allowedTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private generateImageFileName(
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
originalFileName: string
|
||||
): string {
|
||||
|
||||
const timestamp = Date.now();
|
||||
const randomString = Math.random().toString(36).substring(2, 8);
|
||||
|
||||
const safeMerchantName = this.normalizeForPath(merchantName);
|
||||
const safeMerchantId = this.normalizeForPath(merchantId);
|
||||
|
||||
// Extension propre
|
||||
const extension = originalFileName.includes('.')
|
||||
? originalFileName.substring(originalFileName.lastIndexOf('.')).toLowerCase()
|
||||
: '';
|
||||
|
||||
return `merchants/${safeMerchantId}_${safeMerchantName}/logos/${timestamp}_${randomString}${extension}`;
|
||||
}
|
||||
|
||||
|
||||
normalizeForPath(value: string): string {
|
||||
return value
|
||||
.normalize('NFD') // sépare les accents
|
||||
.replace(/[\u0300-\u036f]/g, '') // supprime les accents
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_') // tout sauf a-z0-9 → _
|
||||
.replace(/^_+|_+$/g, '') // retire _ début/fin
|
||||
.replace(/_+/g, '_'); // évite ____
|
||||
}
|
||||
|
||||
private createImageMetadata(
|
||||
userId: string,
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
file: Express.Multer.File,
|
||||
additionalMetadata: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const baseMetadata: Record<string, string> = {
|
||||
'user-id': userId,
|
||||
'merchant-id': merchantId,
|
||||
'merchant-name': merchantName,
|
||||
'original-name': file.originalname,
|
||||
'image-type': file.mimetype.split('/')[1] || 'unknown',
|
||||
'uploaded-at': new Date().toISOString(),
|
||||
...additionalMetadata
|
||||
};
|
||||
|
||||
return baseMetadata;
|
||||
}
|
||||
|
||||
private isImageFile(fileName: string): boolean {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
|
||||
const lowerFileName = fileName.toLowerCase();
|
||||
return imageExtensions.some(ext => lowerFileName.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille d'un fichier en texte lisible
|
||||
*/
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un fichier est une image basée sur son extension
|
||||
*/
|
||||
isImageByExtension(fileName: string): boolean {
|
||||
return this.isImageFile(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un type MIME est une image
|
||||
*/
|
||||
isImageMimeType(mimeType: string): boolean {
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml'
|
||||
];
|
||||
return allowedTypes.includes(mimeType);
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,7 @@ async function bootstrap() {
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
|
||||
credentials: false,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Swagger
|
||||
|
||||
9
src/minio/minio.module.ts
Normal file
9
src/minio/minio.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { MinioService } from './service/minio.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MinioService],
|
||||
exports: [MinioService],
|
||||
})
|
||||
export class MinioModule {}
|
||||
409
src/minio/service/minio.service.ts
Normal file
409
src/minio/service/minio.service.ts
Normal file
@ -0,0 +1,409 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import * as Minio from 'minio';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface UploadOptions {
|
||||
maxSizeMB?: number;
|
||||
expirySeconds?: number;
|
||||
customMetadata?: Record<string, string>;
|
||||
validateContentType?: boolean;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadedAt: Date;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
originalName: string;
|
||||
buffer: Buffer;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export interface PresignedUploadSession {
|
||||
uploadUrl: string;
|
||||
fileName: string;
|
||||
formData: Record<string, string>;
|
||||
metadata: Record<string, string>;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
expiresAt: Date;
|
||||
method: 'POST';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MinioService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MinioService.name);
|
||||
private minioClient: Minio.Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly endpoint: string;
|
||||
private readonly port: number;
|
||||
private readonly useSSL: boolean;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.bucketName = this.configService.get<string>('MINIO_BUCKET', 'bo-assets');
|
||||
this.endpoint = this.configService.get<string>('MINIO_ENDPOINT', 'localhost');
|
||||
this.port = Number(this.configService.get<string>('MINIO_API_PORT', '9000'));
|
||||
this.useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
||||
|
||||
const accessKey = this.configService.get<string>('MINIO_ROOT_USER', 'minioadmin');
|
||||
const secretKey = this.configService.get<string>('MINIO_ROOT_PASSWORD', 'minioadmin');
|
||||
|
||||
// Log de démarrage (sans afficher les credentials)
|
||||
this.logger.log(`🔧 Initializing MinIO client:`);
|
||||
this.logger.log(` Endpoint: ${this.endpoint}:${this.port}`);
|
||||
this.logger.log(` UseSSL: ${this.useSSL}`);
|
||||
this.logger.log(` Bucket: ${this.bucketName}`);
|
||||
this.logger.log(` Access Key: ${accessKey.substring(0, 3)}***`);
|
||||
|
||||
this.minioClient = new Minio.Client({
|
||||
endPoint: this.endpoint,
|
||||
port: this.port,
|
||||
useSSL: this.useSSL,
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
//await this.testConnection();
|
||||
await this.createBucketIfNotExists();
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to initialize MinIO: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à MinIO
|
||||
*/
|
||||
private async testConnection(): Promise<void> {
|
||||
try {
|
||||
this.logger.log('🔍 Testing MinIO connection...');
|
||||
|
||||
const buckets = await this.minioClient.listBuckets();
|
||||
|
||||
this.logger.log(`✅ MinIO connection successful! Found ${buckets.length} bucket(s)`);
|
||||
|
||||
buckets.forEach((bucket, index) => {
|
||||
this.logger.log(`🪣 [${index + 1}] ${bucket.name} (created: ${bucket.creationDate})`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ MinIO connection failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Crée le bucket s'il n'existe pas
|
||||
*/
|
||||
private async createBucketIfNotExists() {
|
||||
try {
|
||||
const exists = await this.minioClient.bucketExists(this.bucketName);
|
||||
|
||||
if (!exists) {
|
||||
this.logger.log(`📦 Creating bucket: ${this.bucketName}`);
|
||||
|
||||
// Créer le bucket avec la région
|
||||
await this.minioClient.makeBucket(this.bucketName, 'us-east-1');
|
||||
|
||||
// Attendre un peu que le bucket soit créé
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Définir une politique pour rendre les fichiers publics en lecture
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${this.bucketName}/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**await this.minioClient.setBucketPolicy(
|
||||
this.bucketName,
|
||||
JSON.stringify(policy),
|
||||
);*/
|
||||
|
||||
this.logger.log(`✅ Bucket "${this.bucketName}" created and configured`);
|
||||
} else {
|
||||
this.logger.log(`✅ Bucket "${this.bucketName}" already exists`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`❌ Error with bucket: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage(
|
||||
fileBuffer: Buffer,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
userId: string,
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
options: UploadOptions = {}
|
||||
): Promise<UploadResult> {
|
||||
|
||||
const {
|
||||
expirySeconds = 3600,
|
||||
} = options;
|
||||
|
||||
this.logger.log(`📤 Starting upload: ${fileName}`);
|
||||
this.logger.log(` User: ${userId}, Merchant: ${merchantName} ID: ${merchantId}`);
|
||||
this.logger.log(` Type: ${contentType}, Size: ${fileBuffer.length} bytes`);
|
||||
|
||||
// 1. Générer une URL pré-signée PUT (la plus simple)
|
||||
const presignedUrl = await this.minioClient.presignedPutObject(
|
||||
this.bucketName,
|
||||
fileName,
|
||||
expirySeconds
|
||||
);
|
||||
|
||||
this.logger.log(` Generated presigned URL: ${presignedUrl.substring(0, 100)}...`);
|
||||
|
||||
// 2. Upload via l'URL pré-signée (simple fetch)
|
||||
const uploadStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await axios.put(presignedUrl, fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
timeout: 30000, // 30 secondes timeout
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const uploadDuration = Date.now() - uploadStartTime;
|
||||
this.logger.log(` Upload completed in ${uploadDuration}ms (status: ${response.status})`);
|
||||
} else {
|
||||
throw new Error(`Upload failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const uploadDuration = Date.now() - uploadStartTime;
|
||||
this.logger.log(` Upload completed in ${uploadDuration}ms`);
|
||||
|
||||
// 3. Attendre un peu et vérifier
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 4. Vérifier que le fichier existe
|
||||
const stat = await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
// 5. Générer les URLs de téléchargement
|
||||
const [fileUrl, downloadUrl] = await Promise.all([
|
||||
this.minioClient.presignedGetObject(this.bucketName, fileName, expirySeconds),
|
||||
this.minioClient.presignedGetObject(this.bucketName, fileName, 7 * 24 * 3600)
|
||||
]);
|
||||
|
||||
// 6. Générer l'URL publique
|
||||
const publicUrl = await this.getPublicUrl(fileName);
|
||||
|
||||
const result: UploadResult = {
|
||||
success: true,
|
||||
fileName,
|
||||
fileUrl,
|
||||
publicUrl,
|
||||
downloadUrl,
|
||||
size: stat.size,
|
||||
contentType: stat.metaData['content-type'] || contentType,
|
||||
uploadedAt: new Date()
|
||||
};
|
||||
|
||||
this.logger.log(`✅ Upload successful: ${fileName}`);
|
||||
this.logger.log(` Public URL: ${publicUrl}`);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response) {
|
||||
throw new Error(`Upload failed: ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
throw new Error('Upload failed: No response received');
|
||||
} else {
|
||||
throw new Error(`Upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une URL pré-signée temporaire pour téléchargement
|
||||
*/
|
||||
async getFileUrl(fileName: string, expirySeconds = 3600): Promise<string> {
|
||||
try {
|
||||
this.logger.log(`🔗 Generating presigned URL for: ${fileName}`);
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
const url = await this.minioClient.presignedGetObject(
|
||||
this.bucketName,
|
||||
fileName,
|
||||
expirySeconds,
|
||||
);
|
||||
|
||||
this.logger.log(`✅ URL generated (valid for ${expirySeconds}s)`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
this.logger.error(`❌ File not found: ${fileName}`);
|
||||
throw new Error(`File not found: ${fileName}`);
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Error generating URL: ${error.message}`);
|
||||
throw new Error(`Failed to generate URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL publique directe (si bucket public)
|
||||
* Vérifie d'abord que le fichier existe
|
||||
*/
|
||||
async getPublicUrl(fileName: string): Promise<string> {
|
||||
try {
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
const protocol = this.useSSL ? 'https' : 'http';
|
||||
const portSuffix =
|
||||
(this.useSSL && this.port === 443) ||
|
||||
(!this.useSSL && this.port === 80)
|
||||
? ''
|
||||
: `:${this.port}`;
|
||||
|
||||
const url = `${protocol}://${this.endpoint}${portSuffix}/${this.bucketName}/${fileName}`;
|
||||
|
||||
this.logger.log(`🔗 Public URL: ${url}`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
this.logger.error(`❌ File not found: ${fileName}`);
|
||||
throw new Error(`File not found: ${fileName}`);
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Error generating public URL: ${error.message}`);
|
||||
throw new Error(`Failed to generate public URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un fichier
|
||||
*/
|
||||
async deleteFile(fileName: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`🗑️ Deleting: ${fileName}`);
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
await this.minioClient.removeObject(this.bucketName, fileName);
|
||||
|
||||
this.logger.log(`✅ Deleted: ${fileName}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
this.logger.warn(`⚠️ File not found (already deleted?): ${fileName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Delete failed: ${error.message}`, error.stack);
|
||||
throw new Error(`Failed to delete file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les fichiers d'un utilisateur
|
||||
*/
|
||||
async listUserFiles(userId: string): Promise<string[]> {
|
||||
try {
|
||||
this.logger.log(`📂 Listing files for user: ${userId}`);
|
||||
|
||||
const stream = this.minioClient.listObjects(
|
||||
this.bucketName,
|
||||
`${userId}/`,
|
||||
true,
|
||||
);
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (obj) => {
|
||||
if (obj.name) {
|
||||
files.push(obj.name);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
this.logger.error(`❌ List failed: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
this.logger.log(`✅ Found ${files.length} file(s) for user ${userId}`);
|
||||
resolve(files);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ List error: ${error.message}`);
|
||||
throw new Error(`Failed to list files: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un fichier existe
|
||||
*/
|
||||
async fileExists(fileName: string): Promise<boolean> {
|
||||
try {
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les infos d'un fichier
|
||||
*/
|
||||
async getFileInfo(fileName: string): Promise<any> {
|
||||
try {
|
||||
const stat = await this.minioClient.statObject(this.bucketName, fileName);
|
||||
return {
|
||||
size: stat.size,
|
||||
lastModified: stat.lastModified,
|
||||
etag: stat.etag,
|
||||
contentType: stat.metaData['content-type'],
|
||||
metaData: stat.metaData,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to get file info: ${error.message}`);
|
||||
throw new Error(`File info error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user