/** * 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; };