| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- import express from 'express';
- import path from 'path';
- import http from 'http';
- import mime from 'mime-types';
- import fs from 'fs';
- import {
- scanPlugins,
- getPluginsConfig,
- getPluginsDir,
- savePluginsConfig,
- getPluginDir,
- resolvePluginAssetPath,
- installPluginFromGit,
- updatePluginFromGit,
- uninstallPlugin,
- } from '../utils/plugin-loader.js';
- import {
- startPluginServer,
- stopPluginServer,
- getPluginPort,
- isPluginRunning,
- } from '../utils/plugin-process-manager.js';
- const router = express.Router();
- // GET / — List all installed plugins (includes server running status)
- router.get('/', (req, res) => {
- try {
- const plugins = scanPlugins().map(p => ({
- ...p,
- serverRunning: p.server ? isPluginRunning(p.name) : false,
- }));
- res.json({ plugins });
- } catch (err) {
- res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
- }
- });
- // GET /:name/manifest — Get single plugin manifest
- router.get('/:name/manifest', (req, res) => {
- try {
- if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
- return res.status(400).json({ error: 'Invalid plugin name' });
- }
- const plugins = scanPlugins();
- const plugin = plugins.find(p => p.name === req.params.name);
- if (!plugin) {
- return res.status(404).json({ error: 'Plugin not found' });
- }
- res.json(plugin);
- } catch (err) {
- res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
- }
- });
- // GET /:name/assets/* — Serve plugin static files
- router.get('/:name/assets/*', (req, res) => {
- const pluginName = req.params.name;
- if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
- return res.status(400).json({ error: 'Invalid plugin name' });
- }
- const assetPath = req.params[0];
- if (!assetPath) {
- return res.status(400).json({ error: 'No asset path specified' });
- }
- const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
- if (!resolvedPath) {
- return res.status(404).json({ error: 'Asset not found' });
- }
- try {
- const stat = fs.statSync(resolvedPath);
- if (!stat.isFile()) {
- return res.status(404).json({ error: 'Asset not found' });
- }
- } catch {
- return res.status(404).json({ error: 'Asset not found' });
- }
- const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
- res.setHeader('Content-Type', contentType);
- // Prevent CDN/proxy caching of plugin assets so updates take effect immediately
- res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
- res.setHeader('Pragma', 'no-cache');
- res.setHeader('Expires', '0');
- const stream = fs.createReadStream(resolvedPath);
- stream.on('error', () => {
- if (!res.headersSent) {
- res.status(500).json({ error: 'Failed to read asset' });
- } else {
- res.end();
- }
- });
- stream.pipe(res);
- });
- // PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
- router.put('/:name/enable', async (req, res) => {
- try {
- const { enabled } = req.body;
- if (typeof enabled !== 'boolean') {
- return res.status(400).json({ error: '"enabled" must be a boolean' });
- }
- const plugins = scanPlugins();
- const plugin = plugins.find(p => p.name === req.params.name);
- if (!plugin) {
- return res.status(404).json({ error: 'Plugin not found' });
- }
- const config = getPluginsConfig();
- config[req.params.name] = { ...config[req.params.name], enabled };
- savePluginsConfig(config);
- // Start or stop the plugin server as needed
- if (plugin.server) {
- if (enabled && !isPluginRunning(plugin.name)) {
- const pluginDir = getPluginDir(plugin.name);
- if (pluginDir) {
- try {
- await startPluginServer(plugin.name, pluginDir, plugin.server);
- } catch (err) {
- console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
- }
- }
- } else if (!enabled && isPluginRunning(plugin.name)) {
- await stopPluginServer(plugin.name);
- }
- }
- res.json({ success: true, name: req.params.name, enabled });
- } catch (err) {
- res.status(500).json({ error: 'Failed to update plugin', details: err.message });
- }
- });
- // POST /install — Install plugin from git URL
- router.post('/install', async (req, res) => {
- try {
- const { url } = req.body;
- if (!url || typeof url !== 'string') {
- return res.status(400).json({ error: '"url" is required and must be a string' });
- }
- // Basic URL validation
- if (!url.startsWith('https://') && !url.startsWith('git@')) {
- return res.status(400).json({ error: 'URL must start with https:// or git@' });
- }
- const manifest = await installPluginFromGit(url);
- // Auto-start the server if the plugin has one (enabled by default)
- if (manifest.server) {
- const pluginDir = getPluginDir(manifest.name);
- if (pluginDir) {
- try {
- await startPluginServer(manifest.name, pluginDir, manifest.server);
- } catch (err) {
- console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
- }
- }
- }
- res.json({ success: true, plugin: manifest });
- } catch (err) {
- res.status(400).json({ error: 'Failed to install plugin', details: err.message });
- }
- });
- // POST /:name/update — Pull latest from git (restarts server if running)
- router.post('/:name/update', async (req, res) => {
- try {
- const pluginName = req.params.name;
- if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
- return res.status(400).json({ error: 'Invalid plugin name' });
- }
- const wasRunning = isPluginRunning(pluginName);
- if (wasRunning) {
- await stopPluginServer(pluginName);
- }
- const manifest = await updatePluginFromGit(pluginName);
- // Restart server if it was running before the update
- if (wasRunning && manifest.server) {
- const pluginDir = getPluginDir(pluginName);
- if (pluginDir) {
- try {
- await startPluginServer(pluginName, pluginDir, manifest.server);
- } catch (err) {
- console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
- }
- }
- }
- res.json({ success: true, plugin: manifest });
- } catch (err) {
- res.status(400).json({ error: 'Failed to update plugin', details: err.message });
- }
- });
- // ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
- router.all('/:name/rpc/*', async (req, res) => {
- const pluginName = req.params.name;
- const rpcPath = req.params[0] || '';
- if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
- return res.status(400).json({ error: 'Invalid plugin name' });
- }
- let port = getPluginPort(pluginName);
- if (!port) {
- // Lazily start the plugin server if it exists and is enabled
- const plugins = scanPlugins();
- const plugin = plugins.find(p => p.name === pluginName);
- if (!plugin || !plugin.server) {
- return res.status(503).json({ error: 'Plugin server is not running' });
- }
- if (!plugin.enabled) {
- return res.status(503).json({ error: 'Plugin is disabled' });
- }
- const pluginDir = path.join(getPluginsDir(), plugin.dirName);
- try {
- port = await startPluginServer(pluginName, pluginDir, plugin.server);
- } catch (err) {
- return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
- }
- }
- // Inject configured secrets as headers
- const config = getPluginsConfig();
- const pluginConfig = config[pluginName] || {};
- const secrets = pluginConfig.secrets || {};
- const headers = {
- 'content-type': req.headers['content-type'] || 'application/json',
- };
- // Add per-plugin user-configured secrets as X-Plugin-Secret-* headers
- for (const [key, value] of Object.entries(secrets)) {
- headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
- }
- // Reconstruct query string
- const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
- const options = {
- hostname: '127.0.0.1',
- port,
- path: `/${rpcPath}${qs}`,
- method: req.method,
- headers,
- };
- const proxyReq = http.request(options, (proxyRes) => {
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
- proxyRes.pipe(res);
- });
- proxyReq.on('error', (err) => {
- if (!res.headersSent) {
- res.status(502).json({ error: 'Plugin server error', details: err.message });
- } else {
- res.end();
- }
- });
- // Forward body (already parsed by express JSON middleware, so re-stringify).
- // Check content-length to detect whether a body was actually sent, since
- // req.body can be falsy for valid payloads like 0, false, null, or {}.
- const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
- if (hasBody && req.body !== undefined) {
- const bodyStr = JSON.stringify(req.body);
- proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
- proxyReq.write(bodyStr);
- }
- proxyReq.end();
- });
- // DELETE /:name — Uninstall plugin (stops server first)
- router.delete('/:name', async (req, res) => {
- try {
- const pluginName = req.params.name;
- // Validate name format to prevent path traversal
- if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
- return res.status(400).json({ error: 'Invalid plugin name' });
- }
- // Stop server and wait for the process to fully exit before deleting files
- if (isPluginRunning(pluginName)) {
- await stopPluginServer(pluginName);
- }
- await uninstallPlugin(pluginName);
- res.json({ success: true, name: pluginName });
- } catch (err) {
- res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
- }
- });
- export default router;
|