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: secure attachment download upload URL's per context #453

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
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
9 changes: 8 additions & 1 deletion api/src/attachment/attachment.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport';

import { AttachmentController } from './controllers/attachment.controller';
import { AttachmentSubscriberRepository } from './repositories/attachment-subscriber.repository';
import { AttachmentUserRepository } from './repositories/attachment-user.repository';
import { AttachmentRepository } from './repositories/attachment.repository';
import { AttachmentModel } from './schemas/attachment.schema';
import { AttachmentService } from './services/attachment.service';
Expand All @@ -22,7 +24,12 @@ import { AttachmentService } from './services/attachment.service';
session: true,
}),
],
providers: [AttachmentRepository, AttachmentService],
providers: [
AttachmentRepository,
AttachmentUserRepository,
AttachmentSubscriberRepository,
AttachmentService,
],
controllers: [AttachmentController],
exports: [AttachmentService],
})
Expand Down
34 changes: 22 additions & 12 deletions api/src/attachment/controllers/attachment.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import {
} from '@/utils/test/test';

import { attachment, attachmentFile } from '../mocks/attachment.mock';
import { AttachmentSubscriberRepository } from '../repositories/attachment-subscriber.repository';
import { AttachmentUserRepository } from '../repositories/attachment-user.repository';
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 @@ -36,7 +38,6 @@ describe('AttachmentController', () => {
let attachmentController: AttachmentController;
let attachmentService: AttachmentService;
let attachmentToDelete: Attachment;

beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttachmentController],
Expand All @@ -47,6 +48,8 @@ describe('AttachmentController', () => {
providers: [
AttachmentService,
AttachmentRepository,
AttachmentUserRepository,
AttachmentSubscriberRepository,
LoggerService,
EventEmitter2,
PluginService,
Expand All @@ -60,9 +63,7 @@ describe('AttachmentController', () => {
});
});

afterAll(async () => {
await closeInMongodConnection();
});
afterAll(closeInMongodConnection);

afterEach(jest.clearAllMocks);

Expand All @@ -78,29 +79,38 @@ 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(
{
file: undefined,
},
{ context: 'user_avatar' },
);
await expect(promiseResult).rejects.toThrow(
new BadRequestException('No file was selected'),
);
});

