07fc653f52
- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6, dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2, tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst, Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein Controller angefasst, keine anwendungsseitige userId-Filterung entfernt. - rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/ TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/ WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates). SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln). runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen Tabellen inkl. gemeinsame-Zeile-Pruefungen). - Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance, searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) — alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung. - Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug 203/203 bestanden (vorher 146). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
204 lines
8.0 KiB
TypeScript
204 lines
8.0 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
HttpException,
|
|
HttpStatus,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
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.
|
|
*
|
|
* Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN
|
|
* Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der
|
|
* Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie
|
|
* `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link
|
|
* haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist
|
|
* unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle
|
|
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
|
|
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
|
|
*
|
|
* Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1,
|
|
* Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/
|
|
* `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit
|
|
* Migration 20260911120000 traegt die Regel auf `FavoriteLink` die
|
|
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`)
|
|
* — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument
|
|
* durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen:
|
|
* zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe
|
|
* ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md).
|
|
*
|
|
* 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.
|
|
*
|
|
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
|
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
|
* Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes
|
|
* PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den
|
|
* Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und
|
|
* "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber
|
|
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
|
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
|
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
|
*/
|
|
@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(tenantId: string, userId: string, widgetId: string) {
|
|
if (!widgetId) throw new BadRequestException('widgetId is required');
|
|
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
|
return tenantPrisma.favoriteLink.findMany({
|
|
where: { userId, widgetId },
|
|
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates a new favorite link.
|
|
* Verifies the target widget belongs to the caller BEFORE any icon
|
|
* discovery network call (T-GWH-05).
|
|
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
|
*/
|
|
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
|
|
|
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
|
|
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
|
|
// wuerde ein gebundenes create mit einer fremdmandantigen widgetId
|
|
// gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht,
|
|
// gehoert einem Kollegen, liegt bei einem fremden Mandanten.
|
|
const widget = await tenantPrisma.widgetInstance.findUnique({
|
|
where: { id: dto.widgetId },
|
|
select: { userId: true },
|
|
});
|
|
if (!widget || widget.userId !== userId) {
|
|
throw new NotFoundException('Widget not found');
|
|
}
|
|
|
|
// 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 tenantPrisma.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(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
|
const link = await tenantPrisma.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 tenantPrisma.favoriteLink.update({
|
|
where: { id },
|
|
data,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes a favorite link.
|
|
* Verifies userId ownership before deleting (T-08-06).
|
|
*/
|
|
async remove(tenantId: string, id: string, userId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
|
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
|
|
|
if (!link || link.userId !== userId) {
|
|
throw new NotFoundException('FavoriteLink not found');
|
|
}
|
|
|
|
await tenantPrisma.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(
|
|
tenantId: string,
|
|
id: string,
|
|
userId: string,
|
|
): Promise<{ contentType: string; body: Buffer }> {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
|
const link = await tenantPrisma.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);
|
|
}
|
|
}
|
|
}
|