Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/attachment access #485

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion api/src/attachment/attachment.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport';

import { UserModule } from '@/user/user.module';

import { AttachmentController } from './controllers/attachment.controller';
import { AttachmentRepository } from './repositories/attachment.repository';
import { SubscriberAttachmentRepository } from './repositories/subscriber-attachment.repository';
import { UserAttachmentRepository } from './repositories/user-attachment.repository';
import { AttachmentModel } from './schemas/attachment.schema';
import { AttachmentService } from './services/attachment.service';

Expand All @@ -21,8 +25,14 @@ import { AttachmentService } from './services/attachment.service';
PassportModule.register({
session: true,
}),
UserModule,
],
providers: [
AttachmentRepository,
UserAttachmentRepository,
SubscriberAttachmentRepository,
AttachmentService,
],
providers: [AttachmentRepository, AttachmentService],
controllers: [AttachmentController],
exports: [AttachmentService],
})
Expand Down
71 changes: 54 additions & 17 deletions api/src/attachment/controllers/attachment.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/

import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { BadRequestException } from '@nestjs/common/exceptions';
import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { Request } from 'express';

import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { ModelRepository } from '@/user/repositories/model.repository';
import { PermissionRepository } from '@/user/repositories/permission.repository';
import { ModelModel } from '@/user/schemas/model.schema';
import { PermissionModel } from '@/user/schemas/permission.schema';
import { ModelService } from '@/user/services/model.service';
import { PermissionService } from '@/user/services/permission.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
Expand All @@ -27,7 +35,7 @@ import {

import { attachment, attachmentFile } from '../mocks/attachment.mock';
import { AttachmentRepository } from '../repositories/attachment.repository';
import { AttachmentModel, Attachment } from '../schemas/attachment.schema';
import { Attachment, AttachmentModel } from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service';

import { AttachmentController } from './attachment.controller';
Expand All @@ -42,14 +50,30 @@ describe('AttachmentController', () => {
controllers: [AttachmentController],
imports: [
rootMongooseTestModule(installAttachmentFixtures),
MongooseModule.forFeature([AttachmentModel]),
MongooseModule.forFeature([
AttachmentModel,
PermissionModel,
ModelModel,
]),
],
providers: [
AttachmentService,
AttachmentRepository,
PermissionService,
PermissionRepository,
ModelService,
ModelRepository,
LoggerService,
EventEmitter2,
PluginService,
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
}).compile();
attachmentController =
Expand Down Expand Up @@ -78,29 +102,42 @@ describe('AttachmentController', () => {

describe('Upload', () => {
it('should throw BadRequestException if no file is selected to be uploaded', async () => {
const promiseResult = attachmentController.uploadFile({
file: undefined,
});
const promiseResult = attachmentController.uploadFile(
{} as Request,
{
file: undefined,
},
{ context: 'block_attachment' },
);
await expect(promiseResult).rejects.toThrow(
new BadRequestException('No file was selected'),
);
});

it('should upload attachment', async () => {
const id = '1'.repeat(24);
jest.spyOn(attachmentService, 'create');
const result = await attachmentController.uploadFile({
file: [attachmentFile],
});
expect(attachmentService.create).toHaveBeenCalledWith({
size: attachmentFile.size,
type: attachmentFile.mimetype,
name: attachmentFile.filename,
channel: {},
location: `/${attachmentFile.filename}`,
});
const result = await attachmentController.uploadFile(
{ session: { passport: { user: { id } } } } as Request,
{
file: [attachmentFile],
},
{ context: 'block_attachment' },
);
// const [name, ext] = attachmentFile.filename.split('.');
expect(attachmentService.create).toHaveBeenCalledWith(
expect.objectContaining({
size: attachmentFile.size,
type: attachmentFile.mimetype,
name: attachmentFile.filename,
ownerType: 'User',
owner: id,
context: 'block_attachment',
}),
);
expect(result).toEqualPayload(
[attachment],
[...IGNORED_TEST_FIELDS, 'url'],
[{ ...attachment, ownerType: 'User', owner: id }],
[...IGNORED_TEST_FIELDS, 'url', 'location'],
);
});
});
Expand Down
57 changes: 40 additions & 17 deletions api/src/attachment/controllers/attachment.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/

import { extname } from 'path';

import {
BadRequestException,
Controller,
Expand All @@ -18,32 +16,39 @@ import {
Param,
Post,
Query,
Req,
StreamableFile,
UnauthorizedException,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { Request } from 'express';
import { diskStorage, memoryStorage } from 'multer';
import { v4 as uuidv4 } from 'uuid';

import { config } from '@/config';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { Roles } from '@/utils/decorators/roles.decorator';
import { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { TFilterQuery } from '@/utils/types/filter.types';

import { AttachmentDownloadDto } from '../dto/attachment.dto';
import {
AttachmentContextParamDto,
AttachmentDownloadDto,
} from '../dto/attachment.dto';
import { AttachmentGuard } from '../guards/attachment-ability.guard';
import { Attachment } from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service';

@UseInterceptors(CsrfInterceptor)
@Controller('attachment')
@UseGuards(AttachmentGuard)
export class AttachmentController extends BaseController<Attachment> {
constructor(
private readonly attachmentService: AttachmentService,
Expand All @@ -61,7 +66,7 @@ export class AttachmentController extends BaseController<Attachment> {
async filterCount(
@Query(
new SearchFilterPipe<Attachment>({
allowedFields: ['name', 'type'],
allowedFields: ['name', 'type', 'context'],
}),
)
filters?: TFilterQuery<Attachment>,
Expand All @@ -72,10 +77,12 @@ export class AttachmentController extends BaseController<Attachment> {
@Get(':id')
async findOne(@Param('id') id: string): Promise<Attachment> {
const doc = await this.attachmentService.findOne(id);

if (!doc) {
this.logger.warn(`Unable to find Attachment by id ${id}`);
throw new NotFoundException(`Attachment with ID ${id} not found`);
}

return doc;
}

Expand All @@ -90,7 +97,9 @@ export class AttachmentController extends BaseController<Attachment> {
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Attachment>,
@Query(
new SearchFilterPipe<Attachment>({ allowedFields: ['name', 'type'] }),
new SearchFilterPipe<Attachment>({
allowedFields: ['name', 'type', 'context'],
}),
)
filters: TFilterQuery<Attachment>,
) {
Expand All @@ -114,26 +123,41 @@ export class AttachmentController extends BaseController<Attachment> {
if (config.parameters.storageMode === 'memory') {
return memoryStorage();
} else {
return diskStorage({
destination: config.parameters.uploadDir,
filename: (req, file, cb) => {
const name = file.originalname.split('.')[0];
const extension = extname(file.originalname);
cb(null, `${name}-${uuidv4()}${extension}`);
},
});
return diskStorage({});
}
})(),
}),
)
async uploadFile(
@Req() req: Request,
@UploadedFiles() files: { file: Express.Multer.File[] },
@Query() { context }: AttachmentContextParamDto,
): Promise<Attachment[]> {
if (!files || !Array.isArray(files?.file) || files.file.length === 0) {
throw new BadRequestException('No file was selected');
}

return await this.attachmentService.uploadFiles(files);
const userId = req.session?.passport?.user?.id;
if (!userId) {
throw new UnauthorizedException(
'Unexpected Error: Only authenticated users are allowed to upload',
);
}

const attachments = [];
for (const file of files.file) {
const attachment = await this.attachmentService.store(file, {
name: file.originalname,
size: file.size,
type: file.mimetype,
context,
owner: userId,
ownerType: 'User',
});
attachments.push(attachment);
}

return attachments;
}

/**
Expand All @@ -142,7 +166,6 @@ export class AttachmentController extends BaseController<Attachment> {
* @param params - The parameters identifying the attachment to download.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
*/
@Roles('public')
@Get('download/:id/:filename?')
async download(
@Param() params: AttachmentDownloadDto,
Expand Down
61 changes: 59 additions & 2 deletions api/src/attachment/dto/attachment.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsMimeType,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
Expand All @@ -18,10 +21,18 @@ import {

import { ChannelName } from '@/channel/types';
import { ObjectIdDto } from '@/utils/dto/object-id.dto';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';

import {
AttachmentContext,
AttachmentOwnerType,
TAttachmentContext,
TAttachmentOwnerType,
} from '../types';

export class AttachmentMetadataDto {
/**
* Attachment original file name
* Attachment name
*/
@ApiProperty({ description: 'Attachment original file name', type: String })
@IsNotEmpty()
Expand All @@ -33,6 +44,7 @@ export class AttachmentMetadataDto {
*/
@ApiProperty({ description: 'Attachment size in bytes', type: Number })
@IsNotEmpty()
@IsNumber()
size: number;

/**
Expand All @@ -41,15 +53,50 @@ export class AttachmentMetadataDto {
@ApiProperty({ description: 'Attachment MIME type', type: String })
@IsNotEmpty()
@IsString()
@IsMimeType()
type: string;

/**
* Attachment specia channel(s) metadata
* Attachment channel
*/
@ApiPropertyOptional({ description: 'Attachment channel', type: Object })
@IsNotEmpty()
@IsObject()
channel?: Partial<Record<ChannelName, any>>;

/**
* Attachment context
*/
@ApiPropertyOptional({
description: 'Attachment Context',
enum: Object.values(AttachmentContext),
})
@IsString()
@IsIn(Object.values(AttachmentContext))
context: TAttachmentContext;

/**
* Attachment Owner Type
*/
@ApiPropertyOptional({
description: 'Attachment Owner Type',
enum: Object.values(AttachmentOwnerType),
})
@IsString()
@IsIn(Object.values(AttachmentOwnerType))
ownerType?: TAttachmentOwnerType;

/**
* Attachment Owner : Subscriber or User ID
*/
@ApiPropertyOptional({
description: 'Attachment Owner : Subscriber / User ID',
enum: Object.values(AttachmentContext),
})
@IsString()
@IsNotEmpty()
@IsObjectId({ message: 'Owner must be a valid ObjectId' })
owner?: string;
}

export class AttachmentCreateDto extends AttachmentMetadataDto {
Expand All @@ -75,3 +122,13 @@ export class AttachmentDownloadDto extends ObjectIdDto {
@IsOptional()
filename?: string;
}

export class AttachmentContextParamDto {
@ApiPropertyOptional({
description: 'Attachment Context',
enum: Object.values(AttachmentContext),
})
@IsString()
@IsIn(Object.values(AttachmentContext))
context?: TAttachmentContext;
}
Loading
Loading