feat(api): Favoriten — Symbol trotz Zertifikatsfehler holen, Reihenfolge per PUT /favorites/order speichern
- icon-discovery.service.ts: undicis eigenes fetch mit Modul-Singleton
LENIENT_TLS_AGENT (Agent({ connect: { rejectUnauthorized: false } }))
als dispatcher in fetchWithRedirectGuard, der einzigen Ausgangsstelle
fuer HTML-Ermittlung und Icon-Byte-Holen; SSRF-Schutz unveraendert
- undici 7.28.0 (bereits im Lockfile aufgeloest) als direkte Abhaengigkeit
von @tessera/api via pnpm add --offline
- PUT /favorites/order (ReorderFavoritesDto) vor den :id-Routen;
FavoritesService.reorder() setzt position=index fuer die Favoriten
eines Widgets in EINER withTenantTransaction, userId+widgetId in jeder
Bedingung (zweites Netz), eine BadRequestException fuer alle
Abweichungen (T-JDD-06)
- getIcon: X-Content-Type-Options nosniff + restriktive CSP (T-JDD-02)
- 10 neue Tests (3 Dispatcher, 7 reorder); volle API-Suite 68 Dateien/
1101 Tests und type-check gruen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,9 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
|
||||
@@ -37,6 +38,8 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
* - 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.
|
||||
* - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes
|
||||
* every updateMany by userId AND widgetId (see reorder() doc below).
|
||||
*
|
||||
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
||||
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
||||
@@ -171,6 +174,74 @@ export class FavoritesService {
|
||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the display order of a user's favorites for one widget
|
||||
* instance (260917-jdd, PUT /favorites/order).
|
||||
*
|
||||
* Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die
|
||||
* einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff
|
||||
* (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von
|
||||
* `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist
|
||||
* gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten
|
||||
* faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()`
|
||||
* setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`)
|
||||
* — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig
|
||||
* und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten
|
||||
* `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar
|
||||
* dieser Klasse es fuer alle Methoden vorsieht).
|
||||
*
|
||||
* `updateMany` statt `update({ where: { id } })`, weil `update` nur nach
|
||||
* `id` filtern koennte — der Ownership-Check muesste dann als separater
|
||||
* Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie
|
||||
* ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in
|
||||
* der Schreiboperation und liefert `count`, das sofort geprueft wird.
|
||||
*
|
||||
* Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit
|
||||
* DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie
|
||||
* fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher
|
||||
* Grund zutraf (Muster T-GWH-05).
|
||||
*
|
||||
* Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es
|
||||
* keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` —
|
||||
* kein Migrations- oder Sonderpfad noetig.
|
||||
*/
|
||||
async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) {
|
||||
if (new Set(dto.ids).size !== dto.ids.length) {
|
||||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
const existing = await tx.favoriteLink.findMany({
|
||||
where: { userId, widgetId: dto.widgetId },
|
||||
select: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||||
|
||||
if (
|
||||
existing.length !== dto.ids.length ||
|
||||
dto.ids.some((id) => !existingIds.has(id))
|
||||
) {
|
||||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||
}
|
||||
|
||||
for (const [index, id] of dto.ids.entries()) {
|
||||
const { count } = await tx.favoriteLink.updateMany({
|
||||
where: { id, userId, widgetId: dto.widgetId },
|
||||
data: { position: index },
|
||||
});
|
||||
|
||||
if (count !== 1) {
|
||||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||
}
|
||||
}
|
||||
|
||||
return tx.favoriteLink.findMany({
|
||||
where: { userId, widgetId: dto.widgetId },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||
|
||||
Reference in New Issue
Block a user