it('should upload attachment', async () => {
jest.spyOn(attachmentService, 'create');
const result = await attachmentController.uploadFile({
file: [attachmentFile],
});
const result = await attachmentController.uploadFile(
{
file: [attachmentFile],
},
{ context: 'user_avatar' },
);
expect(attachmentService.create).toHaveBeenCalledWith({
size: attachmentFile.size,
type: attachmentFile.mimetype,
name: attachmentFile.filename,
channel: {},
location: `/${attachmentFile.filename}`,
context: 'user_avatar',
ownerType: 'User',
});

expect(result).toEqualPayload(
[attachment],
[...IGNORED_TEST_FIELDS, 'url'],
[...IGNORED_TEST_FIELDS, 'url', 'owner'],
);
});
});
Expand Down
67 changes: 58 additions & 9 deletions api/src/attachment/controllers/attachment.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,47 @@ import {
Param,
Post,
Query,
Session,
StreamableFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { Session as ExpressSession } from 'express-session';
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 { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { TFilterQuery } from '@/utils/types/filter.types';

import { AttachmentDownloadDto } from '../dto/attachment.dto';
import { Attachment } from '../schemas/attachment.schema';
import {
Attachment,
AttachmentPopulate,
AttachmentStub,
AttachmentSubscriberFull,
AttachmentUserFull,
TContextType,
} from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service';

@UseInterceptors(CsrfInterceptor)
@Controller('attachment')
export class AttachmentController extends BaseController<Attachment> {
export class AttachmentController extends BaseController<
Attachment,
AttachmentStub,
AttachmentPopulate,
AttachmentUserFull | AttachmentSubscriberFull
> {
constructor(
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
Expand All @@ -70,12 +84,24 @@ export class AttachmentController extends BaseController<Attachment> {
}

@Get(':id')
async findOne(@Param('id') id: string): Promise<Attachment> {
async findOne(
@Param('id') id: string,
@Query(PopulatePipe)
populate: string[],
) {
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`);
}

if (this.attachmentService.canPopulate(populate)) {
if (doc.ownerType === 'User')
return await this.attachmentService.findOneUserAndPopulate(id);
marrouchi marked this conversation as resolved.
Show resolved Hide resolved
else if (doc.ownerType === 'Subscriber')
return await this.attachmentService.findOneSubscriberAndPopulate(id);
}

return doc;
}

Expand All @@ -89,12 +115,27 @@ export class AttachmentController extends BaseController<Attachment> {
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Attachment>,
@Query(PopulatePipe)
populate: string[],
@Query(
new SearchFilterPipe<Attachment>({ allowedFields: ['name', 'type'] }),
new SearchFilterPipe<Attachment>({
allowedFields: ['name', 'type'],
}),
)
filters: TFilterQuery<Attachment>,
) {
return await this.attachmentService.find(filters, pageQuery);
const publicAttachmentsFilter: TFilterQuery<Attachment> = {
...filters,
context: {
$in: ['block_attachment', 'content_attachment'],
},
};
return this.canPopulate(populate)
? await this.attachmentService.findAndPopulate(
publicAttachmentsFilter,
pageQuery,
)
: await this.attachmentService.find(publicAttachmentsFilter, pageQuery);
}

/**
Expand Down Expand Up @@ -128,12 +169,21 @@ export class AttachmentController extends BaseController<Attachment> {
)
async uploadFile(
@UploadedFiles() files: { file: Express.Multer.File[] },
): Promise<Attachment[]> {
@Query() { context }: { context?: TContextType },
@Session() session?: ExpressSession,
) {
if (!files || !Array.isArray(files?.file) || files.file.length === 0) {
throw new BadRequestException('No file was selected');
}

return await this.attachmentService.uploadFiles(files);
const ownerId = session?.passport?.user?.id;

return await this.attachmentService.uploadFiles({
files: files.file,
ownerId,
context,
ownerType: 'User',
});
}

/**
Expand All @@ -142,7 +192,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
3 changes: 3 additions & 0 deletions api/src/attachment/mocks/attachment.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const attachment: Attachment = {
id: '65940d115178607da65c82b6',
createdAt: new Date(),
updatedAt: new Date(),
context: 'user_avatar',
owner: '0',
ownerType: 'User',
};

export const attachmentFile: Express.Multer.File = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 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 { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Query } from 'mongoose';

import { BaseRepository } from '@/utils/generics/base-repository';
import { QuerySortDto } from '@/utils/pagination/pagination-query.dto';

import {
Attachment,
ATTACHMENT_POPULATE,
AttachmentPopulate,
AttachmentSubscriberFull,
} from '../schemas/attachment.schema';

@Injectable()
export class AttachmentSubscriberRepository extends BaseRepository<
Attachment,
AttachmentPopulate,
AttachmentSubscriberFull
> {
constructor(
readonly eventEmitter: EventEmitter2,
@InjectModel(Attachment.name) readonly model: Model<Attachment>,
) {
super(
eventEmitter,
model,
Attachment,
ATTACHMENT_POPULATE,
AttachmentSubscriberFull,
);
}

/**
* @deprecated - This method is not allowed
*/
protected findAllQuery(
_sort?: QuerySortDto<Attachment>,
): Query<Attachment[], Attachment, object, Attachment, 'find', object> {
throw new Error('findAllQuery method is not allowed');
}

/**
* @deprecated - This method is not allowed
*/
async findAll(_sort?: QuerySortDto<Attachment>): Promise<Attachment[]> {
throw new Error('findAll method is not allowed');
}

/**
* @deprecated - This method is not allowed
*/
async findAllAndPopulate(
_sort?: QuerySortDto<Attachment>,
): Promise<AttachmentSubscriberFull[]> {
throw new Error('findAllAndPopulate method is not allowed');
}
}
67 changes: 67 additions & 0 deletions api/src/attachment/repositories/attachment-user.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 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 { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Query } from 'mongoose';

import { BaseRepository } from '@/utils/generics/base-repository';
import { QuerySortDto } from '@/utils/pagination/pagination-query.dto';

import {
Attachment,
ATTACHMENT_POPULATE,
AttachmentPopulate,
AttachmentUserFull,
} from '../schemas/attachment.schema';

@Injectable()
export class AttachmentUserRepository extends BaseRepository<
Attachment,
AttachmentPopulate,
AttachmentUserFull
> {
constructor(
readonly eventEmitter: EventEmitter2,
@InjectModel(Attachment.name) readonly model: Model<Attachment>,
) {
super(
eventEmitter,
model,
Attachment,
ATTACHMENT_POPULATE,
AttachmentUserFull,
);
}

/**
* @deprecated - This method is not allowed
*/
protected findAllQuery(
_sort?: QuerySortDto<Attachment>,
): Query<Attachment[], Attachment, object, Attachment, 'find', object> {
throw new Error('findAllQuery method is not allowed');
}

/**
* @deprecated - This method is not allowed
*/
async findAll(_sort?: QuerySortDto<Attachment>): Promise<Attachment[]> {
throw new Error('findAll method is not allowed');
}

/**
* @deprecated - This method is not allowed
*/
async findAllAndPopulate(
_sort?: QuerySortDto<Attachment>,
): Promise<AttachmentUserFull[]> {
throw new Error('findAllAndPopulate method is not allowed');
}
}
Loading
Loading