feat: working on signed url for private files

This commit is contained in:
Abdalhamid Alhamad
2024-12-12 09:46:38 +03:00
parent 83fc634d25
commit 220a03cc46
20 changed files with 359 additions and 2818 deletions

2975
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@
"dependencies": { "dependencies": {
"@abdalhamid/hello": "^2.0.0", "@abdalhamid/hello": "^2.0.0",
"@hamid/hello": "file:../libraries/test-package", "@hamid/hello": "file:../libraries/test-package",
"@keyv/redis": "^4.0.2",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.1.2", "@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
@ -45,6 +46,7 @@
"amqp-connection-manager": "^4.1.14", "amqp-connection-manager": "^4.1.14",
"amqplib": "^0.10.4", "amqplib": "^0.10.4",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"google-libphonenumber": "^3.2.39", "google-libphonenumber": "^3.2.39",

View File

@ -7,6 +7,7 @@ import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional'; import { addTransactionalDataSource } from 'typeorm-transactional';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module'; import { LookupModule } from './common/modules/lookup/lookup.module';
import { OtpModule } from './common/modules/otp/otp.module'; import { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters'; import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
@ -45,6 +46,8 @@ import { TaskModule } from './task/task.module';
inject: [ConfigService], inject: [ConfigService],
}), }),
I18nModule.forRoot(buildI18nOptions()), I18nModule.forRoot(buildI18nOptions()),
CacheModule,
// App modules // App modules
AuthModule, AuthModule,
CustomerModule, CustomerModule,

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildKeyvOptions } from '~/core/module-options';
import { CacheService } from './services';
@Module({
providers: [
{
provide: 'CACHE_INSTANCE',
useFactory: (config: ConfigService) => buildKeyvOptions(config),
inject: [ConfigService],
},
CacheService,
],
exports: ['CACHE_INSTANCE', CacheService],
})
export class CacheModule {}

View File

@ -0,0 +1,19 @@
import { Inject, Injectable } from '@nestjs/common';
import { Cacheable } from 'cacheable';
@Injectable()
export class CacheService {
constructor(@Inject('CACHE_INSTANCE') private readonly cache: Cacheable) {}
get<T>(key: string): Promise<T | undefined> {
return this.cache.get(key);
}
async set<T>(key: string, value: T, ttl?: number | string): Promise<void> {
await this.cache.set(key, value, ttl);
}
async delete(key: string): Promise<void> {
await this.cache.delete(key);
}
}

View File

@ -0,0 +1 @@
export * from './cache.services';

View File

@ -1,11 +1,31 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DocumentType } from '~/document/enums'; import { DocumentType } from '~/document/enums';
import { DocumentService } from '~/document/services'; import { DocumentService, OciService } from '~/document/services';
@Injectable() @Injectable()
export class LookupService { export class LookupService {
constructor(private readonly documentService: DocumentService) {} constructor(private readonly documentService: DocumentService, private readonly ociService: OciService) {}
findDefaultAvatar() { async findDefaultAvatar() {
return this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR }); const documents = await this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
await Promise.all(
documents.map(async (document) => {
document.url = await this.ociService.generatePreSignedUrl(document);
}),
);
return documents;
}
async findDefaultTasksLogo() {
const documents = await this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_TASKS_LOGO });
await Promise.all(
documents.map(async (document) => {
document.url = await this.ociService.generatePreSignedUrl(document);
}),
);
return documents;
} }
} }

View File

@ -1,3 +1,4 @@
export * from '././keyv-options';
export * from './config-options'; export * from './config-options';
export * from './typeorm-options';
export * from './logger-options'; export * from './logger-options';
export * from './typeorm-options';

View File

@ -0,0 +1,8 @@
import KeyvRedis from '@keyv/redis';
import { ConfigService } from '@nestjs/config';
import { Cacheable } from 'cacheable';
export function buildKeyvOptions(config: ConfigService) {
const secondary = new KeyvRedis(config.get('REDIS_URL'));
return new Cacheable({ secondary, ttl: config.get('REDIS_TTL') });
}

View File

