- DC-160: /catalog/:appId read app.image/ports/env/volumes which live under app.docker.* — every detail response was empty. Now reads docker.* and surfaces defaultPort/subdomain/healthCheck/mediaMount/claimToken/difficulty/ setupInstructions for the deploy form. List/search use the templates own category field (old hand-rolled CATEGORY_MAP mis-categorized apps). - Shield (routes/shield.js + 5-line app.js mount) committed to source after weeks as prod-only drift; regression test asserts the mount exists. - New composition-root e2e suite: boots REAL createApp() on an ephemeral port, probes the historically-broken routes through the REAL mount table (wizard wiring, shield, catalog detail, deploys bridge) — the class of bug all 3 P7 dogfood findings belonged to. 5 tests. - Full suite 3499/3499 (155 suites); table-driven DC-160 test covers all 73 image templates.
133 lines
4.7 KiB
JavaScript
133 lines
4.7 KiB
JavaScript
/**
|
|
* DC-160: App Catalog API — curated templates with categories and search
|
|
*
|
|
* Exposes the existing app-templates.js as a browsable catalog.
|
|
* GET /api/v1/catalog — list all apps (with optional category filter)
|
|
* GET /api/v1/catalog/:appId — get details for a specific app
|
|
* GET /api/v1/catalog/search — search apps by name/category/keyword
|
|
*
|
|
* DC-160 FIX: the detail endpoint used to read `app.image` / `app.ports` /
|
|
* `app.env` / `app.volumes` — but every template stores these under
|
|
* `app.docker.*`. The result was a 200 with empty image/ports/env/volumes
|
|
* for EVERY app. Now reads `app.docker.*` and additionally surfaces the
|
|
* install-time config fields the App Selector needs to render its deploy
|
|
* form (defaultPort, subdomain, healthCheck, mediaMount, claimToken,
|
|
* difficulty, setupInstructions).
|
|
*/
|
|
const express = require('express');
|
|
const { ok, errorResponse } = require('../src/utils/responses');
|
|
|
|
module.exports = function({ APP_TEMPLATES, asyncHandler } = {}) {
|
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
|
const router = express.Router();
|
|
|
|
// Normalize APP_TEMPLATES (array or { id: {...} } map) into a value array.
|
|
const appArray = Array.isArray(APP_TEMPLATES)
|
|
? [...APP_TEMPLATES]
|
|
: Object.values(APP_TEMPLATES || {});
|
|
|
|
const appId = (t) => t.id || (t.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
|
|
const POPULAR = new Set([
|
|
'plex', 'jellyfin', 'sonarr', 'radarr', 'nextcloud', 'gitea', 'qbittorrent',
|
|
]);
|
|
|
|
// Catalog entry shape used by list + search results. Uses the template's
|
|
// OWN category field (DC-160: the old hand-rolled CATEGORY_MAP guessed by
|
|
// substring match — plex→media — which diverged from the templates'
|
|
// real categories and mis-categorized anything unlisted).
|
|
function listEntry(t) {
|
|
return {
|
|
id: appId(t),
|
|
name: t.name,
|
|
description: t.description || '',
|
|
category: t.category || 'Other',
|
|
logo: t.logo || null,
|
|
difficulty: t.difficulty || null,
|
|
popular: POPULAR.has(appId(t)),
|
|
};
|
|
}
|
|
|
|
// GET /api/v1/catalog — list all apps
|
|
router.get('/catalog', wrap(async (req, res) => {
|
|
const { category, sort } = req.query;
|
|
let entries = appArray.map(listEntry);
|
|
|
|
// Filter by category (templates' own category, case-insensitive)
|
|
if (category && category !== 'all') {
|
|
const want = String(category).toLowerCase();
|
|
entries = entries.filter((e) => String(e.category).toLowerCase() === want);
|
|
}
|
|
|
|
// Sort: default popular-first then name; sort=name alphabetical only.
|
|
if (sort === 'name') {
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
} else {
|
|
entries.sort((a, b) => {
|
|
if (a.popular !== b.popular) return a.popular ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
const categories = [...new Set(appArray.map((t) => t.category || 'Other'))].sort();
|
|
|
|
ok(res, {
|
|
total: entries.length,
|
|
categories,
|
|
apps: entries,
|
|
});
|
|
}));
|
|
|
|
// GET /api/v1/catalog/search?q=plex
|
|
router.get('/catalog/search', wrap(async (req, res) => {
|
|
const q = (req.query.q || '').toLowerCase().trim();
|
|
if (!q) {
|
|
return errorResponse(res, 400, 'Search query (q) is required');
|
|
}
|
|
|
|
const apps = appArray
|
|
.filter((t) => {
|
|
const name = (t.name || '').toLowerCase();
|
|
const desc = (t.description || '').toLowerCase();
|
|
const cat = String(t.category || '').toLowerCase();
|
|
const id = appId(t).toLowerCase();
|
|
return name.includes(q) || desc.includes(q) || cat.includes(q) || id.includes(q);
|
|
})
|
|
.map(listEntry);
|
|
|
|
ok(res, { query: q, results: apps.length, apps });
|
|
}));
|
|
|
|
// GET /api/v1/catalog/:appId — get specific app details (DC-160: reads app.docker.*)
|
|
router.get('/catalog/:appId', wrap(async (req, res) => {
|
|
const app = appArray.find((t) => appId(t) === req.params.appId);
|
|
|
|
if (!app) {
|
|
return errorResponse(res, 404, `App '${req.params.appId}' not found in catalog`);
|
|
}
|
|
|
|
const d = app.docker || {};
|
|
ok(res, {
|
|
id: app.id || req.params.appId,
|
|
name: app.name,
|
|
description: app.description || '',
|
|
category: app.category || 'Other',
|
|
image: d.image || '',
|
|
ports: d.ports || [],
|
|
env: d.environment || {},
|
|
volumes: d.volumes || [],
|
|
network: d.network || 'bridge',
|
|
restart: d.restart || 'unless-stopped',
|
|
defaultPort: app.defaultPort || null,
|
|
subdomain: app.subdomain || appId(app),
|
|
healthCheck: app.healthCheck || null,
|
|
mediaMount: app.mediaMount || null,
|
|
claimToken: app.claimToken || null,
|
|
difficulty: app.difficulty || null,
|
|
setupInstructions: app.setupInstructions || null,
|
|
});
|
|
}));
|
|
|
|
return router;
|
|
};
|