feat(08-03): FavoriteLink schema + FavoritesModule (CRUD + SSRF icon discovery)

- Add FavoriteLink Prisma model (userId/tenantId/widgetId scope, iconUrl nullable, position)
- IconDiscoveryService: port SSRF-protected icon discovery with redirect: 'manual',
  private IP / blocked-hostname checks, 4000ms timeout, 200k HTML cap (T-08-05)
- FavoritesService: list/create/update/remove all scoped by userId (T-08-06 / Pitfall 3)
- FavoritesController: GET /favorites?widgetId, POST, PATCH :id, DELETE :id
- FavoritesModule registered in AppModule
- tsc --noEmit passes for @tessera/api
This commit is contained in:
2026-07-01 10:25:52 +02:00
parent a3bb3f2396
commit 758d246e98
8 changed files with 565 additions and 0 deletions
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
import { FavoritesService } from './favorites.service';
/**
* REST controller for per-user, per-widget favorite links.
*
* All routes are protected by the global JwtAuthGuard + TenantGuard.
*
* Routes:
* - GET /favorites?widgetId= — list favorites for a widget instance
* - POST /favorites — create a favorite (triggers server-side icon discovery)
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
*/
@Controller('favorites')
export class FavoritesController {
constructor(private readonly favoritesService: FavoritesService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
if (!userId) {
throw new ForbiddenException('No user context');
}
return { userId, tenantId };
}
@Get()
async list(
@Query('widgetId') widgetId: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.favoritesService.list(userId, widgetId);
}
@Post()
async create(
@Body() dto: CreateFavoriteDto,
@Req() req: Request,
) {
const { userId, tenantId } = this.extractContext(req);
return this.favoritesService.create(userId, tenantId, dto);
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateFavoriteDto,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.favoritesService.update(id, userId, dto);
}
@Delete(':id')
async remove(
@Param('id') id: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.favoritesService.remove(id, userId);
}
}