@ -4,4 +4,7 @@ export const BUCKETS: Record<DocumentType, string> = {
[DocumentType.PROFILE_PICTURE]: 'profile-pictures', [DocumentType.PROFILE_PICTURE]: 'profile-pictures',
[DocumentType.PASSPORT]: 'passports', [DocumentType.PASSPORT]: 'passports',
[DocumentType.DEFAULT_AVATAR]: 'avatars', [DocumentType.DEFAULT_AVATAR]: 'avatars',
[DocumentType.DEFAULT_TASKS_LOGO]: 'tasks-logo',
[DocumentType.CUSTOM_AVATAR]: 'avatars',
[DocumentType.CUSTOM_TASKS_LOGO]: 'tasks-logo',
}; };

View File

@ -1,14 +1,16 @@
import { Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CacheModule } from '~/common/modules/cache/cache.module';
import { DocumentController } from './controllers'; import { DocumentController } from './controllers';
import { Document } from './entities'; import { Document } from './entities';
import { DocumentRepository } from './repositories'; import { DocumentRepository } from './repositories';
import { DocumentService, OciService } from './services'; import { DocumentService, OciService } from './services';
@Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Document])], imports: [TypeOrmModule.forFeature([Document]), CacheModule],
controllers: [DocumentController], controllers: [DocumentController],
providers: [DocumentService, OciService, DocumentRepository], providers: [DocumentService, OciService, DocumentRepository],
exports: [DocumentService], exports: [DocumentService, OciService],
}) })
export class DocumentModule {} export class DocumentModule {}

View File

@ -15,6 +15,9 @@ export class DocumentMetaResponseDto {
@ApiProperty() @ApiProperty()
documentType!: DocumentType; documentType!: DocumentType;
@ApiProperty({ type: String })
url!: string | null;
@ApiProperty() @ApiProperty()
createdAt!: Date; createdAt!: Date;
@ -26,6 +29,7 @@ export class DocumentMetaResponseDto {
this.name = document.name; this.name = document.name;
this.extension = document.extension; this.extension = document.extension;
this.documentType = document.documentType; this.documentType = document.documentType;
this.url = document.url || null;
this.createdAt = document.createdAt; this.createdAt = document.createdAt;
this.updatedAt = document.updatedAt; this.updatedAt = document.updatedAt;
} }

View File

@ -42,4 +42,7 @@ export class Document {
@Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;
// virtual field
url?: string;
} }

View File

@ -2,4 +2,7 @@ export enum DocumentType {
PROFILE_PICTURE = 'PROFILE_PICTURE', PROFILE_PICTURE = 'PROFILE_PICTURE',
PASSPORT = 'PASSPORT', PASSPORT = 'PASSPORT',
DEFAULT_AVATAR = 'DEFAULT_AVATAR', DEFAULT_AVATAR = 'DEFAULT_AVATAR',
DEFAULT_TASKS_LOGO = 'DEFAULT_TASKS_LOGO',
CUSTOM_AVATAR = 'CUSTOM_AVATAR',
CUSTOM_TASKS_LOGO = 'CUSTOM_TASKS_LOGO',
} }

View File

