diff --git a/services/arbiter-3.0/src/routes/admin/branding-assets.js b/services/arbiter-3.0/src/routes/admin/branding-assets.js new file mode 100644 index 0000000..7eaa3fc --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/branding-assets.js @@ -0,0 +1,113 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const sharp = require('sharp'); + +const OPS_ROOT = '/opt/firefrost-ops-manual'; +const CACHE_DIR = '/var/cache/arbiter/branding-thumbs'; +const THUMB_SIZE = 256; +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']); + +// Scan roots — both /branding and /docs/branding +const SCAN_ROOTS = [ + { label: 'branding', rel: 'branding' }, + { label: 'docs/branding', rel: 'docs/branding' } +]; + +function walkImages(dir, baseRel) { + const out = []; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return out; } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + const rel = path.posix.join(baseRel, entry.name); + if (entry.isDirectory()) { + out.push(...walkImages(full, rel)); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (IMAGE_EXTS.has(ext)) { + try { + const st = fs.statSync(full); + out.push({ + rel, full, + name: entry.name, + category: path.posix.dirname(rel), + size: st.size, + mtime: st.mtimeMs + }); + } catch { /* skip */ } + } + } + } + return out; +} + +function listAllAssets() { + const all = []; + for (const root of SCAN_ROOTS) { + const abs = path.join(OPS_ROOT, root.rel); + all.push(...walkImages(abs, root.rel)); + } + // Sort by category, then name + all.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); + return all; +} + +// GET /admin/branding-assets/list — modal body +router.get('/list', (req, res) => { + try { + const assets = listAllAssets(); + // Group by category + const byCategory = {}; + for (const a of assets) { + if (!byCategory[a.category]) byCategory[a.category] = []; + byCategory[a.category].push(a); + } + res.render('admin/social-calendar/_assets', { byCategory, total: assets.length, layout: false }); + } catch (err) { + console.error('Asset list error:', err); + res.status(500).send("
Error loading assets.
"); + } +}); + +// GET /admin/branding-assets/thumb?path=branding/logos/firefrost-emblem-512.png +router.get('/thumb', async (req, res) => { + try { + const rel = String(req.query.path || ''); + // Security: must be under one of our scan roots, no path traversal + const normalized = path.posix.normalize(rel); + if (normalized.includes('..') || path.isAbsolute(normalized)) { + return res.status(400).send('Bad path'); + } + const allowed = SCAN_ROOTS.some(r => normalized.startsWith(r.rel + '/')); + if (!allowed) return res.status(400).send('Out of scope'); + + const source = path.join(OPS_ROOT, normalized); + if (!fs.existsSync(source)) return res.status(404).send('Not found'); + const stat = fs.statSync(source); + + // Cache key: sha1 of path + mtime so edits bust cache automatically + const key = crypto.createHash('sha1').update(normalized + ':' + stat.mtimeMs).digest('hex'); + const cachePath = path.join(CACHE_DIR, key + '.webp'); + + if (!fs.existsSync(cachePath)) { + await sharp(source) + .resize(THUMB_SIZE, THUMB_SIZE, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 80 }) + .toFile(cachePath); + } + + res.set('Content-Type', 'image/webp'); + res.set('Cache-Control', 'private, max-age=86400'); + fs.createReadStream(cachePath).pipe(res); + } catch (err) { + console.error('Thumb generation error:', err); + res.status(500).send('Thumb error'); + } +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index 30e81ab..e3a4624 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -17,6 +17,7 @@ const discordAuditRouter = require('./discord-audit'); const systemRouter = require('./system'); const socialRouter = require('./social'); const socialCalendarRouter = require('./social-calendar'); +const brandingAssetsRouter = require('./branding-assets'); const infrastructureRouter = require('./infrastructure'); const aboutRouter = require('./about'); const mcpLogsRouter = require('./mcp-logs'); @@ -125,6 +126,7 @@ router.use('/discord', discordAuditRouter); router.use('/system', systemRouter); router.use('/social', socialRouter); router.use('/social-calendar', socialCalendarRouter); +router.use('/branding-assets', brandingAssetsRouter); router.use('/infrastructure', infrastructureRouter); router.use('/about', aboutRouter); router.use('/mcp-logs', mcpLogsRouter); diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/_assets.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/_assets.ejs new file mode 100644 index 0000000..f78efa8 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social-calendar/_assets.ejs @@ -0,0 +1,36 @@ +
+
+
+

🎨 Branding Assets

+

<%= total %> images across <%= Object.keys(byCategory).length %> categories. Click to insert filename into media notes.

+
+ +
+ + <% if (total === 0) { %> +
+
📭
+
No branding assets found. Add files to branding/ in the ops manual repo.
+
+ <% } %> + + <% Object.keys(byCategory).sort().forEach(function(cat) { %> +
+

<%= cat %>

+
+ <% byCategory[cat].forEach(function(asset) { %> +
')"> +
+ <%= asset.name %> +
+
<%= asset.name %>
+
+ <% }); %> +
+
+ <% }); %> +
diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs index 7cfdf3b..82062ee 100644 --- a/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs +++ b/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs @@ -48,7 +48,17 @@
- +
+ + +
diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs index 0762f82..0feb89d 100644 --- a/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs +++ b/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs @@ -30,6 +30,13 @@ + + +