diff --git a/dashcaddy-api/routes/catalog.js b/dashcaddy-api/routes/catalog.js new file mode 100644 index 0000000..1488ea1 --- /dev/null +++ b/dashcaddy-api/routes/catalog.js @@ -0,0 +1,132 @@ +/** + * DC-104: 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 + */ +const express = require('express'); +const { ok, errorResponse } = require('../src/utils/responses'); + +// Category mapping for common apps +const CATEGORY_MAP = { + plex: 'media', jellyfin: 'media', emby: 'media', + sonarr: 'media', radarr: 'media', prowlarr: 'media', lidarr: 'media', + readarr: 'media', qbittorrent: 'media', transmission: 'media', + sabnzbd: 'media', nzbget: 'media', + nextcloud: 'productivity', vaultwarden: 'productivity', + gitea: 'development', portainer: 'development', code: 'development', + node: 'development', + redis: 'database', postgres: 'database', mariadb: 'database', mongo: 'database', + mysql: 'database', + nginx: 'network', caddy: 'network', adguard: 'network', pihole: 'network', + technitium: 'network', wireguard: 'network', + homeassistant: 'smart-home', mosquitto: 'smart-home', + grafana: 'monitoring', prometheus: 'monitoring', uptimekuma: 'monitoring', +}; + +function getTemplateCategory(template) { + const id = (template.id || template.name || '').toLowerCase(); + for (const [key, cat] of Object.entries(CATEGORY_MAP)) { + if (id.includes(key)) return cat; + } + return 'other'; +} + +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(); + + // GET /api/v1/catalog — list all apps + router.get('/catalog', wrap(async (req, res) => { + const { category, sort } = req.query; + let apps = APP_TEMPLATES || []; + + // Build catalog entries + let entries = apps.map(t => ({ + id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'), + name: t.name, + description: t.description || '', + category: getTemplateCategory(t), + logo: t.logo || null, + popular: ['plex', 'jellyfin', 'sonarr', 'radarr', 'nextcloud', 'gitea', 'qbittorrent'] + .includes((t.id || t.name || '').toLowerCase().replace(/\s+/g, '-')), + })); + + // Filter by category + if (category && category !== 'all') { + entries = entries.filter(e => e.category === category); + } + + // Sort + if (sort === 'name') { + entries.sort((a, b) => a.name.localeCompare(b.name)); + } else { + // Default: popular first, then alphabetical + entries.sort((a, b) => { + if (a.popular !== b.popular) return a.popular ? -1 : 1; + return a.name.localeCompare(b.name); + }); + } + + // Get categories + const categories = [...new Set(entries.map(e => e.category))].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 = (APP_TEMPLATES || []).filter(t => { + const name = (t.name || '').toLowerCase(); + const desc = (t.description || '').toLowerCase(); + const cat = getTemplateCategory(t).toLowerCase(); + return name.includes(q) || desc.includes(q) || cat.includes(q); + }).map(t => ({ + id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'), + name: t.name, + description: t.description || '', + category: getTemplateCategory(t), + })); + + ok(res, { query: q, results: apps.length, apps }); + })); + + // GET /api/v1/catalog/:appId — get specific app details + router.get('/catalog/:appId', wrap(async (req, res) => { + const appId = req.params.appId; + const app = (APP_TEMPLATES || []).find(t => { + const tid = (t.id || t.name?.toLowerCase().replace(/\s+/g, '-')); + return tid === appId; + }); + + if (!app) { + return errorResponse(res, 404, `App '${appId}' not found in catalog`); + } + + ok(res, { + id: app.id || appId, + name: app.name, + description: app.description || '', + category: getTemplateCategory(app), + image: app.image || '', + ports: app.ports || [], + env: app.env || {}, + volumes: app.volumes || [], + network: app.network || 'bridge', + restart: app.restart || 'unless-stopped', + }); + })); + + return router; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index a0ea76c..46af92f 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -63,6 +63,7 @@ const shareRoutes = require('../routes/share'); const i18nRoutes = require('../routes/i18n'); const discoverRoutes = require('../routes/discover'); const discoverAdoptRoutes = require('../routes/discover-adopt'); +const catalogRoutes = require('../routes/catalog'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -618,6 +619,12 @@ async function createApp() { siteConfig: ctx.config, asyncHandler: ctx.asyncHandler, })); + + // DC-104: App catalog — browse curated templates + apiRouter.use(catalogRoutes({ + APP_TEMPLATES: require('./docker/app-templates'), + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,