@ -1,12 +1,16 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import moment from 'moment';
import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common'; import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common';
import { ObjectStorageClient } from 'oci-objectstorage'; import { ObjectStorageClient } from 'oci-objectstorage';
import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model';
import path from 'path'; import path from 'path';
import { CacheService } from '~/common/modules/cache/services';
import { BUCKETS } from '../constants'; import { BUCKETS } from '../constants';
import { UploadDocumentRequestDto } from '../dtos/request'; import { UploadDocumentRequestDto } from '../dtos/request';
import { UploadResponseDto } from '../dtos/response'; import { UploadResponseDto } from '../dtos/response';
import { Document } from '../entities';
import { generateNewFileName } from '../utils'; import { generateNewFileName } from '../utils';
@Injectable() @Injectable()
@ -20,7 +24,7 @@ export class OciService {
private readonly namespace: string = this.configService.getOrThrow('OCI_NAMESPACE'); private readonly namespace: string = this.configService.getOrThrow('OCI_NAMESPACE');
private readonly region: string = this.configService.getOrThrow('OCI_REGION'); private readonly region: string = this.configService.getOrThrow('OCI_REGION');
private readonly logger = new Logger(OciService.name); private readonly logger = new Logger(OciService.name);
constructor(private configService: ConfigService) { constructor(private configService: ConfigService, private readonly cacheService: CacheService) {
this.ociClient = new ObjectStorageClient({ this.ociClient = new ObjectStorageClient({
authenticationDetailsProvider: new SimpleAuthenticationDetailsProvider( authenticationDetailsProvider: new SimpleAuthenticationDetailsProvider(
this.tenancyId, this.tenancyId,
@ -60,4 +64,39 @@ export class OciService {
documentType, documentType,
}); });
} }
async generatePreSignedUrl(document?: Document): Promise<string | any> {
if (!document) {
return null;
}
const cachedUrl = await this.cacheService.get<string>(document.id);
if (cachedUrl) {
return cachedUrl;
}
const bucketName = BUCKETS[document.documentType];
const objectName = document.name;
const expiration = moment().add(2, 'hours').toDate();
try {
this.logger.debug(`Generating pre-signed url for object ${objectName} in bucket ${bucketName}`);
const res = await this.ociClient.createPreauthenticatedRequest({
namespaceName: this.namespace,
bucketName,
createPreauthenticatedRequestDetails: {
name: objectName,
accessType: CreatePreauthenticatedRequestDetails.AccessType.AnyObjectRead,
timeExpires: expiration,
objectName,
},
retryConfiguration: { terminationStrategy: { shouldTerminate: () => true } },
});
this.cacheService.set(document.id, res.preauthenticatedRequest.fullPath + objectName, '1h');
return res.preauthenticatedRequest.fullPath + objectName;
} catch (error) {
this.logger.error('Error generating pre-signed url', JSON.stringify(error));
return document.name;
}
}
} }

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { Junior } from '~/junior/entities'; import { Junior } from '~/junior/entities';
import { Relationship } from '~/junior/enums'; import { Relationship } from '~/junior/enums';
@ -12,13 +13,15 @@ export class JuniorResponseDto {
@ApiProperty({ example: 'relationship' }) @ApiProperty({ example: 'relationship' })
relationship!: Relationship; relationship!: Relationship;
@ApiProperty({ example: 'profilePictureId' }) @ApiProperty()
profilePictureId: string | null; profilePicture!: DocumentMetaResponseDto | null;
constructor(junior: Junior) { constructor(junior: Junior) {
this.id = junior.id; this.id = junior.id;
this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`; this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`;
this.relationship = junior.relationship; this.relationship = junior.relationship;
this.profilePictureId = junior.customer.user.profilePictureId; this.profilePicture = junior.customer.user.profilePicture
? new DocumentMetaResponseDto(junior.customer.user.profilePicture)
: null;
} }
} }

View File

@ -4,6 +4,7 @@ import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards'; import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { ApiDataPageResponse } from '~/core/decorators';
import { CustomParseUUIDPipe } from '~/core/pipes'; import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { CreateTaskRequestDto, TaskSubmissionRequestDto } from '../dtos/request'; import { CreateTaskRequestDto, TaskSubmissionRequestDto } from '../dtos/request';
@ -27,6 +28,7 @@ export class TaskController {
@Get() @Get()
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
@ApiDataPageResponse(TaskResponseDto)
async findTasks(@Query() query: TasksFilterOptions, @AuthenticatedUser() user: IJwtPayload) { async findTasks(@Query() query: TasksFilterOptions, @AuthenticatedUser() user: IJwtPayload) {
const [tasks, itemCount] = await this.taskService.findTasks(user, query); const [tasks, itemCount] = await this.taskService.findTasks(user, query);
return ResponseFactory.dataPage( return ResponseFactory.dataPage(

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { JuniorResponseDto } from '~/junior/dtos/response'; import { JuniorResponseDto } from '~/junior/dtos/response';
import { Task } from '~/task/entities'; import { Task } from '~/task/entities';
import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { TaskSubmission } from '~/task/entities/task-submissions.entity';
@ -28,6 +29,9 @@ export class TaskResponseDto {
@ApiProperty() @ApiProperty()
junior!: JuniorResponseDto; junior!: JuniorResponseDto;
@ApiProperty()
image!: DocumentMetaResponseDto;
@ApiProperty() @ApiProperty()
createdAt!: Date; createdAt!: Date;
@ -42,6 +46,7 @@ export class TaskResponseDto {
this.dueDate = task.dueDate; this.dueDate = task.dueDate;
this.rewardAmount = task.rewardAmount; this.rewardAmount = task.rewardAmount;
this.submission = task.submission; this.submission = task.submission;
this.image = new DocumentMetaResponseDto(task.image);
this.junior = new JuniorResponseDto(task.assignedTo); this.junior = new JuniorResponseDto(task.assignedTo);
this.createdAt = task.createdAt; this.createdAt = task.createdAt;
this.updatedAt = task.updatedAt; this.updatedAt = task.updatedAt;

View File

@ -32,7 +32,14 @@ export class TaskRepository {
findTask(where: FindOptionsWhere<Task>) { findTask(where: FindOptionsWhere<Task>) {
return this.taskRepository.findOne({ return this.taskRepository.findOne({
where, where,
relations: ['image', 'assignedTo', 'assignedTo.customer', 'assignedTo.customer.user', 'submission'], relations: [
'image',
'assignedTo',
'assignedTo.customer',
'assignedTo.customer.user',
'assignedTo.customer.user.profilePicture',
'submission',
],
}); });
} }
@ -44,6 +51,7 @@ export class TaskRepository {
.leftJoinAndSelect('task.assignedTo', 'assignedTo') .leftJoinAndSelect('task.assignedTo', 'assignedTo')
.leftJoinAndSelect('assignedTo.customer', 'customer') .leftJoinAndSelect('assignedTo.customer', 'customer')
.leftJoinAndSelect('customer.user', 'user') .leftJoinAndSelect('customer.user', 'user')
.leftJoinAndSelect('user.profilePicture', 'profilePicture')
.leftJoinAndSelect('task.submission', 'submission'); .leftJoinAndSelect('task.submission', 'submission');
if (roles.includes(Roles.GUARDIAN)) { if (roles.includes(Roles.GUARDIAN)) {

View File

@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm'; import { FindOptionsWhere } from 'typeorm';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { OciService } from '~/document/services';
import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request'; import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request';
import { Task } from '../entities'; import { Task } from '../entities';
import { SubmissionStatus, TaskStatus } from '../enums'; import { SubmissionStatus, TaskStatus } from '../enums';
@ -8,7 +9,7 @@ import { TaskRepository } from '../repositories';
@Injectable() @Injectable()
export class TaskService { export class TaskService {
constructor(private readonly taskRepository: TaskRepository) {} constructor(private readonly taskRepository: TaskRepository, private readonly ociService: OciService) {}
async createTask(userId: string, body: CreateTaskRequestDto) { async createTask(userId: string, body: CreateTaskRequestDto) {
const task = await this.taskRepository.createTask(userId, body); const task = await this.taskRepository.createTask(userId, body);
return this.findTask({ id: task.id }); return this.findTask({ id: task.id });
@ -24,8 +25,12 @@ export class TaskService {
return task; return task;
} }
findTasks(user: IJwtPayload, query: TasksFilterOptions) { async findTasks(user: IJwtPayload, query: TasksFilterOptions): Promise<[Task[], number]> {
return this.taskRepository.findTasks(user, query); const [tasks, count] = await this.taskRepository.findTasks(user, query);
await this.prepareTasksPictures(tasks);
return [tasks, count];
} }
async submitTask(userId: string, taskId: string, body: TaskSubmissionRequestDto) { async submitTask(userId: string, taskId: string, body: TaskSubmissionRequestDto) {
const task = await this.findTask({ id: taskId, assignedToId: userId }); const task = await this.findTask({ id: taskId, assignedToId: userId });
@ -68,4 +73,26 @@ export class TaskService {
await this.taskRepository.rejectSubmission(task.submission); await this.taskRepository.rejectSubmission(task.submission);
} }
async prepareTasksPictures(tasks: Task[]) {
await Promise.all(
tasks.map(async (task) => {
const [imageUrl, submissionUrl, profilePictureUrl] = await Promise.all([
this.ociService.generatePreSignedUrl(task.image),
this.ociService.generatePreSignedUrl(task.submission?.proofOfCompletion),
this.ociService.generatePreSignedUrl(task.assignedTo.customer.user.profilePicture),
]);
task.image.url = imageUrl;
if (task.submission) {
task.submission.proofOfCompletion.url = submissionUrl;
}
if (task.assignedTo.customer.user.profilePicture) {
task.assignedTo.customer.user.profilePicture.url = profilePictureUrl;
}
}),
);
}
} }