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:
@@ -0,0 +1,96 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import { IconDiscoveryService } from './icon-discovery.service';
|
||||
|
||||
/**
|
||||
* Service for managing per-user, per-widget favorite links.
|
||||
*
|
||||
* Access control (T-08-06 / Pitfall 3):
|
||||
* - Every query is scoped by userId (prevents cross-user access).
|
||||
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
||||
* - update() and remove() verify userId ownership before mutating.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FavoritesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly iconDiscovery: IconDiscoveryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns all favorites for a user's widget instance, ordered by position asc.
|
||||
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
|
||||
*/
|
||||
async list(userId: string, widgetId: string) {
|
||||
return this.prisma.favoriteLink.findMany({
|
||||
where: { userId, widgetId },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new favorite link.
|
||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||
*/
|
||||
async create(userId: string, tenantId: string, dto: CreateFavoriteDto) {
|
||||
let iconUrl = dto.iconUrl ?? null;
|
||||
|
||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||
if (!iconUrl) {
|
||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(dto.url);
|
||||
}
|
||||
|
||||
return this.prisma.favoriteLink.create({
|
||||
data: {
|
||||
userId,
|
||||
tenantId,
|
||||
widgetId: dto.widgetId,
|
||||
title: dto.title,
|
||||
url: dto.url,
|
||||
iconUrl,
|
||||
position: dto.position ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing favorite.
|
||||
* Verifies userId ownership before applying changes (T-08-06).
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*/
|
||||
async update(id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (dto.title !== undefined) data.title = dto.title;
|
||||
if (dto.url !== undefined) data.url = dto.url;
|
||||
if ('iconUrl' in dto) data.iconUrl = dto.iconUrl; // Allows explicit null
|
||||
if (dto.position !== undefined) data.position = dto.position;
|
||||
|
||||
return this.prisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a favorite link.
|
||||
* Verifies userId ownership before deleting (T-08-06).
|
||||
*/
|
||||
async remove(id: string, userId: string) {
|
||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
await this.prisma.favoriteLink.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user