import "dotenv/config"; import express from "express"; import cors from "cors"; import fs from "node:fs"; import path from "node:path"; import { PluginRegistry } from "./registry.js"; import { runtimeBootstrapScript } from "./bootstrap.js"; const app = express(); app.use(express.json({ limit: "10mb" })); const port = Number(process.env.PORT || 4110); const basePath = process.env.RUNTIME_BASE_PATH || ""; const pluginsDir = path.resolve(process.env.PLUGINS_DIR || "./plugins"); const dataDir = path.resolve(process.env.DATA_DIR || "./data"); const stateFile = path.join(dataDir, "plugin-state.json"); const allowOrigin = process.env.ALLOW_ORIGIN || "*"; const owncloudBaseUrl = process.env.OWNCLOUD_BASE_URL || "https://owncloud.example.com"; const owncloudUser = process.env.OWNCLOUD_DEMO_USER || "demo@peowco.com"; app.use(cors({ origin: allowOrigin === "*" ? true : allowOrigin, credentials: true })); const registry = new PluginRegistry(pluginsDir, stateFile, basePath); registry.load(); function html(body, title = "Mailcow Plugin Runtime") { return `${title}${body}`; } function pluginUiDir(pluginId) { return path.join(pluginsDir, pluginId, "ui"); } function pluginRootDir(pluginId) { return path.join(pluginsDir, pluginId); } function sanitizePluginId(pluginId) { return /^[a-z0-9][a-z0-9-]*$/i.test(pluginId) ? pluginId : null; } app.get(`${basePath}/health`, (_req, res) => res.json({ ok: true, version: "0.2.0", pluginsDir, dataDir, basePath, owncloudBaseUrl })); app.get(`${basePath}/api/runtime/plugins`, (_req, res) => { registry.load(); res.json({ ok: true, version: "0.2.0", plugins: registry.list() }); }); app.get(`${basePath}/api/runtime/hooks`, (_req, res) => { registry.load(); res.json({ ok: true, version: "0.2.0", hooks: registry.resolvedHooks(), index: registry.hooksIndex() }); }); app.post(`${basePath}/api/runtime/plugins/:id/enable`, (req, res) => { registry.load(); const p = registry.enable(req.params.id); if (!p) return res.status(404).json({ ok: false, error: "plugin_not_found" }); res.json({ ok: true, plugin: p }); }); app.post(`${basePath}/api/runtime/plugins/:id/disable`, (req, res) => { registry.load(); const p = registry.disable(req.params.id); if (!p) return res.status(404).json({ ok: false, error: "plugin_not_found" }); res.json({ ok: true, plugin: p }); }); app.get(`${basePath}/runtime/bootstrap.js`, (_req, res) => res.type("application/javascript").send(runtimeBootstrapScript(basePath))); app.use(`${basePath}/plugins/:pluginId/ui`, (req, res, next) => { const pluginId = sanitizePluginId(req.params.pluginId); if (!pluginId) return res.status(400).send("invalid plugin id"); return express.static(pluginUiDir(pluginId))(req, res, next); }); app.get(`${basePath}/plugins/:pluginId/manifest`, (req, res) => { registry.load(); const pluginId = sanitizePluginId(req.params.pluginId); if (!pluginId) return res.status(400).json({ ok: false, error: "invalid_plugin_id" }); const record = registry.get(pluginId); if (!record) return res.status(404).json({ ok: false, error: "plugin_not_found" }); res.json({ ok: true, plugin: record }); }); app.get(`${basePath}/plugins/owncloud-attach/api/me`, (_req, res) => { res.json({ ok: true, user: { id: owncloudUser, email: owncloudUser, displayName: process.env.OWNCLOUD_DEMO_DISPLAY_NAME || "Peowco Demo User", owncloudBaseUrl, authMode: process.env.OWNCLOUD_AUTH_MODE || "oidc-proxy", }, }); }); app.get(`${basePath}/plugins/owncloud-attach/api/files`, (req, res) => { const requestedPath = String(req.query.path || "/"); const mockFilesPath = path.join(pluginRootDir("owncloud-attach"), "data", "files.json"); const sample = fs.existsSync(mockFilesPath) ? JSON.parse(fs.readFileSync(mockFilesPath, "utf8")) : { cwd: requestedPath, items: [] }; res.json({ ok: true, source: process.env.OWNCLOUD_LISTING_MODE || "mock", cwd: requestedPath, items: sample.items || [], }); }); app.post(`${basePath}/plugins/owncloud-attach/api/select`, (req, res) => { const body = req.body || {}; const files = Array.isArray(body.files) ? body.files : []; res.json({ ok: true, mode: process.env.OWNCLOUD_ATTACHMENT_MODE || "draft-upload-proxy", selectedCount: files.length, files: files.map((file) => ({ id: file.id, name: file.name, size: file.size, status: "queued", mailcowDraftUpload: { recommendedEndpoint: process.env.MAILCOW_DRAFT_UPLOAD_ENDPOINT || "/api/v1/mail/attachments/upload", method: "POST", }, })), }); }); app.get(`${basePath}/runtime/ui`, (_req, res) => { registry.load(); const plugins = registry.list(); const hooks = registry.resolvedHooks(); res.type("html").send(html(`

Mailcow Plugin Runtime v0.2.0

Combined foundation: v0.1.0 registry/runtime + v0.2.0 rendered hook contributions, plugin asset serving, persisted plugin state, and ownCloud reference plugin UI/API.

Base path
${basePath || "/"}
Plugins dir
${pluginsDir}
State file
${stateFile}

Plugins

${plugins .map((p) => `

${p.manifest.name}

ID: ${p.manifest.id}
Version: ${p.manifest.version}
Enabled: ${p.enabled}
Valid: ${p.valid}
Hooks: ${(Object.keys(p.manifest.contributions || {}).join(", ") || (p.manifest.hooks || []).join(", ") || "none")}
`) .join("")}

Runtime demo

Compose toolbar hook target
Compose attachments hook target

Resolved hooks

${JSON.stringify(hooks, null, 2)}
`)); }); app.get(`${basePath}/mailcow/nginx.conf`, (_req, res) => { res.type("text/plain").send(`location ${basePath || "/plugins-runtime/"} { proxy_pass http://mailcow-plugin-runtime:${port}${basePath || "/plugins-runtime/"}; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; }`); }); app.listen(port, () => console.log(`mailcow-plugin-runtime v0.2.0 listening on ${port}`));