Files
TREK/server/src/nest/plugins/plugin-user-settings.controller.ts
T
Maurice ba7d55f999 Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.

Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:

- TagsController.create answers { error: 'Tag name is required' }, pinned
  byte-for-byte in two tests, and the pipe formats as `field: message`. The
  DTO leaves name optional and the handler keeps the check that owns the
  message. Same shape for the booking-import confirm body, which additionally
  gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
  and check that BEFORE looking at the body. A schema that rejected first
  would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
  documented reset. Typing it as string[] would have removed a working admin
  action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
  it dislikes, so the client falls back to straight lines. A 400 there would
  turn a graceful degradation into a visible error.

files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.

PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.

typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
2026-08-10 19:54:46 +02:00

87 lines
3.6 KiB
TypeScript

import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { DatabaseService } from '../database/database.service';
import { PluginUserSettingsUpdateDto } from './plugins.dto';
/**
* GET/POST /api/plugin-settings/:id — a USER's own `scope:'user'` settings for a
* plugin (#plugins). Deliberately its own path (not under the admin surface, not
* under the `/api/plugins/:id/*` proxy) and gated by JwtAuthGuard only: every user
* manages their OWN config here — an API key, a personal preference — separate from
* the admin-owned instance config.
*
* Secrets are stored encrypted and NEVER echoed back (masked); the write only accepts
* keys the plugin declared as `scope:'user'` fields. The plugin reads the acting
* user's value at runtime via `ctx.settings.get(key)`.
*/
@Controller('api/plugin-settings')
@UseGuards(JwtAuthGuard)
export class PluginUserSettingsController {
constructor(
private readonly plugins: PluginsService,
private readonly runtime: PluginRuntimeService,
private readonly dbs: DatabaseService,
) {}
private activeWithUserFields(id: string): boolean {
const row = this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
return !!row;
}
@Get(':id')
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): {
fields: unknown[];
config: Record<string, unknown>;
actions: Array<{ key: string; label: string; hint?: string; danger: boolean }>;
} {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {}, actions: [] };
return {
fields: this.plugins.userSettingsFields(id),
config: this.plugins.getUserConfig(id, userId),
actions: this.runtime.actionsOf(id),
};
}
/**
* Run one of the plugin's declared settings-page actions ("Test connection").
* USER-INITIATED: the acting user is the caller, bound host-side — so the action reads
* the CALLER's own settings and any trip read it makes is checked against them. It can
* never act as anyone else, and a key the plugin didn't declare is refused.
*/
@Post(':id/actions/:key')
@HttpCode(200)
async runAction(
@Param('id') id: string,
@Param('key') key: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ ok: boolean; message?: string }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) {
throw new HttpException({ error: 'Plugin is not active' }, 404);
}
try {
return await this.runtime.invokeAction(id, key, userId);
} catch (e) {
// A failing action is a RESULT, not a server error — show the user why.
return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) };
}
}
@Post(':id')
update(
@Param('id') id: string,
@Body() body: PluginUserSettingsUpdateDto,
@Req() req: Request & { user?: { id: number } },
): { config: Record<string, unknown> } {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { config: {} };
const patch = body?.config && typeof body.config === 'object' ? body.config : {};
return { config: this.plugins.updateUserConfig(id, userId, patch) };
}
}