Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ jobs:
environment: ${{ needs.changes.outputs.environment }}
region: ${{ env.GCP_REGION }}
project_id: ${{ secrets.GCP_PROJECT_ID }}
staging_api_url: ${{ secrets.STAGING_API_URL }}

- name: Get Frontend URL
id: frontend-url
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/upload/storage.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Storage } from '@google-cloud/storage';
const mockBucket = {
file: jest.fn().mockReturnValue({
save: jest.fn().mockResolvedValue(undefined),
makePublic: jest.fn().mockResolvedValue(undefined),
}),
};
const mockStorageInstance = {
Expand Down
87 changes: 84 additions & 3 deletions backend/src/modules/upload/upload.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { UploadController } from './upload.controller';
import * as fs from 'fs';
import { UploadController, createImageFileFilter } from './upload.controller';
import { StorageService } from './storage.service';

describe('UploadController', () => {
let controller: UploadController;

const mockStorageService = {
const mockStorageService = {
isGcsEnabled: jest.fn().mockReturnValue(false),
uploadToGcs: jest.fn(),
};
Expand Down Expand Up @@ -117,6 +117,60 @@ describe('UploadController', () => {
'image/jpeg',
);
});

it('should use .gif extension when mimetype is image/gif', async () => {
const req = mockRequest({ user: { id: 'user-1' } });
const file = mockFile({ mimetype: 'image/gif' });

const result = await controller.uploadAvatar(file, req);

expect(result.url).toMatch(/\.gif$/);
expect(result.url).toMatch(/^http:\/\/localhost:4000\/uploads\/avatars\/user-1-\d+\.gif$/);
});

it('should use .jpg as fallback extension for unknown mimetype', async () => {
const req = mockRequest({ user: { id: 'user-1' } });
const file = mockFile({ mimetype: 'image/bmp' } as Express.Multer.File);

const result = await controller.uploadAvatar(file, req);

expect(result.url).toMatch(/\.jpg$/);
});

it('should create avatars directory when it does not exist', async () => {
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined);
const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
const req = mockRequest({ user: { id: 'user-1' } });
const file = mockFile();

const result = await controller.uploadAvatar(file, req);

expect(existsSyncSpy).toHaveBeenCalled();
expect(mkdirSyncSpy).toHaveBeenCalledWith(expect.stringContaining('uploads/avatars'), { recursive: true });
expect(result.url).toMatch(/\/uploads\/avatars\//);
existsSyncSpy.mockRestore();
mkdirSyncSpy.mockRestore();
writeFileSyncSpy.mockRestore();
});
});

describe('createImageFileFilter', () => {
it('should call cb with error and false for invalid mimetype', () => {
const filter = createImageFileFilter();
const cb = jest.fn();
filter(null, { mimetype: 'application/pdf' } as Express.Multer.File, cb);
expect(cb).toHaveBeenCalledWith(expect.any(BadRequestException), false);
expect(cb).toHaveBeenCalledTimes(1);
});

it('should call cb with null and true for valid mimetype', () => {
const filter = createImageFileFilter();
const cb = jest.fn();
filter(null, { mimetype: 'image/png' } as Express.Multer.File, cb);
expect(cb).toHaveBeenCalledWith(null, true);
expect(cb).toHaveBeenCalledTimes(1);
});
});

describe('uploadBackground', () => {
Expand Down Expand Up @@ -173,5 +227,32 @@ describe('UploadController', () => {
'image/png',
);
});

it('should use .gif extension when mimetype is image/gif', async () => {
const req = mockRequest({ user: { id: 'user-1' } });
const file = mockFile({ fieldname: 'background', mimetype: 'image/gif' });

const result = await controller.uploadBackground(file, req);

expect(result.url).toMatch(/\.gif$/);
expect(result.url).toMatch(/^http:\/\/localhost:4000\/uploads\/backgrounds\/background-\d+-[a-z0-9]+\.gif$/);
});

it('should create backgrounds directory when it does not exist', async () => {
const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined);
const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
const req = mockRequest({ user: { id: 'user-1' } });
const file = mockFile({ fieldname: 'background', mimetype: 'image/webp' });

const result = await controller.uploadBackground(file, req);

expect(existsSyncSpy).toHaveBeenCalled();
expect(mkdirSyncSpy).toHaveBeenCalledWith(expect.stringContaining('uploads/backgrounds'), { recursive: true });
expect(result.url).toMatch(/\/uploads\/backgrounds\//);
existsSyncSpy.mockRestore();
mkdirSyncSpy.mockRestore();
writeFileSyncSpy.mockRestore();
});
});
});
42 changes: 21 additions & 21 deletions backend/src/modules/upload/upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { StorageService } from './storage.service';

const AVATARS_DIR = 'uploads/avatars';
const BACKGROUNDS_DIR = 'uploads/backgrounds';
const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
export const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];

function getExtension(mimetype: string): string {
const map: Record<string, string> = {
Expand All @@ -28,6 +28,24 @@ function getExtension(mimetype: string): string {
return map[mimetype] ?? 'jpg';
}

/** Used by FileInterceptor to reject non-image uploads. Exported for tests. */
export function createImageFileFilter(): (
_req: unknown,
file: Express.Multer.File,
cb: (err: Error | null, accept: boolean) => void,
) => void {
return (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
if (!ALLOWED_MIMES.includes(file.mimetype)) {
cb(
new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'),
false,
);
return;
}
cb(null, true);
};
}

/**
* Upload controller: saves files to Google Cloud Storage (or disk when GCS not configured)
* and returns public URLs. Used for avatar, board/card background images.
Expand All @@ -40,16 +58,7 @@ export class UploadController {
@UseInterceptors(
FileInterceptor('avatar', {
storage: memoryStorage(),
fileFilter: (_req, file, cb) => {
if (!ALLOWED_MIMES.includes(file.mimetype)) {
cb(
new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'),
false,
);
return;
}
cb(null, true);
},
fileFilter: createImageFileFilter(),
}),
)
async uploadAvatar(
Expand Down Expand Up @@ -89,16 +98,7 @@ export class UploadController {
@UseInterceptors(
FileInterceptor('background', {
storage: memoryStorage(),
fileFilter: (_req, file, cb) => {
if (!ALLOWED_MIMES.includes(file.mimetype)) {
cb(
new BadRequestException('Invalid file type. Use JPEG, PNG, GIF or WebP.'),
false,
);
return;
}
cb(null, true);
},
fileFilter: createImageFileFilter(),
}),
)
async uploadBackground(
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ export default function DashboardPage() {
>
<CartesianGrid
strokeDasharray='3 3'
className='stroke-muted'
className='stroke-muted text-trello'
/>
<XAxis dataKey='workspace' tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
Expand Down
Loading