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

View File

@ -7,6 +7,7 @@ import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module';
import { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
@ -45,6 +46,8 @@ import { TaskModule } from './task/task.module';
inject: [ConfigService],
}),
I18nModule.forRoot(buildI18nOptions()),
CacheModule,
// App modules
AuthModule,
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 { DocumentType } from '~/document/enums';
import { DocumentService } from '~/document/services';
import { DocumentService, OciService } from '~/document/services';
@Injectable()
export class LookupService {
constructor(private readonly documentService: DocumentService) {}
findDefaultAvatar() {
return this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
constructor(private readonly documentService: DocumentService, private readonly ociService: OciService) {}
async findDefaultAvatar() {
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 './typeorm-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.PASSPORT]: 'passports',
[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 { CacheModule } from '~/common/modules/cache/cache.module';
import { DocumentController } from './controllers';
import { Document } from './entities';
import { DocumentRepository } from './repositories';
import { DocumentService, OciService } from './services';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([Document])],
imports: [TypeOrmModule.forFeature([Document]), CacheModule],
controllers: [DocumentController],
providers: [DocumentService, OciService, DocumentRepository],
exports: [DocumentService],
exports: [DocumentService, OciService],
})
export class DocumentModule {}

View File

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

View File

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

View File

@ -2,4 +2,7 @@ export enum DocumentType {
PROFILE_PICTURE = 'PROFILE_PICTURE',
PASSPORT = 'PASSPORT',
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 { ConfigService } from '@nestjs/config';
import { plainToInstance } from 'class-transformer';
import moment from 'moment';
import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common';
import { ObjectStorageClient } from 'oci-objectstorage';
import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model';
import path from 'path';
import { CacheService } from '~/common/modules/cache/services';
import { BUCKETS } from '../constants';
import { UploadDocumentRequestDto } from '../dtos/request';
import { UploadResponseDto } from '../dtos/response';
import { Document } from '../entities';
import { generateNewFileName } from '../utils';
@Injectable()
@ -20,7 +24,7 @@ export class OciService {
private readonly namespace: string = this.configService.getOrThrow('OCI_NAMESPACE');
private readonly region: string = this.configService.getOrThrow('OCI_REGION');
private readonly logger = new Logger(OciService.name);
constructor(private configService: ConfigService) {
constructor(private configService: ConfigService, private readonly cacheService: CacheService) {
this.ociClient = new ObjectStorageClient({
authenticationDetailsProvider: new SimpleAuthenticationDetailsProvider(
this.tenancyId,
@ -60,4 +64,39 @@ export class OciService {
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 { DocumentMetaResponseDto } from '~/document/dtos/response';
import { Junior } from '~/junior/entities';
import { Relationship } from '~/junior/enums';
@ -12,13 +13,15 @@ export class JuniorResponseDto {
@ApiProperty({ example: 'relationship' })
relationship!: Relationship;
@ApiProperty({ example: 'profilePictureId' })
profilePictureId: string | null;
@ApiProperty()
profilePicture!: DocumentMetaResponseDto | null;
constructor(junior: Junior) {
this.id = junior.id;
this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`;
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 { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { ApiDataPageResponse } from '~/core/decorators';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateTaskRequestDto, TaskSubmissionRequestDto } from '../dtos/request';
@ -27,6 +28,7 @@ export class TaskController {
@Get()
@UseGuards(AccessTokenGuard)
@ApiDataPageResponse(TaskResponseDto)
async findTasks(@Query() query: TasksFilterOptions, @AuthenticatedUser() user: IJwtPayload) {
const [tasks, itemCount] = await this.taskService.findTasks(user, query);
return ResponseFactory.dataPage(

View File

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

View File

@ -32,7 +32,14 @@ export class TaskRepository {
findTask(where: FindOptionsWhere<Task>) {
return this.taskRepository.findOne({
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('assignedTo.customer', 'customer')
.leftJoinAndSelect('customer.user', 'user')
.leftJoinAndSelect('user.profilePicture', 'profilePicture')
.leftJoinAndSelect('task.submission', 'submission');
if (roles.includes(Roles.GUARDIAN)) {

View File

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