From d22ff8c3c9e82dff45ed0e685dac321053fa1e3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:10:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Task=20#125=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20Branding=20asset=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a visual asset library to the social calendar form, backed by the firefrost-operations-manual branding/ directory with on-the-fly thumbnail generation via sharp. - src/routes/admin/branding-assets.js: /list scans both branding/ and docs/branding/ recursively, groups by category directory. /thumb generates 256px webp thumbnails from source, caches to disk keyed by sha1(path + mtime) so edits bust the cache automatically. Path traversal protection + scope check on thumb requests. - src/views/admin/social-calendar/_assets.ejs: modal body with category-grouped grid of lazy-loaded thumbnails, click-to-insert. - Form modal gets a 'Browse assets' button next to Media Notes. - Calendar shell gets a second modal (higher z-index) and a sppInsertAsset() helper that appends the clicked filename to the media_notes textarea. Infrastructure (not in repo): - /opt/firefrost-ops-manual clone added to Command Center - /etc/systemd/system/firefrost-ops-sync.{service,timer} pulls every 15 minutes to keep assets fresh - /var/cache/arbiter/branding-thumbs created for thumb cache - sharp added as Arbiter dependency (libvips 8.17.3) Chronicler #81 --- .../src/routes/admin/branding-assets.js | 113 ++++++++++++++++++ .../arbiter-3.0/src/routes/admin/index.js | 2 + .../views/admin/social-calendar/_assets.ejs | 36 ++++++ .../src/views/admin/social-calendar/_form.ejs | 12 +- .../src/views/admin/social-calendar/index.ejs | 17 +++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 services/arbiter-3.0/src/routes/admin/branding-assets.js create mode 100644 services/arbiter-3.0/src/views/admin/social-calendar/_assets.ejs 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 @@ + + +