mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
137 lines
6.3 KiB
TypeScript
137 lines
6.3 KiB
TypeScript
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
|
import { Trips } from '../../../db/entities/Trips.entity';
|
|
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
|
|
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
|
import { pluginsEnabled } from '../kill-switch';
|
|
import { PluginHooks } from '../plugin-hooks.service';
|
|
import { stripEmoji } from '../text-sanitize';
|
|
|
|
/**
|
|
* GET /api/view-contributions/:view/:tripId — host-rendered columns/actions that
|
|
* plugins implementing the `tableContributor` hook (#plugins) add into a native
|
|
* planner view (reservations/transports/places/day/costs/packing/files/todos). Mirrors place-details.controller (#1429):
|
|
* additive + fail-safe — the caller must access the trip, each provider runs
|
|
* host->plugin on a short timeout, and one that errors/times out contributes nothing.
|
|
*
|
|
* Unlike place-details, every field is NORMALIZED server-side: a plugin contribution
|
|
* only ever crosses this boundary as bounded primitives (never HTML/markup). Strings
|
|
* are String()-coerced + length-capped, kind/tone/icon/target are enum-whitelisted,
|
|
* per-provider counts are capped, and a column URL must be http/https/mailto — a
|
|
* javascript:/data: URL would be click-XSS into the native DOM.
|
|
*/
|
|
type Tone = 'default' | 'success' | 'warn' | 'danger';
|
|
type ActionTarget = { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string };
|
|
interface Column { kind: 'column'; pluginId: string; entityId: number; id: string; label: string; value?: string; url?: string; icon?: string; tone: Tone; }
|
|
interface Action { kind: 'action'; pluginId: string; entityId: number; id: string; label: string; icon?: string; target: ActionTarget; }
|
|
type Contribution = Column | Action;
|
|
|
|
const VIEWS: ReadonlySet<string> = new Set(['reservations', 'transports', 'places', 'day', 'costs', 'packing', 'files', 'todos']);
|
|
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
|
|
const MAX_COLUMNS = 20;
|
|
const MAX_ACTIONS = 10;
|
|
const LABEL_MAX = 64;
|
|
const VALUE_MAX = 256;
|
|
const URL_MAX = 2048;
|
|
const ID_MAX = 64;
|
|
const ICON_MAX = 40;
|
|
const SUB_MAX = 512;
|
|
|
|
const cap = (v: unknown, max: number): string => stripEmoji(String(v ?? '')).slice(0, max);
|
|
|
|
/** Allow only http/https/mailto; anything else (or an unparseable value) is dropped —
|
|
* a javascript:/data:/vbscript: URL rendered as an <a href> is click-XSS. */
|
|
function safeUrl(raw: unknown): string | undefined {
|
|
if (typeof raw !== 'string' || raw.length === 0 || raw.length > URL_MAX) return undefined;
|
|
try {
|
|
const proto = new URL(raw).protocol;
|
|
return proto === 'http:' || proto === 'https:' || proto === 'mailto:' ? raw : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function normalize(pluginId: string, raw: unknown): Contribution[] {
|
|
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
|
const out: Contribution[] = [];
|
|
// The caps are PER ENTITY (the data model is keyed by entityId) — a global cap would
|
|
// make columns vanish from every row past the first MAX_COLUMNS in a large table. A
|
|
// generous overall bound still guards against a provider returning millions of rows.
|
|
const colsPer = new Map<number, number>();
|
|
const actsPer = new Map<number, number>();
|
|
const MAX_TOTAL = 5000;
|
|
for (const c of list) {
|
|
if (out.length >= MAX_TOTAL) break;
|
|
if (!c || typeof c !== 'object') continue;
|
|
const entityId = typeof c.entityId === 'number' && Number.isFinite(c.entityId) ? c.entityId : undefined;
|
|
const id = cap(c.id, ID_MAX);
|
|
const label = cap(c.label, LABEL_MAX);
|
|
if (entityId === undefined || !id || !label) continue; // an entity-keyed contribution must have all three
|
|
const icon = typeof c.icon === 'string' && c.icon ? cap(c.icon, ICON_MAX) : undefined;
|
|
|
|
if (c.kind === 'column') {
|
|
if ((colsPer.get(entityId) ?? 0) >= MAX_COLUMNS) continue;
|
|
colsPer.set(entityId, (colsPer.get(entityId) ?? 0) + 1);
|
|
out.push({
|
|
kind: 'column',
|
|
pluginId,
|
|
entityId,
|
|
id,
|
|
label,
|
|
value: c.value !== undefined && c.value !== null ? cap(c.value, VALUE_MAX) : undefined,
|
|
url: safeUrl(c.url),
|
|
icon,
|
|
tone: (TONES.has(c.tone as string) ? c.tone : 'default') as Tone,
|
|
});
|
|
} else if (c.kind === 'action') {
|
|
if ((actsPer.get(entityId) ?? 0) >= MAX_ACTIONS) continue;
|
|
const t = c.target as Record<string, unknown> | undefined;
|
|
let target: ActionTarget | undefined;
|
|
if (t && t.kind === 'frame' && typeof t.sub === 'string' && t.sub) {
|
|
target = { kind: 'frame', sub: cap(t.sub, SUB_MAX) };
|
|
} else if (t && t.kind === 'route' && (t.method === 'GET' || t.method === 'POST') && typeof t.sub === 'string' && t.sub) {
|
|
target = { kind: 'route', method: t.method, sub: cap(t.sub, SUB_MAX) };
|
|
}
|
|
if (!target) continue; // a malformed target drops the action, never renders a dead button
|
|
actsPer.set(entityId, (actsPer.get(entityId) ?? 0) + 1);
|
|
out.push({ kind: 'action', pluginId, entityId, id, label, icon, target });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
@Controller('api/view-contributions')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class ViewContributionsController {
|
|
constructor(
|
|
private readonly hooks: PluginHooks,
|
|
@InjectRepository(Trips) private readonly trips: TripsRepository,
|
|
) {}
|
|
|
|
@Get(':view/:tripId')
|
|
async get(
|
|
@Param('view') view: string,
|
|
@Param('tripId') tripIdRaw: string,
|
|
@Req() req: Request & { user?: { id: number } },
|
|
): Promise<{ contributions: Contribution[] }> {
|
|
if (!pluginsEnabled() || !VIEWS.has(view)) return { contributions: [] };
|
|
const tripId = Number(tripIdRaw);
|
|
const userId = req.user?.id;
|
|
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { contributions: [] };
|
|
|
|
const ids = this.hooks.providersOf('tableContributor');
|
|
const perProvider = await Promise.all(
|
|
ids.map(async (id): Promise<Contribution[]> => {
|
|
try {
|
|
const raw = await this.hooks.tableContributions(id, view, tripId, userId);
|
|
return normalize(id, raw);
|
|
} catch {
|
|
return []; // a slow / failing provider contributes nothing
|
|
}
|
|
}),
|
|
);
|
|
return { contributions: perProvider.flat() };
|
|
}
|
|
}
|