Files
tessera-ctl/apps/api/src/favorites/favorites.service.ts
T
schalli 9b65ac63c3
Tessera CI/CD / Lint & Type Check (push) Successful in 38s
Tessera CI/CD / Tests (push) Successful in 40s
Tessera CI/CD / Build & Publish Images (push) Successful in 22s
fix(favorites): normalize scheme-less URLs so favicons resolve
A favorite entered as a bare host ("ctl.de") passed @IsUrl() but had no
scheme, so `new URL()` threw inside icon discovery and it silently fell
back to a relative "/favicon.ico" — which 502'd through the icon proxy
and left the widget showing the first-letter placeholder ("C").

- add normalizeUrl() (prepend https:// when no scheme present)
- apply it in discoverFavoriteIconUrl and when storing the favorite url,
  so both the link and discovery use the normalized value
- on update, re-run discovery when the icon field is cleared, so editing
  a previously-broken favorite repairs its icon
- tests: normalizeUrl cases + end-to-end discovery (apple-touch extraction,
  scheme-less fallback stays absolute)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:58:44 +02:00

151 lines
4.9 KiB
TypeScript

import {
BadRequestException,
HttpException,
HttpStatus,
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, normalizeUrl } 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) {
if (!widgetId) throw new BadRequestException('widgetId is required');
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) {
// Normalize so a scheme-less entry like "ctl.de" is stored (and discovered)
// as "https://ctl.de" — otherwise the link and icon discovery both break.
const url = normalizeUrl(dto.url);
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(url);
}
return this.prisma.favoriteLink.create({
data: {
userId,
tenantId,
widgetId: dto.widgetId,
title: dto.title,
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;
const normalizedUrl =
dto.url !== undefined ? normalizeUrl(dto.url) : undefined;
if (normalizedUrl !== undefined) data.url = normalizedUrl;
if (dto.position !== undefined) data.position = dto.position;
if ('iconUrl' in dto) {
if (dto.iconUrl) {
// Explicit icon URL supplied — respect it as-is.
data.iconUrl = dto.iconUrl;
} else {
// Icon cleared (empty/null) — re-run discovery against the effective
// (new or existing) url so editing a broken favorite repairs its icon.
const effectiveUrl = normalizedUrl ?? link.url;
data.iconUrl =
await this.iconDiscovery.discoverFavoriteIconUrl(effectiveUrl);
}
}
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 } });
}
/**
* Fetches the raw bytes of a favorite's stored icon, scoped to the
* requesting user (T-08-06 — same ownership check as update/remove).
* Never accepts a client-supplied URL — only the stored iconUrl on a
* row the caller owns is fetched (T-QFIP-01).
*
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
* by the caller, or has no icon on record. Throws a 502 HttpException
* if the upstream fetch fails (unreachable, timeout, non-image, or
* SSRF-blocked) -- never returns a placeholder image.
*/
async getIconBytes(
id: string,
userId: string,
): Promise<{ contentType: string; body: Buffer }> {
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || !link.iconUrl) {
throw new NotFoundException('FavoriteLink not found');
}
try {
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
} catch {
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
}
}
}