harden media
This commit is contained in:
48
apps/api/src/domain/media/DefaultAvatarAssets.http.test.ts
Normal file
48
apps/api/src/domain/media/DefaultAvatarAssets.http.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('Default avatar assets (HTTP)', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
let module: TestingModule | undefined;
|
||||
let app: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
|
||||
process.env.GRIDPILOT_API_BOOTSTRAP = 'false';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { AppModule } = await import('../../app.module');
|
||||
|
||||
module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await module?.close();
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('serves male/female/neutral default avatar files from website public assets', async () => {
|
||||
const variants = ['male-default-avatar', 'female-default-avatar', 'neutral-default-avatar'] as const;
|
||||
|
||||
for (const v of variants) {
|
||||
const res = await request(app.getHttpServer()).get(`/media/default/${v}`).expect(200);
|
||||
expect(res.headers['content-type']).toMatch(/image\/(jpeg|jpg)/);
|
||||
expect(Number(res.headers['content-length'] ?? 0)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ import { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
|
||||
import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
|
||||
import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
|
||||
import type { MulterFile } from './types/MulterFile';
|
||||
import { MediaGenerationService } from '@core/media/domain/services/MediaGenerationService';
|
||||
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
|
||||
import { LOGGER_TOKEN, MEDIA_STORAGE_PORT_TOKEN } from './MediaTokens';
|
||||
|
||||
describe('MediaController', () => {
|
||||
let controller: MediaController;
|
||||
@@ -33,6 +36,13 @@ describe('MediaController', () => {
|
||||
getAvatar: ReturnType<typeof vi.fn>;
|
||||
updateAvatar: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let generationService: MediaGenerationService & {
|
||||
generateDriverAvatar: ReturnType<typeof vi.fn>;
|
||||
generateTeamLogo: ReturnType<typeof vi.fn>;
|
||||
generateLeagueLogo: ReturnType<typeof vi.fn>;
|
||||
generateLeagueCover: ReturnType<typeof vi.fn>;
|
||||
generateDefaultPNG: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -49,24 +59,54 @@ describe('MediaController', () => {
|
||||
updateAvatar: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MediaGenerationService,
|
||||
useValue: {
|
||||
generateDriverAvatar: vi.fn(),
|
||||
generateTeamLogo: vi.fn(),
|
||||
generateLeagueLogo: vi.fn(),
|
||||
generateLeagueCover: vi.fn(),
|
||||
generateDefaultPNG: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MediaResolverAdapter,
|
||||
useValue: {
|
||||
resolve: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LOGGER_TOKEN,
|
||||
useValue: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MEDIA_STORAGE_PORT_TOKEN,
|
||||
useValue: {
|
||||
uploadMedia: vi.fn(),
|
||||
deleteMedia: vi.fn(),
|
||||
getBytes: vi.fn(),
|
||||
getMetadata: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<MediaController>(MediaController);
|
||||
service = module.get(MediaService) as MediaService & {
|
||||
requestAvatarGeneration: ReturnType<typeof vi.fn>;
|
||||
uploadMedia: ReturnType<typeof vi.fn>;
|
||||
getMedia: ReturnType<typeof vi.fn>;
|
||||
deleteMedia: ReturnType<typeof vi.fn>;
|
||||
getAvatar: ReturnType<typeof vi.fn>;
|
||||
updateAvatar: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
service = module.get(MediaService) as any;
|
||||
generationService = module.get(MediaGenerationService) as any;
|
||||
});
|
||||
|
||||
const createMockResponse = (): Response => {
|
||||
const res: Partial<Response> = {};
|
||||
res.status = vi.fn().mockReturnValue(res as Response);
|
||||
res.json = vi.fn().mockReturnValue(res as Response);
|
||||
res.setHeader = vi.fn().mockReturnValue(res as Response);
|
||||
res.send = vi.fn().mockReturnValue(res as Response);
|
||||
return res as Response;
|
||||
};
|
||||
|
||||
@@ -154,6 +194,276 @@ describe('MediaController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamLogo', () => {
|
||||
it('should return generated team logo SVG', async () => {
|
||||
const teamId = 'team-123';
|
||||
const svg = '<svg>logo</svg>';
|
||||
generationService.generateTeamLogo.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getTeamLogo(teamId, res);
|
||||
|
||||
expect(generationService.generateTeamLogo).toHaveBeenCalledWith(teamId);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeagueLogo', () => {
|
||||
it('should return generated league logo SVG', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const svg = '<svg>league-logo</svg>';
|
||||
generationService.generateLeagueLogo.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getLeagueLogo(leagueId, res);
|
||||
|
||||
expect(generationService.generateLeagueLogo).toHaveBeenCalledWith(leagueId);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeagueCover', () => {
|
||||
it('should return generated league cover SVG', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const svg = '<svg>league-cover</svg>';
|
||||
generationService.generateLeagueCover.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getLeagueCover(leagueId, res);
|
||||
|
||||
expect(generationService.generateLeagueCover).toHaveBeenCalledWith(leagueId);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDriverAvatar', () => {
|
||||
it('should return generated driver avatar SVG', async () => {
|
||||
const driverId = 'driver-123';
|
||||
const svg = '<svg>avatar</svg>';
|
||||
generationService.generateDriverAvatar.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getDriverAvatar(driverId, res);
|
||||
|
||||
expect(generationService.generateDriverAvatar).toHaveBeenCalledWith(driverId);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultMedia', () => {
|
||||
it('should return PNG with correct cache headers', async () => {
|
||||
const variant = 'male-default-avatar';
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // PNG header
|
||||
generationService.generateDefaultPNG.mockReturnValue(pngBuffer);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getDefaultMedia(variant, res);
|
||||
|
||||
expect(generationService.generateDefaultPNG).toHaveBeenCalledWith(variant);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(pngBuffer);
|
||||
});
|
||||
|
||||
it('should handle different variants', async () => {
|
||||
const variants = ['male-default-avatar', 'female-default-avatar', 'neutral-default-avatar', 'logo'];
|
||||
|
||||
for (const variant of variants) {
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||
generationService.generateDefaultPNG.mockReturnValue(pngBuffer);
|
||||
|
||||
const res = createMockResponse();
|
||||
await controller.getDefaultMedia(variant, res);
|
||||
|
||||
expect(generationService.generateDefaultPNG).toHaveBeenCalledWith(variant);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGeneratedMedia', () => {
|
||||
it('should return team logo SVG with long cache', async () => {
|
||||
const type = 'team';
|
||||
const id = '123';
|
||||
const svg = '<svg>team-logo</svg>';
|
||||
generationService.generateTeamLogo.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getGeneratedMedia(type, id, res);
|
||||
|
||||
expect(generationService.generateTeamLogo).toHaveBeenCalledWith(id);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
|
||||
it('should return league logo SVG with long cache', async () => {
|
||||
const type = 'league';
|
||||
const id = '456';
|
||||
const svg = '<svg>league-logo</svg>';
|
||||
generationService.generateLeagueLogo.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getGeneratedMedia(type, id, res);
|
||||
|
||||
expect(generationService.generateLeagueLogo).toHaveBeenCalledWith(id);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
|
||||
it('should return driver avatar SVG with long cache', async () => {
|
||||
const type = 'driver';
|
||||
const id = '789';
|
||||
const svg = '<svg>driver-avatar</svg>';
|
||||
generationService.generateDriverAvatar.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getGeneratedMedia(type, id, res);
|
||||
|
||||
expect(generationService.generateDriverAvatar).toHaveBeenCalledWith(id);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
|
||||
it('should handle unknown types with fallback', async () => {
|
||||
const type = 'unknown';
|
||||
const id = '999';
|
||||
const svg = '<svg>fallback</svg>';
|
||||
generationService.generateLeagueLogo.mockReturnValue(svg);
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getGeneratedMedia(type, id, res);
|
||||
|
||||
expect(generationService.generateLeagueLogo).toHaveBeenCalledWith('unknown-999');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(svg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadedMedia', () => {
|
||||
it('should return uploaded media bytes with correct headers', async () => {
|
||||
const mediaId = 'media-123';
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // PNG header
|
||||
const mockStorage = {
|
||||
getBytes: vi.fn().mockResolvedValue(pngBuffer),
|
||||
getMetadata: vi.fn().mockResolvedValue({ size: 4, contentType: 'image/png' }),
|
||||
};
|
||||
const mockService = {
|
||||
getMedia: vi.fn().mockResolvedValue({ id: mediaId }),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [MediaController],
|
||||
providers: [
|
||||
{
|
||||
provide: MediaService,
|
||||
useValue: mockService,
|
||||
},
|
||||
{
|
||||
provide: MediaGenerationService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: MediaResolverAdapter,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: LOGGER_TOKEN,
|
||||
useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: MEDIA_STORAGE_PORT_TOKEN,
|
||||
useValue: mockStorage,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const testController = module.get<MediaController>(MediaController);
|
||||
const res = createMockResponse();
|
||||
|
||||
await testController.getUploadedMedia(mediaId, res);
|
||||
|
||||
expect(mockService.getMedia).toHaveBeenCalledWith(mediaId);
|
||||
expect(mockStorage.getBytes).toHaveBeenCalledWith('uploaded/media-123');
|
||||
expect(mockStorage.getMetadata).toHaveBeenCalledWith('uploaded/media-123');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith(pngBuffer);
|
||||
});
|
||||
|
||||
it('should return 404 when media not found', async () => {
|
||||
const mediaId = 'media-123';
|
||||
const mockStorage = {
|
||||
getBytes: vi.fn(),
|
||||
getMetadata: vi.fn(),
|
||||
};
|
||||
const mockService = {
|
||||
getMedia: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [MediaController],
|
||||
providers: [
|
||||
{
|
||||
provide: MediaService,
|
||||
useValue: mockService,
|
||||
},
|
||||
{
|
||||
provide: MediaGenerationService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: MediaResolverAdapter,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: LOGGER_TOKEN,
|
||||
useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: MEDIA_STORAGE_PORT_TOKEN,
|
||||
useValue: mockStorage,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const testController = module.get<MediaController>(MediaController);
|
||||
const res = createMockResponse();
|
||||
|
||||
await testController.getUploadedMedia(mediaId, res);
|
||||
|
||||
expect(mockService.getMedia).toHaveBeenCalledWith(mediaId);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Media not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMedia', () => {
|
||||
it('should return media if found', async () => {
|
||||
const mediaId = 'media-123';
|
||||
@@ -208,7 +518,7 @@ describe('MediaController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvatar', () => {
|
||||
describe('getAvatarDetails', () => {
|
||||
it('should return avatar if found', async () => {
|
||||
const driverId = 'driver-123';
|
||||
const dto: GetAvatarOutputDTO = {
|
||||
@@ -218,7 +528,7 @@ describe('MediaController', () => {
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getAvatar(driverId, res);
|
||||
await controller.getAvatarDetails(driverId, res);
|
||||
|
||||
expect(service.getAvatar).toHaveBeenCalledWith(driverId);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
@@ -231,7 +541,7 @@ describe('MediaController', () => {
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
await controller.getAvatar(driverId, res);
|
||||
await controller.getAvatarDetails(driverId, res);
|
||||
|
||||
expect(service.getAvatar).toHaveBeenCalledWith(driverId);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
@@ -289,6 +599,55 @@ describe('MediaController', () => {
|
||||
useValue: {
|
||||
getMedia: vi.fn(async () => ({ id: 'm1' })),
|
||||
deleteMedia: vi.fn(async () => ({ success: true })),
|
||||
requestAvatarGeneration: vi.fn(),
|
||||
uploadMedia: vi.fn(),
|
||||
getAvatar: vi.fn(),
|
||||
updateAvatar: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MediaGenerationService,
|
||||
useValue: {
|
||||
generateDriverAvatar: vi.fn(() => '<svg>avatar</svg>'),
|
||||
generateTeamLogo: vi.fn(() => '<svg>logo</svg>'),
|
||||
generateLeagueLogo: vi.fn(() => '<svg>league</svg>'),
|
||||
generateDefaultPNG: vi.fn(() => Buffer.from([0x89, 0x50, 0x4E, 0x47])),
|
||||
generateLeagueCover: vi.fn(() => '<svg>cover</svg>'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MediaResolverAdapter,
|
||||
useValue: {
|
||||
resolve: vi.fn((ref) => {
|
||||
if (ref.type === 'system-default') {
|
||||
return `/media/default/${ref.variant}`;
|
||||
}
|
||||
if (ref.type === 'generated') {
|
||||
return `/media/generated/${ref.generationRequestId}`;
|
||||
}
|
||||
if (ref.type === 'uploaded') {
|
||||
return `/media/uploaded/${ref.mediaId}`;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LOGGER_TOKEN,
|
||||
useValue: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MEDIA_STORAGE_PORT_TOKEN,
|
||||
useValue: {
|
||||
uploadMedia: vi.fn(),
|
||||
deleteMedia: vi.fn(),
|
||||
getBytes: vi.fn(),
|
||||
getMetadata: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -327,5 +686,16 @@ describe('MediaController', () => {
|
||||
|
||||
await request(app.getHttpServer()).delete('/media/m1').expect(200);
|
||||
});
|
||||
|
||||
it('allows new public routes without authentication', async () => {
|
||||
// Test default media route
|
||||
await request(app.getHttpServer()).get('/media/default/male-default-avatar').expect(200);
|
||||
|
||||
// Test generated media route
|
||||
await request(app.getHttpServer()).get('/media/generated/team/123').expect(200);
|
||||
|
||||
// Test debug resolve route
|
||||
await request(app.getHttpServer()).get('/media/debug/resolve?type=system-default&variant=avatar').expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,149 +16,29 @@ import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
|
||||
import { ValidateFaceInputDTO } from './dtos/ValidateFaceInputDTO';
|
||||
import { ValidateFaceOutputDTO } from './dtos/ValidateFaceOutputDTO';
|
||||
import type { MulterFile } from './types/MulterFile';
|
||||
import { MediaGenerationService } from '@core/media/domain/services/MediaGenerationService';
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
|
||||
import { LOGGER_TOKEN, MEDIA_STORAGE_PORT_TOKEN } from './MediaTokens';
|
||||
import type { MediaStoragePort } from '@core/media/application/ports/MediaStoragePort';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
|
||||
type UploadMediaInput = UploadMediaInputDTO;
|
||||
type UpdateAvatarInput = UpdateAvatarInputDTO;
|
||||
|
||||
function hashToHue(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash) % 360;
|
||||
}
|
||||
|
||||
function escapeXml(input: string): string {
|
||||
return input
|
||||
.replaceAll('&', '\u0026amp;')
|
||||
.replaceAll('<', '\u0026lt;')
|
||||
.replaceAll('>', '\u0026gt;')
|
||||
.replaceAll('"', '\u0026quot;')
|
||||
.replaceAll("'", '\u0026apos;');
|
||||
}
|
||||
|
||||
function deriveLeagueLabel(leagueId: string): string {
|
||||
const digits = leagueId.match(/\d+/)?.[0];
|
||||
if (digits) return digits.slice(-2);
|
||||
return leagueId.replaceAll(/[^a-zA-Z]/g, '').slice(0, 2).toUpperCase() || 'GP';
|
||||
}
|
||||
|
||||
function buildLeagueLogoSvg(leagueId: string): string {
|
||||
const hue = hashToHue(leagueId);
|
||||
const label = escapeXml(deriveLeagueLabel(leagueId));
|
||||
const bg = `hsl(${hue} 70% 38%)`;
|
||||
const border = `hsl(${hue} 70% 28%)`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="League logo">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${bg}"/>
|
||||
<stop offset="100%" stop-color="hsl(${hue} 80% 46%)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="92" height="92" rx="18" fill="url(#g)" stroke="${border}" stroke-width="4"/>
|
||||
<text x="48" y="56" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="34" font-weight="800" text-anchor="middle" fill="white">${label}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildLeagueCoverSvg(leagueId: string): string {
|
||||
const hue = hashToHue(leagueId);
|
||||
const title = escapeXml(leagueId);
|
||||
const bg1 = `hsl(${hue} 70% 28%)`;
|
||||
const bg2 = `hsl(${(hue + 35) % 360} 85% 35%)`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400" role="img" aria-label="League cover">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${bg1}"/>
|
||||
<stop offset="100%" stop-color="${bg2}"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.07)" stroke-width="2"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="400" fill="url(#bg)"/>
|
||||
<rect width="1200" height="400" fill="url(#grid)"/>
|
||||
<circle cx="1020" cy="120" r="180" fill="rgba(255,255,255,0.06)"/>
|
||||
<circle cx="1080" cy="170" r="120" fill="rgba(255,255,255,0.05)"/>
|
||||
|
||||
<text x="64" y="110" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="40" font-weight="800" fill="rgba(255,255,255,0.92)">GridPilot League</text>
|
||||
<text x="64" y="165" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="22" font-weight="600" fill="rgba(255,255,255,0.75)">${title}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildDriverAvatarSvg(driverId: string): string {
|
||||
const hue = hashToHue(driverId);
|
||||
const initials = deriveLeagueLabel(driverId);
|
||||
const bg = `hsl(${hue} 70% 38%)`;
|
||||
const border = `hsl(${hue} 70% 28%)`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Driver avatar">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${bg}"/>
|
||||
<stop offset="100%" stop-color="hsl(${hue} 80% 46%)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="48" cy="48" r="44" fill="url(#g)" stroke="${border}" stroke-width="3"/>
|
||||
<text x="48" y="56" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="32" font-weight="800" text-anchor="middle" fill="white">${initials}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildTrackImageSvg(trackId: string): string {
|
||||
const hue = hashToHue(trackId);
|
||||
const label = escapeXml(deriveLeagueLabel(trackId));
|
||||
const bg1 = `hsl(${hue} 70% 28%)`;
|
||||
const bg2 = `hsl(${(hue + 20) % 360} 65% 35%)`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400" role="img" aria-label="Track image">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${bg1}"/>
|
||||
<stop offset="100%" stop-color="${bg2}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="400" fill="url(#bg)"/>
|
||||
|
||||
<!-- Track outline -->
|
||||
<path d="M 200 200 Q 400 100 600 200 T 1000 200" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M 200 220 Q 400 120 600 220 T 1000 220" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="6" stroke-linecap="round"/>
|
||||
|
||||
<text x="64" y="110" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="40" font-weight="800" fill="rgba(255,255,255,0.92)">Track ${label}</text>
|
||||
<text x="64" y="165" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="22" font-weight="600" fill="rgba(255,255,255,0.75)">${escapeXml(trackId)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildCategoryIconSvg(categoryId: string): string {
|
||||
const hue = hashToHue(categoryId);
|
||||
const label = escapeXml(categoryId.substring(0, 3).toUpperCase());
|
||||
const bg = `hsl(${hue} 70% 38%)`;
|
||||
const border = `hsl(${hue} 70% 28%)`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Category icon">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${bg}"/>
|
||||
<stop offset="100%" stop-color="hsl(${hue} 80% 46%)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="12" fill="url(#g)" stroke="${border}" stroke-width="2"/>
|
||||
<text x="32" y="40" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial" font-size="22" font-weight="800" text-anchor="middle" fill="white">${label}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
@ApiTags('media')
|
||||
@Controller('media')
|
||||
export class MediaController {
|
||||
constructor(@Inject(MediaService) private readonly mediaService: MediaService) {}
|
||||
constructor(
|
||||
@Inject(MediaService) private readonly mediaService: MediaService,
|
||||
@Inject(MediaGenerationService) private readonly mediaGenerationService: MediaGenerationService,
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
@Inject(MediaResolverAdapter) private readonly mediaResolver: MediaResolverAdapter,
|
||||
@Inject(MEDIA_STORAGE_PORT_TOKEN) private readonly mediaStorage: MediaStoragePort,
|
||||
) {}
|
||||
|
||||
@Post('avatar/generate')
|
||||
@ApiOperation({ summary: 'Request avatar generation' })
|
||||
@@ -167,11 +47,14 @@ export class MediaController {
|
||||
@Body() input: RequestAvatarGenerationInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.debug('[MediaController] Requesting avatar generation', { input });
|
||||
const dto: RequestAvatarGenerationOutputDTO = await this.mediaService.requestAvatarGeneration(input);
|
||||
|
||||
if (dto.success) {
|
||||
this.logger.info('[MediaController] Avatar generation request successful', { dto });
|
||||
res.status(HttpStatus.CREATED).json(dto);
|
||||
} else {
|
||||
this.logger.warn('[MediaController] Avatar generation request failed', { dto });
|
||||
res.status(HttpStatus.BAD_REQUEST).json(dto);
|
||||
}
|
||||
}
|
||||
@@ -186,167 +69,306 @@ export class MediaController {
|
||||
@Body() input: UploadMediaInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.debug('[MediaController] Uploading media', { filename: file?.originalname, input });
|
||||
const dto: UploadMediaOutputDTO = await this.mediaService.uploadMedia({ ...input, file });
|
||||
|
||||
if (dto.success) {
|
||||
this.logger.info('[MediaController] Media upload successful', { mediaId: dto.mediaId });
|
||||
res.status(HttpStatus.CREATED).json(dto);
|
||||
} else {
|
||||
this.logger.warn('[MediaController] Media upload failed', { error: dto.error });
|
||||
res.status(HttpStatus.BAD_REQUEST).json(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('leagues/:leagueId/logo')
|
||||
@ApiOperation({ summary: 'Get league logo (placeholder)' })
|
||||
@ApiParam({ name: 'leagueId', description: 'League ID' })
|
||||
async getLeagueLogo(
|
||||
@Param('leagueId') leagueId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildLeagueLogoSvg(leagueId);
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('leagues/:leagueId/cover')
|
||||
@ApiOperation({ summary: 'Get league cover (placeholder)' })
|
||||
@ApiParam({ name: 'leagueId', description: 'League ID' })
|
||||
async getLeagueCover(
|
||||
@Param('leagueId') leagueId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildLeagueCoverSvg(leagueId);
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('teams/:teamId/logo')
|
||||
@ApiOperation({ summary: 'Get team logo (placeholder)' })
|
||||
@ApiOperation({ summary: 'Get team logo (dynamically generated)' })
|
||||
@ApiParam({ name: 'teamId', description: 'Team ID' })
|
||||
async getTeamLogo(
|
||||
@Param('teamId') teamId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildLeagueLogoSvg(teamId);
|
||||
this.logger.debug('[MediaController] Generating team logo', { teamId });
|
||||
const svg = this.mediaGenerationService.generateTeamLogo(teamId);
|
||||
const svgLength = svg.length;
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
|
||||
this.logger.info('[MediaController] Team logo generated', { teamId, svgLength });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('team/:teamId/logo')
|
||||
@ApiOperation({ summary: 'Get team logo (singular path)' })
|
||||
@ApiParam({ name: 'teamId', description: 'Team ID' })
|
||||
async getTeamLogoSingular(
|
||||
@Param('teamId') teamId: string,
|
||||
@Get('leagues/:leagueId/logo')
|
||||
@ApiOperation({ summary: 'Get league logo (dynamically generated)' })
|
||||
@ApiParam({ name: 'leagueId', description: 'League ID' })
|
||||
async getLeagueLogo(
|
||||
@Param('leagueId') leagueId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildLeagueLogoSvg(teamId);
|
||||
this.logger.debug('[MediaController] Generating league logo', { leagueId });
|
||||
const svg = this.mediaGenerationService.generateLeagueLogo(leagueId);
|
||||
const svgLength = svg.length;
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
|
||||
this.logger.info('[MediaController] League logo generated', { leagueId, svgLength });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('team/:teamId/logo.png')
|
||||
@ApiOperation({ summary: 'Get team logo with .png extension' })
|
||||
@ApiParam({ name: 'teamId', description: 'Team ID' })
|
||||
async getTeamLogoPng(
|
||||
@Param('teamId') teamId: string,
|
||||
@Get('leagues/:leagueId/cover')
|
||||
@ApiOperation({ summary: 'Get league cover (dynamically generated)' })
|
||||
@ApiParam({ name: 'leagueId', description: 'League ID' })
|
||||
async getLeagueCover(
|
||||
@Param('leagueId') leagueId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildLeagueLogoSvg(teamId);
|
||||
this.logger.debug('[MediaController] Generating league cover', { leagueId });
|
||||
const svg = this.mediaGenerationService.generateLeagueCover(leagueId);
|
||||
const svgLength = svg.length;
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
|
||||
this.logger.info('[MediaController] League cover generated', { leagueId, svgLength });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('teams/:teamId/cover')
|
||||
@ApiOperation({ summary: 'Get team cover (placeholder)' })
|
||||
@ApiParam({ name: 'teamId', description: 'Team ID' })
|
||||
async getTeamCover(
|
||||
@Param('teamId') teamId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildLeagueCoverSvg(teamId);
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('drivers/:driverId/avatar')
|
||||
@ApiOperation({ summary: 'Get driver avatar (placeholder)' })
|
||||
@Get('avatar/:driverId')
|
||||
@ApiOperation({ summary: 'Get driver avatar (dynamically generated)' })
|
||||
@ApiParam({ name: 'driverId', description: 'Driver ID' })
|
||||
async getDriverAvatar(
|
||||
@Param('driverId') driverId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildDriverAvatarSvg(driverId);
|
||||
this.logger.debug('[MediaController] Generating driver avatar', { driverId });
|
||||
const svg = this.mediaGenerationService.generateDriverAvatar(driverId);
|
||||
const svgLength = svg.length;
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
|
||||
this.logger.info('[MediaController] Driver avatar generated', { driverId, svgLength });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('avatar/:driverId')
|
||||
@ApiOperation({ summary: 'Get driver avatar (alternative path)' })
|
||||
@ApiParam({ name: 'driverId', description: 'Driver ID' })
|
||||
async getDriverAvatarAlt(
|
||||
@Param('driverId') driverId: string,
|
||||
@Get('default/:variant')
|
||||
@ApiOperation({ summary: 'Get default media asset (PNG)' })
|
||||
@ApiParam({ name: 'variant', description: 'Variant name (e.g., male-default-avatar, female-default-avatar, logo)' })
|
||||
async getDefaultMedia(
|
||||
@Param('variant') variant: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildDriverAvatarSvg(driverId);
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
this.logger.debug('[MediaController] Getting default media', { variant });
|
||||
|
||||
// Drivers must use real assets from website public dir.
|
||||
// Supported:
|
||||
// - male-default-avatar
|
||||
// - female-default-avatar
|
||||
// - neutral-default-avatar
|
||||
if (
|
||||
variant === 'male-default-avatar' ||
|
||||
variant === 'female-default-avatar' ||
|
||||
variant === 'neutral-default-avatar'
|
||||
) {
|
||||
const candidates = [`${variant}.jpg`, `${variant}.jpeg`];
|
||||
// This needs to work in multiple runtimes:
|
||||
// - docker dev (cwd often: /app/apps/api) -> ../website
|
||||
// - local tests (cwd often: repo root) -> apps/website
|
||||
// Prefer a deterministic directory discovery rather than assuming a single cwd.
|
||||
const baseDirs = [
|
||||
path.resolve(process.cwd(), 'apps', 'website', 'public', 'images', 'avatars'),
|
||||
path.resolve(process.cwd(), '..', 'website', 'public', 'images', 'avatars'),
|
||||
];
|
||||
|
||||
for (const baseDir of baseDirs) {
|
||||
for (const filename of candidates) {
|
||||
const abs = path.join(baseDir, filename);
|
||||
try {
|
||||
const bytes = await fs.readFile(abs);
|
||||
res.setHeader('Content-Type', filename.endsWith('.png') ? 'image/png' : 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.status(HttpStatus.OK).send(bytes);
|
||||
this.logger.info('[MediaController] Default avatar served', { variant, filename, baseDir, size: bytes.length });
|
||||
return;
|
||||
} catch {
|
||||
// try next filename/baseDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn('[MediaController] Default avatar asset not found', { variant, baseDirs, candidates });
|
||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Default avatar asset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: generated PNG for other defaults
|
||||
const png = this.mediaGenerationService.generateDefaultPNG(variant);
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.status(HttpStatus.OK).send(png);
|
||||
this.logger.info('[MediaController] Default media generated', { variant, size: png.length });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('tracks/:trackId/image')
|
||||
@ApiOperation({ summary: 'Get track image (placeholder)' })
|
||||
@ApiParam({ name: 'trackId', description: 'Track ID' })
|
||||
async getTrackImage(
|
||||
@Param('trackId') trackId: string,
|
||||
@Get('generated/:type/:id')
|
||||
@ApiOperation({ summary: 'Get generated media (SVG)' })
|
||||
@ApiParam({ name: 'type', description: 'Media type (team, league, driver)' })
|
||||
@ApiParam({ name: 'id', description: 'Entity ID' })
|
||||
async getGeneratedMedia(
|
||||
@Param('type') type: string,
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildTrackImageSvg(trackId);
|
||||
this.logger.debug('[MediaController] Generating media', { type, id });
|
||||
let svg: string;
|
||||
|
||||
// Route to appropriate generator based on type
|
||||
if (type === 'team') {
|
||||
svg = this.mediaGenerationService.generateTeamLogo(id);
|
||||
} else if (type === 'league') {
|
||||
svg = this.mediaGenerationService.generateLeagueLogo(id);
|
||||
} else if (type === 'driver') {
|
||||
svg = this.mediaGenerationService.generateDriverAvatar(id);
|
||||
} else {
|
||||
// Fallback: generate a generic logo
|
||||
svg = this.mediaGenerationService.generateLeagueLogo(`${type}-${id}`);
|
||||
}
|
||||
|
||||
const svgLength = svg.length;
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
|
||||
this.logger.info('[MediaController] Generated media served', { type, id, svgLength });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('categories/:categoryId/icon')
|
||||
@ApiOperation({ summary: 'Get category icon (placeholder)' })
|
||||
@ApiParam({ name: 'categoryId', description: 'Category ID' })
|
||||
async getCategoryIcon(
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Get('uploaded/:mediaId')
|
||||
@ApiOperation({ summary: 'Get uploaded media' })
|
||||
@ApiParam({ name: 'mediaId', description: 'Media ID' })
|
||||
async getUploadedMedia(
|
||||
@Param('mediaId') mediaId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildCategoryIconSvg(categoryId);
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
this.logger.debug('[MediaController] Getting uploaded media', { mediaId });
|
||||
|
||||
// Look up the media to get the storage key
|
||||
const media = await this.mediaService.getMedia(mediaId);
|
||||
if (!media) {
|
||||
this.logger.warn('[MediaController] Uploaded media not found', { mediaId });
|
||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the storage key from the media reference
|
||||
// The mediaId is used as the storage key
|
||||
const storageKey = `uploaded/${mediaId}`;
|
||||
|
||||
// Get file bytes from storage
|
||||
const bytes = await this.mediaStorage.getBytes!(storageKey);
|
||||
if (!bytes) {
|
||||
this.logger.warn('[MediaController] Uploaded media file not found', { mediaId, storageKey });
|
||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get metadata to determine content type
|
||||
const metadata = await this.mediaStorage.getMetadata!(storageKey);
|
||||
const contentType = metadata?.contentType || 'application/octet-stream';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.status(HttpStatus.OK).send(bytes);
|
||||
|
||||
this.logger.info('[MediaController] Uploaded media served', { mediaId, storageKey, size: bytes.length });
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sponsors/:sponsorId/logo')
|
||||
@ApiOperation({ summary: 'Get sponsor logo (placeholder)' })
|
||||
@ApiParam({ name: 'sponsorId', description: 'Sponsor ID' })
|
||||
async getSponsorLogo(
|
||||
@Param('sponsorId') sponsorId: string,
|
||||
@Get('debug/resolve')
|
||||
@ApiOperation({ summary: 'Debug media reference resolution' })
|
||||
@ApiResponse({ status: 200, description: 'Resolution debug info' })
|
||||
async debugResolve(
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const svg = buildLeagueLogoSvg(sponsorId);
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.status(HttpStatus.OK).send(svg);
|
||||
this.logger.debug('[MediaController] Debug resolve requested');
|
||||
|
||||
// Parse query parameters
|
||||
const query = res.req.query;
|
||||
|
||||
let ref: MediaReference | null = null;
|
||||
let refHash: string | null = null;
|
||||
let resolvedPath: string | null = null;
|
||||
let resolver: string | null = null;
|
||||
const notes: string[] = [];
|
||||
|
||||
try {
|
||||
// Try to construct MediaReference from query params
|
||||
if (query.type === 'system-default' && query.variant) {
|
||||
const variant = query.variant as 'avatar' | 'logo';
|
||||
const avatarVariant = query.avatarVariant as 'male' | 'female' | 'neutral' | undefined;
|
||||
ref = MediaReference.createSystemDefault(variant, avatarVariant);
|
||||
resolver = 'default';
|
||||
} else if (query.type === 'generated' && query.generationRequestId) {
|
||||
ref = MediaReference.createGenerated(query.generationRequestId as string);
|
||||
resolver = 'generated';
|
||||
} else if (query.type === 'uploaded' && query.mediaId) {
|
||||
ref = MediaReference.createUploaded(query.mediaId as string);
|
||||
resolver = 'uploaded';
|
||||
} else if (query.ref) {
|
||||
// Try to parse base64url JSON
|
||||
try {
|
||||
const decoded = Buffer.from(query.ref as string, 'base64').toString('utf-8');
|
||||
const props = JSON.parse(decoded);
|
||||
ref = MediaReference.fromJSON(props);
|
||||
resolver = 'auto-detected';
|
||||
} catch (e) {
|
||||
notes.push('Failed to parse ref as base64url JSON');
|
||||
}
|
||||
} else {
|
||||
notes.push('No valid query parameters provided');
|
||||
notes.push('Expected: type, variant, avatarVariant OR generationRequestId OR mediaId OR ref (base64url)');
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
refHash = ref.hash();
|
||||
resolvedPath = await this.mediaResolver.resolve(ref);
|
||||
|
||||
if (!resolvedPath) {
|
||||
notes.push('Resolver returned null');
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('[MediaController] Debug resolve completed', {
|
||||
ref: ref ? ref.toJSON() : null,
|
||||
refHash,
|
||||
resolvedPath,
|
||||
resolver,
|
||||
notes,
|
||||
});
|
||||
|
||||
res.status(HttpStatus.OK).json({
|
||||
ref: ref ? ref.toJSON() : null,
|
||||
refHash,
|
||||
resolvedPath,
|
||||
resolver,
|
||||
notes,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error('[MediaController] Debug resolve failed', error instanceof Error ? error : new Error(String(error)));
|
||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: errorMessage,
|
||||
notes: ['Internal error during resolution'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@@ -358,11 +380,14 @@ export class MediaController {
|
||||
@Param('mediaId') mediaId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.debug('[MediaController] Getting media details', { mediaId });
|
||||
const dto: GetMediaOutputDTO | null = await this.mediaService.getMedia(mediaId);
|
||||
|
||||
if (dto) {
|
||||
this.logger.info('[MediaController] Media details found', { mediaId });
|
||||
res.status(HttpStatus.OK).json(dto);
|
||||
} else {
|
||||
this.logger.warn('[MediaController] Media not found', { mediaId });
|
||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
|
||||
}
|
||||
}
|
||||
@@ -375,25 +400,30 @@ export class MediaController {
|
||||
@Param('mediaId') mediaId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.debug('[MediaController] Deleting media', { mediaId });
|
||||
const dto: DeleteMediaOutputDTO = await this.mediaService.deleteMedia(mediaId);
|
||||
|
||||
this.logger.info('[MediaController] Media deletion result', { mediaId, success: dto.success });
|
||||
res.status(HttpStatus.OK).json(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('avatar/:driverId')
|
||||
@ApiOperation({ summary: 'Get avatar for driver' })
|
||||
@Get('avatar/:driverId/details')
|
||||
@ApiOperation({ summary: 'Get avatar details for driver' })
|
||||
@ApiParam({ name: 'driverId', description: 'Driver ID' })
|
||||
@ApiResponse({ status: 200, description: 'Avatar details', type: GetAvatarOutputDTO })
|
||||
async getAvatar(
|
||||
async getAvatarDetails(
|
||||
@Param('driverId') driverId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.debug('[MediaController] Getting avatar details', { driverId });
|
||||
const dto: GetAvatarOutputDTO | null = await this.mediaService.getAvatar(driverId);
|
||||
|
||||
if (dto) {
|
||||
this.logger.info('[MediaController] Avatar details found', { driverId });
|
||||
res.status(HttpStatus.OK).json(dto);
|
||||
} else {
|
||||
this.logger.warn('[MediaController] Avatar not found', { driverId });
|
||||
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
|
||||
}
|
||||
}
|
||||
@@ -407,8 +437,10 @@ export class MediaController {
|
||||
@Body() input: UpdateAvatarInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.debug('[MediaController] Updating avatar', { driverId });
|
||||
const dto: UpdateAvatarOutputDTO = await this.mediaService.updateAvatar(driverId, input);
|
||||
|
||||
this.logger.info('[MediaController] Avatar update result', { driverId, success: dto.success });
|
||||
res.status(HttpStatus.OK).json(dto);
|
||||
}
|
||||
|
||||
@@ -419,12 +451,15 @@ export class MediaController {
|
||||
@Body() input: ValidateFaceInputDTO,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.debug('[MediaController] Validating face photo');
|
||||
const dto: ValidateFaceOutputDTO = await this.mediaService.validateFacePhoto(input);
|
||||
|
||||
if (dto.isValid) {
|
||||
this.logger.info('[MediaController] Face validation passed');
|
||||
res.status(HttpStatus.OK).json(dto);
|
||||
} else {
|
||||
this.logger.warn('[MediaController] Face validation failed', { errorMessage: dto.errorMessage });
|
||||
res.status(HttpStatus.BAD_REQUEST).json(dto);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ export * from './MediaTokens';
|
||||
|
||||
import type { FaceValidationResult } from '@core/media/application/ports/FaceValidationPort';
|
||||
import type { AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort';
|
||||
import type { UploadResult } from '@core/media/application/ports/MediaStoragePort';
|
||||
|
||||
// External adapters (ports) - these remain mock implementations
|
||||
class MockFaceValidationAdapter implements FaceValidationPort {
|
||||
@@ -86,17 +85,6 @@ class MockAvatarGenerationAdapter implements AvatarGenerationPort {
|
||||
}
|
||||
}
|
||||
|
||||
class MockMediaStorageAdapter implements MediaStoragePort {
|
||||
async uploadMedia(): Promise<UploadResult> {
|
||||
return {
|
||||
success: true,
|
||||
url: 'https://cdn.example.com/media/mock-file.png',
|
||||
filename: 'mock-file.png',
|
||||
};
|
||||
}
|
||||
async deleteMedia(): Promise<void> {}
|
||||
}
|
||||
|
||||
class MockLogger implements Logger {
|
||||
debug(): void {}
|
||||
info(): void {}
|
||||
@@ -104,7 +92,16 @@ class MockLogger implements Logger {
|
||||
error(): void {}
|
||||
}
|
||||
|
||||
import { MediaGenerationService } from '@core/media/domain/services/MediaGenerationService';
|
||||
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
|
||||
import { FileSystemMediaStorageAdapter } from '@adapters/media/ports/FileSystemMediaStorageAdapter';
|
||||
|
||||
export const MediaProviders: Provider[] = [
|
||||
MediaGenerationService,
|
||||
{
|
||||
provide: MediaResolverAdapter,
|
||||
useFactory: () => new MediaResolverAdapter({}),
|
||||
},
|
||||
RequestAvatarGenerationPresenter,
|
||||
UploadMediaPresenter,
|
||||
GetMediaPresenter,
|
||||
@@ -121,7 +118,9 @@ export const MediaProviders: Provider[] = [
|
||||
},
|
||||
{
|
||||
provide: MEDIA_STORAGE_PORT_TOKEN,
|
||||
useClass: MockMediaStorageAdapter,
|
||||
useFactory: () => new FileSystemMediaStorageAdapter({
|
||||
baseDir: process.env.MEDIA_STORAGE_DIR || '/data/media',
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: LOGGER_TOKEN,
|
||||
|
||||
@@ -166,6 +166,16 @@ export class MediaService {
|
||||
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> {
|
||||
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
|
||||
|
||||
// Handle null avatarUrl - this would mean removing the avatar
|
||||
if (input.avatarUrl === null) {
|
||||
// For now, we'll treat null as an error since the use case requires a URL
|
||||
// In a complete implementation, this would trigger avatar removal
|
||||
return {
|
||||
success: false,
|
||||
error: 'Avatar URL cannot be null',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.updateAvatarUseCase.execute({
|
||||
driverId,
|
||||
mediaUrl: input.avatarUrl,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class GetAvatarOutputDTO {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ nullable: true })
|
||||
@IsString()
|
||||
avatarUrl: string = '';
|
||||
avatarUrl: string | null = null;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export class UpdateAvatarInputDTO {
|
||||
@IsString()
|
||||
driverId: string = '';
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ nullable: true })
|
||||
@IsString()
|
||||
avatarUrl: string = '';
|
||||
avatarUrl: string | null = null;
|
||||
}
|
||||
Reference in New Issue
Block a user