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>
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
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';
|
||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
|
||||
/**
|
||||
* Service for managing per-user, per-widget favorite links.
|
||||
@@ -42,11 +42,14 @@ export class FavoritesService {
|
||||
* 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(dto.url);
|
||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||
}
|
||||
|
||||
return this.prisma.favoriteLink.create({
|
||||
@@ -55,7 +58,7 @@ export class FavoritesService {
|
||||
tenantId,
|
||||
widgetId: dto.widgetId,
|
||||
title: dto.title,
|
||||
url: dto.url,
|
||||
url,
|
||||
iconUrl,
|
||||
position: dto.position ?? 0,
|
||||
},
|
||||
@@ -77,10 +80,26 @@ export class FavoritesService {
|
||||
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
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user