feat(server): give the memories endpoints their Zod contracts

The last nine memories entries in the body-contract allow list are gone: three
each on the immich, synology and unified controllers. They read @Body() as
Record<string, unknown> and coerced by hand.

The schemas are deliberately permissive and loose, and that is the point rather
than a shortcut. Paging arrives as `2` or `'2'` because the controller does
`Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string
'true'; auto_upload is accepted in any shape because only a real boolean is
acted on. A schema that described the ideal body instead of the real one would
turn working clients into 400s, and parity outranks tightening. Narrowing them
is a separate, client-visible change.

Loose objects for the same reason: a strict object strips unknown keys, and
`selections[]` goes straight to the services as `Selection[]` - stripping there
would be a silent data loss the day a provider adds a field.

This unblocks be-memories-fold, which could not move a controller whose body had
no contract to move with it.
This commit is contained in:
Maurice
2026-08-08 11:58:18 +02:00
committed by Maurice
parent ac96644508
commit 97f524cdfd
8 changed files with 250 additions and 18 deletions
@@ -12,9 +12,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'BookingImportController.confirm',
'BookingImportController.preview',
'BookingImportController.previewAsync',
'ImmichMemoriesController.putSettings',
'ImmichMemoriesController.search',
'ImmichMemoriesController.test',
'JourneyController.addContributor',
'JourneyController.addTrip',
'JourneyController.create',
@@ -43,12 +40,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'PluginsController.setEgressHosts',
'PluginsController.uninstall',
'PluginsController.updateConfig',
'SynologyMemoriesController.putSettings',
'SynologyMemoriesController.search',
'SynologyMemoriesController.test',
'TagsController.create',
'TagsController.update',
'UnifiedMemoriesController.addPhotos',
'UnifiedMemoriesController.createAlbumLink',
'UnifiedMemoriesController.setSharing',
];
@@ -5,6 +5,7 @@ import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import { ImmichSearchDto, ImmichSettingsDto, ImmichTestDto } from './memories.dto';
/**
* /api/integrations/memories/immich — Immich connection, browse/search, asset
@@ -30,7 +31,7 @@ export class ImmichMemoriesController {
@Put('settings')
async putSettings(
@CurrentUser() user: User,
@Body() body: { immich_url?: string; immich_api_key?: string; auto_upload?: unknown },
@Body() body: ImmichSettingsDto,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
@@ -57,7 +58,7 @@ export class ImmichMemoriesController {
@Post('test')
@HttpCode(200)
async test(@Body() body: { immich_url?: string; immich_api_key?: string }) {
async test(@Body() body: ImmichTestDto) {
const { immich_url, immich_api_key } = body;
if (!immich_url || !immich_api_key) {
return { connected: false, error: 'URL and API key required' };
@@ -77,7 +78,7 @@ export class ImmichMemoriesController {
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async search(@CurrentUser() user: User, @Body() body: ImmichSearchDto, @Res() res: Response): Promise<void> {
const { from, to, size, page } = body as { from?: string; to?: string; size?: unknown; page?: unknown };
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.min(Number(size) || 50, 200);
+34
View File
@@ -0,0 +1,34 @@
import { createZodDto } from 'nestjs-zod';
import {
addTripPhotosSchema,
createAlbumLinkSchema,
immichSearchSchema,
immichSettingsSchema,
immichTestSchema,
setTripPhotoSharingSchema,
synologySearchSchema,
synologySettingsSchema,
synologyTestSchema,
} from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared memories contracts.
* The global ZodValidationPipe (APP_PIPE in app.module.ts) validates any @Body()
* parameter typed with one of these classes by metatype — the Zod schemas in
* shared/ remain the single source of truth for the wire contract.
*
* These nine routes were the last memories entries in
* common/body-contract-allow-list.ts; the boot gate throws on stale entries, so
* they were removed in the same change.
*/
export class ImmichSettingsDto extends createZodDto(immichSettingsSchema) {}
export class ImmichTestDto extends createZodDto(immichTestSchema) {}
export class ImmichSearchDto extends createZodDto(immichSearchSchema) {}
export class SynologySettingsDto extends createZodDto(synologySettingsSchema) {}
export class SynologyTestDto extends createZodDto(synologyTestSchema) {}
export class SynologySearchDto extends createZodDto(synologySearchSchema) {}
export class AddTripPhotosDto extends createZodDto(addTripPhotosSchema) {}
export class SetTripPhotoSharingDto extends createZodDto(setTripPhotoSharingSchema) {}
export class CreateAlbumLinkDto extends createZodDto(createAlbumLinkSchema) {}
@@ -6,6 +6,7 @@ import { fail, success } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { SynologySearchDto, SynologySettingsDto, SynologyTestDto } from './memories.dto';
function _parseStringBodyField(value: unknown): string {
return String(value ?? '').trim();
@@ -47,7 +48,7 @@ export class SynologyMemoriesController {
}
@Put('settings')
async putSettings(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async putSettings(@CurrentUser() user: User, @Body() body: SynologySettingsDto, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
@@ -67,7 +68,7 @@ export class SynologyMemoriesController {
@Post('test')
@HttpCode(200)
async test(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async test(@CurrentUser() user: User, @Body() body: SynologyTestDto, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
@@ -110,7 +111,7 @@ export class SynologyMemoriesController {
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async search(@CurrentUser() user: User, @Body() body: SynologySearchDto, @Res() res: Response): Promise<void> {
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
let offset = _parseNumberBodyField(body.offset, 0);
@@ -5,6 +5,7 @@ import type { Selection } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AddTripPhotosDto, CreateAlbumLinkDto, SetTripPhotoSharingDto } from './memories.dto';
/**
* /api/integrations/memories/unified — provider-agnostic trip photo + album-link
@@ -35,7 +36,7 @@ export class UnifiedMemoriesController {
async addPhotos(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Body() body: AddTripPhotosDto,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
@@ -53,7 +54,7 @@ export class UnifiedMemoriesController {
async setSharing(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Body() body: SetTripPhotoSharingDto,
@Res() res: Response,
): Promise<void> {
const result = await this.memories.setTripPhotoSharing(tripId, user.id, Number(body?.photo_id), body?.shared as boolean);
@@ -94,7 +95,7 @@ export class UnifiedMemoriesController {
createAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Body() body: CreateAlbumLinkDto,
@Res() res: Response,
): void {
const passphrase = body?.passphrase ? String(body.passphrase) : undefined;