Spaces:
Runtime error
Runtime error
File size: 3,907 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | import { All, Controller, Param, Query, Req, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOkResponse, ApiResponse } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { Public } from '../auth/decorators/auth.decorators';
import { IngressService } from './ingress.service';
import { InstanceThrottlerGuard } from './instance-throttler.guard';
// @Public so the global ApiKeyGuard early-returns (providers can't present an API key), but NOT
// @SkipThrottle — the global IP throttle stays as a coarse guard (per-instance fairness is P1).
// The provider body is read as RAW bytes from req.rawBody (stashed by the json() verify callback in
// main.ts) — it is intentionally NOT DTO-bound, so the global ValidationPipe never 400s on the
// provider's unknown keys, and the exact signed bytes reach the HMAC verifier.
@ApiTags('integration')
@Public()
@Controller('ingress')
export class IngressController {
constructor(private readonly ingress: IngressService) {}
// Express 5 (path-to-regexp v8) has no bare `*` — Nest's route converter rewrites it to the named
// wildcard `*path`, so the trailing segments land in req.params.path (an array), not req.params[0].
//
// InstanceThrottlerGuard runs IN ADDITION to the global per-IP ProxyAwareThrottlerGuard (an
// APP_GUARD, so it still applies here) — two independent buckets, keyed differently, both enforced.
// Its limit/ttl (INGRESS_INSTANCE_LIMIT / INGRESS_INSTANCE_TTL) are read directly by the guard
// itself, NOT via @Throttle: @Throttle metadata is reflected on the route and read by every
// ThrottlerGuard subclass that walks a tier of that name, including the global per-IP guard — so a
// route-level override here would silently retarget the global guard's tolerance too. See
// InstanceThrottlerGuard's onModuleInit for how it keeps its tier fully independent.
@UseGuards(InstanceThrottlerGuard)
@All(':pluginId/:instanceId/*path')
@ApiOkResponse({
description:
'GET verification challenge echo, or a duplicate delivery already persisted (idempotent re-delivery). Not the primary success path — see 202.',
})
@ApiResponse({
status: 202,
description: 'Webhook accepted and queued for async plugin processing (the primary success path).',
})
@ApiResponse({ status: 401, description: 'Signature verification failed (missing, stale, or wrong secret).' })
@ApiResponse({ status: 403, description: 'GET verification challenge failed (verifyToken mismatch).' })
@ApiResponse({ status: 404, description: 'Unknown pluginId/instanceId, or no route claimed by the plugin.' })
@ApiResponse({ status: 413, description: 'Request body exceeds the route maxBodyBytes limit.' })
@ApiResponse({ status: 429, description: 'Per-instance rate limit exceeded (INGRESS_INSTANCE_LIMIT).' })
async receive(
@Param('pluginId') pluginId: string,
@Param('instanceId') instanceId: string,
@Query() query: Record<string, string>,
@Req() req: Request & { rawBody?: Buffer },
@Res() res: Response,
): Promise<void> {
const wildcard = (req.params as Record<string, string | string[] | undefined>).path;
const segments = Array.isArray(wildcard)
? wildcard
: typeof wildcard === 'string'
? wildcard.split('/').filter(Boolean)
: [];
const route = segments[0] ?? '';
const headers: Record<string, string> = Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v.join(',') : String(v ?? '')]),
);
const rawBody = req.rawBody?.toString('utf8') ?? '';
const result = await this.ingress.handle({
pluginId,
instanceId,
route,
method: req.method,
headers,
query,
rawBody,
});
if (result.headers) res.set(result.headers);
res.status(result.status).send(result.body ?? '');
}
}
|