diff --git a/apps/api/src/campaign-application/campaign-application.controller.ts b/apps/api/src/campaign-application/campaign-application.controller.ts index 44f73e43..096a989c 100644 --- a/apps/api/src/campaign-application/campaign-application.controller.ts +++ b/apps/api/src/campaign-application/campaign-application.controller.ts @@ -9,6 +9,8 @@ import { Param, Patch, Post, + Response, + StreamableFile, UploadedFiles, UseInterceptors, } from '@nestjs/common' @@ -101,6 +103,33 @@ export class CampaignApplicationController { return this.campaignApplicationService.deleteFile(id, isAdminFlag, person) } + @Get('fileById/:id') + async fetchFile( + @Param('id') id: string, + @AuthenticatedUser() user: KeycloakTokenParsed, + @Response({ passthrough: true }) res, + ): Promise { + const person = await this.personService.findOneByKeycloakId(user.sub) + if (!person) { + Logger.error('No person found in database') + throw new NotFoundException('No person found in database') + } + + const isAdminFlag = isAdmin(user) + + const file = await this.campaignApplicationService.getFile(id, isAdminFlag, person) + + res.set({ + 'Content-Type': file?.mimetype, + 'Content-Disposition': 'attachment; filename="' + file.filename + '"', + 'Cache-Control': (file.mimetype ?? '').startsWith('image/') + ? 'public, s-maxage=15552000, stale-while-revalidate=15552000, immutable' + : 'no-store', + }) + + return new StreamableFile(file.stream) + } + @Patch(':id') async update( @Param('id') id: string, diff --git a/apps/api/src/campaign-application/campaign-application.service.spec.ts b/apps/api/src/campaign-application/campaign-application.service.spec.ts index a157126a..09cbdda2 100644 --- a/apps/api/src/campaign-application/campaign-application.service.spec.ts +++ b/apps/api/src/campaign-application/campaign-application.service.spec.ts @@ -46,6 +46,7 @@ describe('CampaignApplicationService', () => { const mockS3Service = { uploadObject: jest.fn(), deleteObject: jest.fn(), + streamFile: jest.fn().mockResolvedValue(1234), } const mockEmailService = { @@ -290,6 +291,18 @@ describe('CampaignApplicationService', () => { expect(result).toEqual(mockSingleCampaignApplication) expect(prismaMock.campaignApplication.findUnique).toHaveBeenCalledTimes(1) + expect(prismaMock.campaignApplication.findUnique).toHaveBeenCalledWith({ + where: { id: 'id' }, + include: { + documents: { + select: { + id: true, + filename: true, + mimetype: true, + }, + }, + }, + }) }) it('should throw a NotFoundException if no campaign-application is found', async () => { @@ -422,4 +435,72 @@ describe('CampaignApplicationService', () => { }) }) }) + + describe('getFile', () => { + it('should return a single campaign-application file', async () => { + prismaMock.campaignApplication.findFirst.mockResolvedValue(mockSingleCampaignApplication) + prismaMock.campaignApplicationFile.findFirst.mockResolvedValue({ + id: '123', + filename: 'my-file', + } as File) + + const result = await service.getFile('id', false, mockPerson) + + expect(result).toEqual({ + filename: 'my-file', + stream: 1234, + }) + expect(prismaMock.campaignApplication.findFirst).toHaveBeenCalledTimes(1) + expect(prismaMock.campaignApplication.findFirst).toHaveBeenCalledWith({ + where: { + documents: { + some: { + id: 'id', + }, + }, + }, + }) + + expect(prismaMock.campaignApplicationFile.findFirst).toHaveBeenNthCalledWith(1, { + where: { id: 'id' }, + }) + }) + + it('should throw a NotFoundException if no campaign-application is found', async () => { + prismaMock.campaignApplication.findUnique.mockResolvedValue(null) + + await expect(service.getFile('id', false, mockPerson)).rejects.toThrow( + new NotFoundException('File does not exist'), + ) + }) + + it('should handle errors and throw an exception', async () => { + const errorMessage = 'error' + prismaMock.campaignApplication.findFirst.mockRejectedValue(new Error(errorMessage)) + + await expect(service.getFile('id', false, mockPerson)).rejects.toThrow(errorMessage) + }) + + it('should not allow non-admin users to see files belonging to other users', async () => { + prismaMock.campaignApplication.findFirst.mockResolvedValue(mockSingleCampaignApplication) + await expect( + service.getFile('id', false, { ...mockPerson, organizer: { id: 'different-id' } }), + ).rejects.toThrow( + new ForbiddenException('User is not admin or organizer of the campaignApplication'), + ) + }) + + it('should allow admin users to see files belonging to other users', async () => { + prismaMock.campaignApplication.findFirst.mockResolvedValue(mockSingleCampaignApplication) + prismaMock.campaignApplicationFile.findFirst.mockResolvedValue({ + id: '123', + filename: 'my-file', + } as File) + await expect( + service.getFile('id', true, { ...mockPerson, organizer: { id: 'different-id' } }), + ).resolves.not.toThrow( + new ForbiddenException('User is not admin or organizer of the campaignApplication'), + ) + }) + }) }) diff --git a/apps/api/src/campaign-application/campaign-application.service.ts b/apps/api/src/campaign-application/campaign-application.service.ts index b9fcda03..777ce690 100644 --- a/apps/api/src/campaign-application/campaign-application.service.ts +++ b/apps/api/src/campaign-application/campaign-application.service.ts @@ -173,6 +173,15 @@ export class CampaignApplicationService { try { const singleCampaignApplication = await this.prisma.campaignApplication.findUnique({ where: { id }, + include: { + documents: { + select: { + id: true, + filename: true, + mimetype: true, + }, + }, + }, }) if (!singleCampaignApplication) { throw new NotFoundException('Campaign application doesnt exist') @@ -338,4 +347,45 @@ export class CampaignApplicationService { throw error } } + + async getFile( + id: string, + isAdminFlag: boolean, + person: Prisma.PersonGetPayload<{ include: { organizer: { select: { id: true } } } }>, + ) { + try { + const campaignApplication = await this.prisma.campaignApplication.findFirst({ + where: { + documents: { + some: { + id: id, + }, + }, + }, + }) + + if (!campaignApplication) { + throw new NotFoundException('File does not exist') + } + + if (isAdminFlag === false && campaignApplication.organizerId !== person.organizer?.id) { + throw new ForbiddenException('User is not admin or organizer of the campaignApplication') + } + + const file = await this.prisma.campaignApplicationFile.findFirst({ where: { id: id } }) + if (!file) { + Logger.warn('No campaign application file record with ID: ' + id) + throw new NotFoundException('No campaign application file record with ID: ' + id) + } + + return { + filename: encodeURIComponent(file.filename), + mimetype: file.mimetype, + stream: await this.s3.streamFile(this.bucketName, id), + } + } catch (error) { + Logger.error('Error in getFile():', error) + throw error + } + } } diff --git a/apps/api/src/email/template.interface.ts b/apps/api/src/email/template.interface.ts index 12059d83..f3a44594 100644 --- a/apps/api/src/email/template.interface.ts +++ b/apps/api/src/email/template.interface.ts @@ -113,7 +113,6 @@ export class CreateCampaignApplicationAdminEmailDto extends EmailTemplate<{ name = TemplateType.createCampaignApplicationAdmin } - export class CreateCampaignApplicationOrganizerEmailDto extends EmailTemplate<{ campaignApplicationName: string editLink?: string @@ -122,4 +121,4 @@ export class CreateCampaignApplicationOrganizerEmailDto extends EmailTemplate<{ firstName: string }> { name = TemplateType.createCampaignApplicationOrganizer -} \ No newline at end of file +}