import { BadRequestException, Body, ConflictException, ForbiddenException, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Patch, PayloadTooLargeException, Post, Req, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { pluginModeInputSchema, type PluginModeInput, type PluginView } from '@dorfteich/shared'; import { SiteAdminGuard } from '../admin/site-admin.guard'; import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants'; /** Maps a package/registry error to the HTTP status that fits its class. */ function toHttpException(error: PluginPackageError): HttpException { const body = error.details ? { code: error.code, message: error.message, details: error.details } : { code: error.code, message: error.message }; switch (error.code) { case 'plugin_not_found': return new NotFoundException(body); case 'plugin_required_cannot_uninstall': case 'plugin_version_not_higher': case 'plugin_not_optional': return new ConflictException(body); // Hash pinning (#232): the upload is well-formed, the policy says no. case 'plugin_not_pinned': case 'plugin_hash_mismatch': return new ForbiddenException(body); case 'plugin_too_large': return new PayloadTooLargeException(body); default: return new BadRequestException(body); } } /** * Site Admin plugin administration (ADR 0008, issue #71). Installing is * deliberately restricted to Site Admins (kickoff decision); the instance-mode * and per-pond-activation writes arrive with the admin UI (#72). */ @Controller('admin/plugins') // Kill switch first (issue #200): while plugins are disabled instance-wide, // even a Site Admin sees 404 here — the switch is flipped in the settings // panel, not by probing dead routes. @UseGuards(PluginsEnabledGuard, SiteAdminGuard) export class PluginAdminController { constructor(private readonly plugins: PluginsService) {} /** Upload and install (or update) a plugin ZIP. */ @Post() @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_ZIP_BYTES } })) async install( @UploadedFile() file: Express.Multer.File | undefined, @Req() request: AuthedRequest, ): Promise { if (!file) throw new BadRequestException({ code: 'bad_request', message: 'No file uploaded' }); try { return await this.plugins.install(file.buffer, request.user!); } catch (error) { if (error instanceof PluginPackageError) throw toHttpException(error); throw error; } } /** All installed plugins. */ @Get() list(): Promise { return this.plugins.list(); } /** Set a plugin's instance mode: disabled | optional | required (#72). */ @Patch(':id/mode') async setMode( @Param('id') id: string, @Body(new ZodValidationPipe(pluginModeInputSchema)) body: PluginModeInput, @Req() request: AuthedRequest, ): Promise { try { return await this.plugins.setMode(id, body.mode, request.user!); } catch (error) { if (error instanceof PluginPackageError) throw toHttpException(error); throw error; } } /** Uninstall a plugin (refused while `required`). */ @Delete(':id') @HttpCode(204) async uninstall(@Param('id') id: string, @Req() request: AuthedRequest): Promise { try { await this.plugins.uninstall(id, request.user!); } catch (error) { if (error instanceof PluginPackageError) throw toHttpException(error); throw error; } } }