File size: 7,921 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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Param,
  Body,
  Header,
  HttpCode,
  HttpStatus,
  UseInterceptors,
  UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiResponse, ApiConsumes } from '@nestjs/swagger';
import { PluginsService } from './plugins.service';
import { PluginDto, PluginConfigDto, PluginSessionsDto, InstallFromUrlDto } from './dto/plugin.dto';
import type { CatalogPlugin } from './catalog';
import { RequireRole, CurrentApiKey } from '../auth/decorators/auth.decorators';
import { ApiKey, ApiKeyRole } from '../auth/entities/api-key.entity';

/** Max accepted upload size for a plugin package (compressed). */
const MAX_PLUGIN_UPLOAD_BYTES = 5 * 1024 * 1024;

@ApiTags('plugins')
@Controller('plugins')
export class PluginsController {
  constructor(private readonly pluginsService: PluginsService) {}

  @Get()
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'List all plugins' })
  @ApiResponse({ status: 200, description: 'List of all plugins' })
  findAll(): PluginDto[] {
    return this.pluginsService.findAll();
  }

  @Post('install')
  @RequireRole(ApiKeyRole.ADMIN)
  @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_UPLOAD_BYTES } }))
  @ApiConsumes('multipart/form-data')
  @ApiOperation({ summary: 'Install a plugin from an uploaded .zip package' })
  @ApiResponse({ status: 201, description: 'Plugin installed' })
  @ApiResponse({ status: 400, description: 'Invalid package' })
  @ApiResponse({ status: 409, description: 'Plugin already installed' })
  install(@UploadedFile() file: { buffer?: Buffer }): PluginDto {
    return this.pluginsService.install(file);
  }

  @Post('install-url')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Install a plugin by downloading its .zip from a URL (SSRF-guarded)' })
  @ApiResponse({ status: 201, description: 'Plugin installed' })
  @ApiResponse({ status: 400, description: 'Invalid URL, download failed, or invalid package' })
  @ApiResponse({ status: 409, description: 'Plugin already installed' })
  async installFromUrl(@Body() dto: InstallFromUrlDto): Promise<PluginDto> {
    return await this.pluginsService.installFromUrl(dto.url);
  }

  // Declared before `:id` so `GET /plugins/catalog` is not captured by the `:id` route.
  @Get('catalog')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'List the remote plugin catalog, annotated with install state' })
  @ApiResponse({ status: 200, description: 'Catalog entries' })
  @ApiResponse({ status: 400, description: 'Catalog could not be fetched or parsed' })
  async catalog(): Promise<CatalogPlugin[]> {
    return await this.pluginsService.getCatalog();
  }

  @Get(':id')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Get plugin by ID' })
  @ApiResponse({ status: 200, description: 'Plugin details' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  findOne(@Param('id') id: string): PluginDto {
    return this.pluginsService.findOne(id);
  }

  @Post(':id/enable')
  @RequireRole(ApiKeyRole.ADMIN)
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Enable a plugin' })
  @ApiResponse({ status: 200, description: 'Plugin enabled successfully' })
  async enable(@Param('id') id: string): Promise<{ success: boolean; message: string }> {
    return await this.pluginsService.enable(id);
  }

  @Post(':id/disable')
  @RequireRole(ApiKeyRole.ADMIN)
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Disable a plugin' })
  @ApiResponse({ status: 200, description: 'Plugin disabled successfully' })
  async disable(@Param('id') id: string): Promise<{ success: boolean; message: string }> {
    return await this.pluginsService.disable(id);
  }

  @Put(':id/config')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Update plugin configuration' })
  @ApiResponse({ status: 200, description: 'Plugin configuration updated' })
  updateConfig(@Param('id') id: string, @Body() configDto: PluginConfigDto): { success: boolean; message: string } {
    return this.pluginsService.updateConfig(id, configDto.config);
  }

  // The dashboard fetches this WITH the API key and injects the body as an opaque-origin sandboxed
  // iframe srcdoc. It attaches that document's response-specific nonce to inline scripts so the
  // inherited CSP allows only the isolated editor bootstrap, without enabling parent unsafe-inline.
  @Get(':id/config-ui')
  @RequireRole(ApiKeyRole.ADMIN)
  @Header('Content-Type', 'text/html; charset=utf-8')
  @Header('Content-Security-Policy', 'sandbox')
  @Header('X-Content-Type-Options', 'nosniff')
  @ApiOperation({ summary: "Serve a plugin's sandboxed config-UI entry HTML (for an iframe srcdoc)" })
  @ApiResponse({ status: 200, description: 'Config UI HTML' })
  @ApiResponse({ status: 404, description: 'Plugin not found or has no config UI' })
  getConfigUi(@Param('id') id: string): string {
    return this.pluginsService.getConfigUiHtml(id);
  }

  @Put(':id/config/:sessionId')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Set a plugin config override for a specific session (empty = clear it)' })
  @ApiResponse({ status: 200, description: 'Per-session plugin configuration updated' })
  @ApiResponse({ status: 400, description: 'Plugin is global (not session-scoped)' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  updateSessionConfig(
    @Param('id') id: string,
    @Param('sessionId') sessionId: string,
    @Body() configDto: PluginConfigDto,
  ): { success: boolean; message: string } {
    return this.pluginsService.updateSessionConfig(id, sessionId, configDto.config);
  }

  @Put(':id/sessions')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: "Set which sessions a session-scoped plugin is activated for (['*'] = all)" })
  @ApiResponse({ status: 200, description: 'Plugin session activation updated', type: PluginDto })
  @ApiResponse({ status: 400, description: 'Plugin is global (not session-scoped)' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  updateSessions(@Param('id') id: string, @Body() dto: PluginSessionsDto, @CurrentApiKey() apiKey?: ApiKey): PluginDto {
    // The target sessions live in the body, which the ApiKeyGuard (keyed off route params) never
    // inspects — so a session-restricted key's allowedSessions scope must be enforced here.
    return this.pluginsService.updateSessions(id, dto.sessions, apiKey?.allowedSessions);
  }

  @Post(':id/update')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Update an installed plugin in place from a URL (preserves config + enabled state)' })
  @ApiResponse({ status: 201, description: 'Plugin updated' })
  @ApiResponse({ status: 400, description: 'Invalid URL/package, id mismatch, or built-in' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  async update(@Param('id') id: string, @Body() dto: InstallFromUrlDto): Promise<PluginDto> {
    return await this.pluginsService.updateFromUrl(id, dto.url);
  }

  @Delete(':id')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Uninstall a plugin (removes its files; built-ins are protected)' })
  @ApiResponse({ status: 200, description: 'Plugin uninstalled' })
  @ApiResponse({ status: 400, description: 'Cannot uninstall (e.g. built-in)' })
  @ApiResponse({ status: 404, description: 'Plugin not found' })
  async uninstall(@Param('id') id: string): Promise<{ success: boolean; message: string }> {
    return await this.pluginsService.uninstall(id);
  }

  @Get(':id/health')
  @RequireRole(ApiKeyRole.ADMIN)
  @ApiOperation({ summary: 'Check plugin health' })
  @ApiResponse({ status: 200, description: 'Plugin health status' })
  async healthCheck(@Param('id') id: string): Promise<{ healthy: boolean; message?: string }> {
    return await this.pluginsService.healthCheck(id);
  }
}