// Admin · Modules — install, enable, disable, uninstall, purge and restart. // // Mounted at /api/v1/admin/modules by admin/index.js, which has already applied // `noindex, isLoggedIn, staffOnly`. Every route here re-gates to `admin`: an // editor or moderator has no business installing code into the server process, // and the group gate alone would let them. // // Route order matters in one place. `/restart` and `/sources` are declared // BEFORE the `/:id/...` routes, because express matches in declaration order and // a module whose id was `restart` would otherwise shadow — or be shadowed by — // the literal path. The id pattern below makes that unreachable in practice; the // ordering makes it unreachable by construction. const express = require('express') const { body, param, query } = require('express-validator') const controller = require('./modules.controller') const { requireRole } = require('../../../utils/auth') const validate = require('../../../middleware/validate') const modulesRouter = express.Router() const adminOnly = requireRole('admin') // The loader's own id rule (MODULE_API.md §2.1). Applied at the edge so a // traversal-shaped id never reaches a path join, even though install.js checks // it again — this one produces a 400 with a readable message, that one is the // guarantee. const ID = /^[a-z][a-z0-9-]{1,31}$/ modulesRouter.get( '/', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = 'List installed modules, their live state, and the source allowlist' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'Modules and the install source allowlist', content: { "application/json": { schema: { type: "object", properties: { modules: { type: "array", items: { type: "object", additionalProperties: true } }, sourceHosts: { type: "array", items: { type: "string" } } } } } } } */ adminOnly, controller.list, ) modulesRouter.post( '/', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = 'Install or upgrade a module from a release install-manifest URL' // #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */ /* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'A different module is already installed; a site runs one module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, body('url').isString().trim().isLength({ min: 1, max: 2048 }), validate, controller.create, ) modulesRouter.put( '/sources', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = 'Replace the allowlist of hosts modules may be installed from' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["hosts"], properties: { hosts: { type: "string", description: "Comma- or space-separated hostnames. An empty list forbids all installs." } } } } } } */ /* #swagger.responses[200] = { description: 'The new allowlist', content: { "application/json": { schema: { type: "object", properties: { sourceHosts: { type: "array", items: { type: "string" } } } } } } } */ /* #swagger.responses[400] = { description: 'One of the entries is not a hostname', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, body('hosts').isString().isLength({ max: 2048 }), validate, controller.setSources, ) modulesRouter.post( '/restart', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = 'Restart the server process so module changes take effect' // #swagger.description = 'Runs the same graceful shutdown a SIGTERM does. The process is brought back by the supervisor, which the shipped docker-compose.yml provides; a bare `npm start` will not come back.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[202] = { description: 'Shutting down', content: { "application/json": { schema: { type: "object", properties: { restarting: { type: "boolean" } } } } } } */ adminOnly, controller.restart, ) modulesRouter.post( '/:id/enable', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = 'Enable a module (takes effect on the next restart)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' } /* #swagger.responses[200] = { description: 'Enabled — restart to start it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, param('id').matches(ID), validate, controller.enable, ) modulesRouter.post( '/:id/disable', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = 'Stop a module now — runs its onShutdown, then its routes answer 404' // #swagger.description = 'The only module action that takes effect without a restart. Re-enabling needs one, because there is no onBoot re-dispatch.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' } /* #swagger.responses[200] = { description: 'Disabled', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, param('id').matches(ID), validate, controller.disable, ) modulesRouter.post( '/:id/purge', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = "Run a disabled module’s purge.sql, dropping its tables and data" // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' } /* #swagger.responses[200] = { description: 'Purged', content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, purged: { type: "integer" } } } } } } */ /* #swagger.responses[400] = { description: 'The module ships no purge.sql', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'The module must be disabled first', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, param('id').matches(ID), validate, controller.purge, ) modulesRouter.delete( '/:id', // #swagger.tags = ['Admin · Modules'] // #swagger.summary = 'Uninstall a module, optionally deleting its data too' // #swagger.description = "Removes the module directory and leaves its row disabled. With purge=true it also runs purge.sql first — which is the only moment it can, since purge.sql lives inside the directory being deleted." // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Module id.' } // #swagger.parameters['purge'] = { in: 'query', required: false, schema: { type: 'boolean' }, description: "Also run the module’s purge.sql and drop its row. Destructive and irreversible." } /* #swagger.responses[200] = { description: 'Uninstalled — restart to unmount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[400] = { description: 'Purge was asked for and the module ships no purge.sql', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'No such module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, param('id').matches(ID), query('purge').optional().isIn(['true', 'false', '1', '0']), validate, controller.remove, ) module.exports = modulesRouter