plugins.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import express from 'express';
  2. import path from 'path';
  3. import http from 'http';
  4. import mime from 'mime-types';
  5. import fs from 'fs';
  6. import {
  7. scanPlugins,
  8. getPluginsConfig,
  9. getPluginsDir,
  10. savePluginsConfig,
  11. getPluginDir,
  12. resolvePluginAssetPath,
  13. installPluginFromGit,
  14. updatePluginFromGit,
  15. uninstallPlugin,
  16. } from '../utils/plugin-loader.js';
  17. import {
  18. startPluginServer,
  19. stopPluginServer,
  20. getPluginPort,
  21. isPluginRunning,
  22. } from '../utils/plugin-process-manager.js';
  23. const router = express.Router();
  24. // GET / — List all installed plugins (includes server running status)
  25. router.get('/', (req, res) => {
  26. try {
  27. const plugins = scanPlugins().map(p => ({
  28. ...p,
  29. serverRunning: p.server ? isPluginRunning(p.name) : false,
  30. }));
  31. res.json({ plugins });
  32. } catch (err) {
  33. res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
  34. }
  35. });
  36. // GET /:name/manifest — Get single plugin manifest
  37. router.get('/:name/manifest', (req, res) => {
  38. try {
  39. if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
  40. return res.status(400).json({ error: 'Invalid plugin name' });
  41. }
  42. const plugins = scanPlugins();
  43. const plugin = plugins.find(p => p.name === req.params.name);
  44. if (!plugin) {
  45. return res.status(404).json({ error: 'Plugin not found' });
  46. }
  47. res.json(plugin);
  48. } catch (err) {
  49. res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
  50. }
  51. });
  52. // GET /:name/assets/* — Serve plugin static files
  53. router.get('/:name/assets/*', (req, res) => {
  54. const pluginName = req.params.name;
  55. if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
  56. return res.status(400).json({ error: 'Invalid plugin name' });
  57. }
  58. const assetPath = req.params[0];
  59. if (!assetPath) {
  60. return res.status(400).json({ error: 'No asset path specified' });
  61. }
  62. const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
  63. if (!resolvedPath) {
  64. return res.status(404).json({ error: 'Asset not found' });
  65. }
  66. try {
  67. const stat = fs.statSync(resolvedPath);
  68. if (!stat.isFile()) {
  69. return res.status(404).json({ error: 'Asset not found' });
  70. }
  71. } catch {
  72. return res.status(404).json({ error: 'Asset not found' });
  73. }
  74. const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
  75. res.setHeader('Content-Type', contentType);
  76. // Prevent CDN/proxy caching of plugin assets so updates take effect immediately
  77. res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
  78. res.setHeader('Pragma', 'no-cache');
  79. res.setHeader('Expires', '0');
  80. const stream = fs.createReadStream(resolvedPath);
  81. stream.on('error', () => {
  82. if (!res.headersSent) {
  83. res.status(500).json({ error: 'Failed to read asset' });
  84. } else {
  85. res.end();
  86. }
  87. });
  88. stream.pipe(res);
  89. });
  90. // PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
  91. router.put('/:name/enable', async (req, res) => {
  92. try {
  93. const { enabled } = req.body;
  94. if (typeof enabled !== 'boolean') {
  95. return res.status(400).json({ error: '"enabled" must be a boolean' });
  96. }
  97. const plugins = scanPlugins();
  98. const plugin = plugins.find(p => p.name === req.params.name);
  99. if (!plugin) {
  100. return res.status(404).json({ error: 'Plugin not found' });
  101. }
  102. const config = getPluginsConfig();
  103. config[req.params.name] = { ...config[req.params.name], enabled };
  104. savePluginsConfig(config);
  105. // Start or stop the plugin server as needed
  106. if (plugin.server) {
  107. if (enabled && !isPluginRunning(plugin.name)) {
  108. const pluginDir = getPluginDir(plugin.name);
  109. if (pluginDir) {
  110. try {
  111. await startPluginServer(plugin.name, pluginDir, plugin.server);
  112. } catch (err) {
  113. console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
  114. }
  115. }
  116. } else if (!enabled && isPluginRunning(plugin.name)) {
  117. await stopPluginServer(plugin.name);
  118. }
  119. }
  120. res.json({ success: true, name: req.params.name, enabled });
  121. } catch (err) {
  122. res.status(500).json({ error: 'Failed to update plugin', details: err.message });
  123. }
  124. });
  125. // POST /install — Install plugin from git URL
  126. router.post('/install', async (req, res) => {
  127. try {
  128. const { url } = req.body;
  129. if (!url || typeof url !== 'string') {
  130. return res.status(400).json({ error: '"url" is required and must be a string' });
  131. }
  132. // Basic URL validation
  133. if (!url.startsWith('https://') && !url.startsWith('git@')) {
  134. return res.status(400).json({ error: 'URL must start with https:// or git@' });
  135. }
  136. const manifest = await installPluginFromGit(url);
  137. // Auto-start the server if the plugin has one (enabled by default)
  138. if (manifest.server) {
  139. const pluginDir = getPluginDir(manifest.name);
  140. if (pluginDir) {
  141. try {
  142. await startPluginServer(manifest.name, pluginDir, manifest.server);
  143. } catch (err) {
  144. console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
  145. }
  146. }
  147. }
  148. res.json({ success: true, plugin: manifest });
  149. } catch (err) {
  150. res.status(400).json({ error: 'Failed to install plugin', details: err.message });
  151. }
  152. });
  153. // POST /:name/update — Pull latest from git (restarts server if running)
  154. router.post('/:name/update', async (req, res) => {
  155. try {
  156. const pluginName = req.params.name;
  157. if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
  158. return res.status(400).json({ error: 'Invalid plugin name' });
  159. }
  160. const wasRunning = isPluginRunning(pluginName);
  161. if (wasRunning) {
  162. await stopPluginServer(pluginName);
  163. }
  164. const manifest = await updatePluginFromGit(pluginName);
  165. // Restart server if it was running before the update
  166. if (wasRunning && manifest.server) {
  167. const pluginDir = getPluginDir(pluginName);
  168. if (pluginDir) {
  169. try {
  170. await startPluginServer(pluginName, pluginDir, manifest.server);
  171. } catch (err) {
  172. console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
  173. }
  174. }
  175. }
  176. res.json({ success: true, plugin: manifest });
  177. } catch (err) {
  178. res.status(400).json({ error: 'Failed to update plugin', details: err.message });
  179. }
  180. });
  181. // ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
  182. router.all('/:name/rpc/*', async (req, res) => {
  183. const pluginName = req.params.name;
  184. const rpcPath = req.params[0] || '';
  185. if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
  186. return res.status(400).json({ error: 'Invalid plugin name' });
  187. }
  188. let port = getPluginPort(pluginName);
  189. if (!port) {
  190. // Lazily start the plugin server if it exists and is enabled
  191. const plugins = scanPlugins();
  192. const plugin = plugins.find(p => p.name === pluginName);
  193. if (!plugin || !plugin.server) {
  194. return res.status(503).json({ error: 'Plugin server is not running' });
  195. }
  196. if (!plugin.enabled) {
  197. return res.status(503).json({ error: 'Plugin is disabled' });
  198. }
  199. const pluginDir = path.join(getPluginsDir(), plugin.dirName);
  200. try {
  201. port = await startPluginServer(pluginName, pluginDir, plugin.server);
  202. } catch (err) {
  203. return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
  204. }
  205. }
  206. // Inject configured secrets as headers
  207. const config = getPluginsConfig();
  208. const pluginConfig = config[pluginName] || {};
  209. const secrets = pluginConfig.secrets || {};
  210. const headers = {
  211. 'content-type': req.headers['content-type'] || 'application/json',
  212. };
  213. // Add per-plugin user-configured secrets as X-Plugin-Secret-* headers
  214. for (const [key, value] of Object.entries(secrets)) {
  215. headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
  216. }
  217. // Reconstruct query string
  218. const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
  219. const options = {
  220. hostname: '127.0.0.1',
  221. port,
  222. path: `/${rpcPath}${qs}`,
  223. method: req.method,
  224. headers,
  225. };
  226. const proxyReq = http.request(options, (proxyRes) => {
  227. res.writeHead(proxyRes.statusCode, proxyRes.headers);
  228. proxyRes.pipe(res);
  229. });
  230. proxyReq.on('error', (err) => {
  231. if (!res.headersSent) {
  232. res.status(502).json({ error: 'Plugin server error', details: err.message });
  233. } else {
  234. res.end();
  235. }
  236. });
  237. // Forward body (already parsed by express JSON middleware, so re-stringify).
  238. // Check content-length to detect whether a body was actually sent, since
  239. // req.body can be falsy for valid payloads like 0, false, null, or {}.
  240. const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
  241. if (hasBody && req.body !== undefined) {
  242. const bodyStr = JSON.stringify(req.body);
  243. proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
  244. proxyReq.write(bodyStr);
  245. }
  246. proxyReq.end();
  247. });
  248. // DELETE /:name — Uninstall plugin (stops server first)
  249. router.delete('/:name', async (req, res) => {
  250. try {
  251. const pluginName = req.params.name;
  252. // Validate name format to prevent path traversal
  253. if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
  254. return res.status(400).json({ error: 'Invalid plugin name' });
  255. }
  256. // Stop server and wait for the process to fully exit before deleting files
  257. if (isPluginRunning(pluginName)) {
  258. await stopPluginServer(pluginName);
  259. }
  260. await uninstallPlugin(pluginName);
  261. res.json({ success: true, name: pluginName });
  262. } catch (err) {
  263. res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
  264. }
  265. });
  266. export default router;