Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

oven: restore server.mjs + add /product/:name.png route

Papers auto-build accidentally deleted oven/server.mjs. Restored with
the new product image route for serving static hardware images from CDN.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+3717
+3717
oven/server.mjs
··· 1 + #!/usr/bin/env node 2 + // Oven Server 3 + // Main Express server for the unified bake processing service 4 + 5 + import 'dotenv/config'; 6 + import express from 'express'; 7 + import https from 'https'; 8 + import http from 'http'; 9 + import fs from 'fs'; 10 + import { execSync } from 'child_process'; 11 + import { gunzipSync, gzipSync } from 'node:zlib'; 12 + import { WebSocketServer } from 'ws'; 13 + import { healthHandler, bakeHandler, statusHandler, bakeCompleteHandler, bakeStatusHandler, getActiveBakes, getIncomingBakes, getRecentBakes, subscribeToUpdates, cleanupStaleBakes } from './baker.mjs'; 14 + import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser } from './grabber.mjs'; 15 + import archiver from 'archiver'; 16 + import sharp from 'sharp'; 17 + import { createBundle, createJSPieceBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs'; 18 + import { streamOSImage, getOSBuildStatus, invalidateManifest, purgeOSBuildCache, clearOSBuildLocalCache } from './os-builder.mjs'; 19 + import { startOSBaseBuild, getOSBaseBuild, getOSBaseBuildsSummary, cancelOSBaseBuild } from './os-base-build.mjs'; 20 + import { startNativeBuild, getNativeBuild, getNativeBuildsSummary, cancelNativeBuild } from './native-builder.mjs'; 21 + import { startPoller as startNativeGitPoller, getPollerStatus as getNativePollerStatus } from './native-git-poller.mjs'; 22 + import { startPapersBuild, getPapersBuild, getPapersBuildsSummary, cancelPapersBuild } from './papers-builder.mjs'; 23 + import { startPoller as startPapersGitPoller, getPollerStatus as getPapersPollerStatus } from './papers-git-poller.mjs'; 24 + import { join, dirname } from 'path'; 25 + import { fileURLToPath } from 'url'; 26 + 27 + const app = express(); 28 + const PORT = process.env.PORT || 3002; 29 + const dev = process.env.NODE_ENV === 'development'; 30 + 31 + // Track server start time for uptime display 32 + const SERVER_START_TIME = Date.now(); 33 + 34 + // Get git version at startup (from env var set during deploy, or try git) 35 + let GIT_VERSION = process.env.OVEN_VERSION || 'unknown'; 36 + if (GIT_VERSION === 'unknown') { 37 + try { 38 + GIT_VERSION = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); 39 + } catch (e) { 40 + // Not a git repo, that's fine 41 + } 42 + } 43 + console.log(`šŸ“¦ Oven version: ${GIT_VERSION}`); 44 + console.log(`šŸ• Server started at: ${new Date(SERVER_START_TIME).toISOString()}`); 45 + 46 + // Activity log buffer for streaming to clients 47 + const activityLogBuffer = []; 48 + const MAX_ACTIVITY_LOG = 100; 49 + let wss = null; // Will be set after server starts 50 + 51 + function addServerLog(type, icon, msg) { 52 + const entry = { time: new Date().toISOString(), type, icon, msg }; 53 + activityLogBuffer.unshift(entry); 54 + if (activityLogBuffer.length > MAX_ACTIVITY_LOG) { 55 + activityLogBuffer.pop(); 56 + } 57 + // Broadcast to connected clients if wss exists and has clients 58 + if (wss && wss.clients) { 59 + const logMsg = JSON.stringify({ logEntry: entry }); 60 + wss.clients.forEach(client => { 61 + if (client.readyState === 1) client.send(logMsg); 62 + }); 63 + } 64 + } 65 + 66 + // Export for use in other modules 67 + export { addServerLog }; 68 + 69 + // Log server startup 70 + addServerLog('info', 'šŸ”„', 'Oven server starting...'); 71 + 72 + // OS base-build admin key auth 73 + // Accepts either OS_BUILD_ADMIN_KEY directly in env, or OS_BUILD_ADMIN_KEY_FILE. 74 + let cachedOSBuildAdminKey = null; 75 + let cachedOSBuildAdminMtimeMs = null; 76 + 77 + function getConfiguredOSBuildAdminKey() { 78 + const envKey = (process.env.OS_BUILD_ADMIN_KEY || '').trim(); 79 + if (envKey) return envKey; 80 + 81 + const keyFile = (process.env.OS_BUILD_ADMIN_KEY_FILE || '').trim(); 82 + if (!keyFile) return ''; 83 + 84 + try { 85 + const stat = fs.statSync(keyFile); 86 + if (cachedOSBuildAdminKey && cachedOSBuildAdminMtimeMs === stat.mtimeMs) { 87 + return cachedOSBuildAdminKey; 88 + } 89 + const nextKey = fs.readFileSync(keyFile, 'utf8').trim(); 90 + cachedOSBuildAdminKey = nextKey; 91 + cachedOSBuildAdminMtimeMs = stat.mtimeMs; 92 + return nextKey; 93 + } catch { 94 + return ''; 95 + } 96 + } 97 + 98 + function getOSBuildRequestKey(req) { 99 + const headerKey = (req.get('x-oven-os-key') || '').trim(); 100 + if (headerKey) return headerKey; 101 + const auth = (req.get('authorization') || '').trim(); 102 + if (auth.startsWith('Bearer ')) return auth.slice(7).trim(); 103 + return ''; 104 + } 105 + 106 + function requireOSBuildAdmin(req, res, next) { 107 + const expectedKey = getConfiguredOSBuildAdminKey(); 108 + if (!expectedKey) { 109 + return res.status(503).json({ 110 + error: 'OS build admin key not configured. Set OS_BUILD_ADMIN_KEY or OS_BUILD_ADMIN_KEY_FILE.', 111 + }); 112 + } 113 + 114 + const providedKey = getOSBuildRequestKey(req); 115 + if (!providedKey || providedKey !== expectedKey) { 116 + return res.status(401).json({ error: 'Unauthorized' }); 117 + } 118 + 119 + return next(); 120 + } 121 + 122 + // ===== SHARED PROGRESS UI COMPONENTS ===== 123 + // Shared CSS for progress indicators across all oven dashboards 124 + const PROGRESS_UI_CSS = ` 125 + /* Oven Progress UI - shared across all dashboards */ 126 + .oven-loading { 127 + position: absolute; 128 + inset: 0; 129 + display: flex; 130 + flex-direction: column; 131 + align-items: center; 132 + justify-content: center; 133 + background: rgba(0,0,0,0.85); 134 + color: #888; 135 + text-align: center; 136 + padding: 10px; 137 + z-index: 10; 138 + } 139 + .oven-loading .preview-img { 140 + width: 80px; 141 + height: 80px; 142 + image-rendering: pixelated; 143 + border: 1px solid #333; 144 + margin-bottom: 8px; 145 + display: none; 146 + background: #111; 147 + } 148 + .oven-loading .loading-text { 149 + font-size: 12px; 150 + color: #fff; 151 + } 152 + .oven-loading .progress-text { 153 + font-size: 11px; 154 + margin-top: 8px; 155 + color: #88ff88; 156 + font-family: monospace; 157 + max-width: 150px; 158 + word-break: break-word; 159 + } 160 + .oven-loading .progress-bar { 161 + width: 80%; 162 + max-width: 150px; 163 + height: 4px; 164 + background: #333; 165 + border-radius: 2px; 166 + margin: 8px auto 0; 167 + overflow: hidden; 168 + } 169 + .oven-loading .progress-bar-fill { 170 + height: 100%; 171 + background: #88ff88; 172 + width: 0%; 173 + transition: width 0.3s ease; 174 + } 175 + .oven-loading.error { 176 + color: #f44; 177 + } 178 + .oven-loading.success { 179 + color: #4f4; 180 + } 181 + `; 182 + 183 + // Shared JavaScript for progress polling and UI updates 184 + const PROGRESS_UI_JS = ` 185 + // Shared progress state 186 + let progressPollInterval = null; 187 + 188 + // Update any loading indicator with progress data 189 + function updateOvenLoadingUI(container, data, queueInfo) { 190 + if (!container) return; 191 + 192 + const loadingText = container.querySelector('.loading-text'); 193 + const progressText = container.querySelector('.progress-text'); 194 + const progressBar = container.querySelector('.progress-bar-fill'); 195 + const previewImg = container.querySelector('.preview-img'); 196 + 197 + // Check if item is in queue and get position 198 + let queuePosition = null; 199 + if (queueInfo && queueInfo.length > 0 && data.piece) { 200 + const queueItem = queueInfo.find(q => q.piece === data.piece); 201 + if (queueItem) { 202 + queuePosition = queueItem.position; 203 + } 204 + } 205 + 206 + // Map stage to friendly text 207 + const stageText = { 208 + 'loading': 'šŸš€ Loading piece...', 209 + 'waiting-content': 'ā³ Waiting for render...', 210 + 'settling': 'āøļø Settling...', 211 + 'capturing': 'šŸ“ø Capturing...', 212 + 'encoding': 'šŸ”„ Processing...', 213 + 'uploading': 'ā˜ļø Uploading...', 214 + 'queued': queuePosition ? 'ā³ In queue (#' + queuePosition + ')...' : 'ā³ In queue...', 215 + }; 216 + 217 + if (loadingText && data.stage) { 218 + loadingText.textContent = stageText[data.stage] || data.stage; 219 + } 220 + if (progressText && data.stageDetail) { 221 + progressText.textContent = data.stageDetail; 222 + } 223 + if (progressBar && data.percent != null) { 224 + progressBar.style.width = data.percent + '%'; 225 + } 226 + // Show streaming preview 227 + if (previewImg && data.previewFrame) { 228 + previewImg.src = 'data:image/jpeg;base64,' + data.previewFrame; 229 + previewImg.style.display = 'block'; 230 + } 231 + } 232 + 233 + // Create loading HTML structure 234 + function createOvenLoadingHTML(initialText = 'šŸ”„ Loading...') { 235 + return '<img class="preview-img" alt="preview">' + 236 + '<span class="loading-text">' + initialText + '</span>' + 237 + '<div class="progress-text"></div>' + 238 + '<div class="progress-bar"><div class="progress-bar-fill"></div></div>'; 239 + } 240 + 241 + // Start polling /grab-status for progress updates 242 + function startProgressPolling(callback, intervalMs = 150) { 243 + stopProgressPolling(); 244 + progressPollInterval = setInterval(async () => { 245 + try { 246 + const res = await fetch('/grab-status'); 247 + const data = await res.json(); 248 + if (callback && data.progress) { 249 + callback(data); 250 + } 251 + } catch (err) { 252 + // Ignore polling errors 253 + } 254 + }, intervalMs); 255 + } 256 + 257 + function stopProgressPolling() { 258 + if (progressPollInterval) { 259 + clearInterval(progressPollInterval); 260 + progressPollInterval = null; 261 + } 262 + } 263 + `; 264 + 265 + // Parse JSON bodies 266 + app.use(express.json()); 267 + 268 + // CORS headers for cross-origin image loading (needed for canvas pixel validation) 269 + app.use((req, res, next) => { 270 + res.setHeader('Access-Control-Allow-Origin', '*'); 271 + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 272 + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 273 + if (req.method === 'OPTIONS') { 274 + return res.sendStatus(200); 275 + } 276 + next(); 277 + }); 278 + 279 + // Serve font glyph JSONs locally for Puppeteer captures. 280 + // Font_1 glyph XHR requests from the disk.mjs worker are redirected here 281 + // by the request interceptor to avoid Puppeteer's broken concurrent XHR handling. 282 + const __serverDirname = dirname(fileURLToPath(import.meta.url)); 283 + app.get('/local-glyph/*', (req, res) => { 284 + const glyphPath = req.params[0]; // Express auto-decodes URI params 285 + // Sanitize: only allow paths within ac-source/disks/drawings 286 + if (glyphPath.includes('..') || glyphPath.includes('\0')) { 287 + return res.status(400).send('Invalid path'); 288 + } 289 + const filePath = join(__serverDirname, 'ac-source', 'disks', 'drawings', glyphPath); 290 + res.sendFile(filePath, (err) => { 291 + if (err) res.status(404).json({ error: 'glyph not found' }); 292 + }); 293 + }); 294 + 295 + // Oven TV dashboard — live-updating visual bake monitor 296 + app.get('/', (req, res) => { 297 + res.setHeader('Content-Type', 'text/html'); 298 + res.send(OVEN_TV_HTML); 299 + }); 300 + 301 + const OVEN_TV_HTML = `<!DOCTYPE html> 302 + <html> 303 + <head> 304 + <meta charset="utf-8"> 305 + <title>oven</title> 306 + <meta name="viewport" content="width=device-width, initial-scale=1"> 307 + <link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png"> 308 + <style> 309 + :root { 310 + --bg: #f7f7f7; 311 + --bg-deep: #ececec; 312 + --bg-card: #fff; 313 + --bg-hover: #f0f0f0; 314 + --text: #111; 315 + --text-secondary: #555; 316 + --text-muted: #888; 317 + --text-dim: #aaa; 318 + --border: #ddd; 319 + --border-subtle: #e8e8e8; 320 + --accent: rgb(205, 92, 155); 321 + --accent-hover: rgb(220, 110, 170); 322 + --success: #2a9a2a; 323 + --error: #c44; 324 + --preview-bg: #e0e0e0; 325 + --overlay-bg: rgba(255,255,255,0.92); 326 + --scrollbar: transparent; 327 + } 328 + @media (prefers-color-scheme: dark) { 329 + :root { 330 + --bg: #1e1e1e; 331 + --bg-deep: #161616; 332 + --bg-card: #252526; 333 + --bg-hover: #2a2a2a; 334 + --text: #d4d4d4; 335 + --text-secondary: #888; 336 + --text-muted: #666; 337 + --text-dim: #444; 338 + --border: #3e3e42; 339 + --border-subtle: #2e2e32; 340 + --accent: rgb(205, 92, 155); 341 + --accent-hover: rgb(225, 115, 175); 342 + --success: #4caf50; 343 + --error: #f44; 344 + --preview-bg: #111; 345 + --overlay-bg: rgba(0,0,0,0.88); 346 + } 347 + } 348 + 349 + * { box-sizing: border-box; margin: 0; padding: 0; } 350 + ::-webkit-scrollbar { display: none; } 351 + 352 + body { 353 + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; 354 + font-size: 15px; 355 + background: var(--bg); 356 + color: var(--text); 357 + height: 100vh; 358 + display: flex; 359 + flex-direction: column; 360 + overflow: hidden; 361 + } 362 + 363 + .status-bar { 364 + background: var(--bg-deep); 365 + border-bottom: 2px solid var(--accent); 366 + padding: 5px 12px; 367 + display: flex; 368 + align-items: center; 369 + justify-content: space-between; 370 + flex-shrink: 0; 371 + gap: 8px; 372 + } 373 + .status-bar .title { color: var(--accent); font-weight: bold; font-size: 1.1em; } 374 + .status-bar .stats { display: flex; gap: 12px; color: var(--text-muted); font-size: 0.95em; } 375 + .status-bar .stats span { white-space: nowrap; } 376 + .status-bar .stats .active { color: var(--success); } 377 + .status-bar .stats .queued { color: var(--accent); } 378 + .sb-btn { 379 + background: var(--bg-card); color: var(--text-secondary); border: 1px solid var(--border); 380 + padding: 3px 8px; cursor: pointer; font-family: inherit; font-size: 0.85em; 381 + border-radius: 3px; text-decoration: none; display: inline-block; 382 + } 383 + .sb-btn:hover { color: var(--text); border-color: var(--accent); } 384 + 385 + .hero { 386 + flex: 1; 387 + display: flex; 388 + align-items: center; 389 + justify-content: center; 390 + gap: 10px; 391 + padding: 10px; 392 + min-height: 0; 393 + overflow: hidden; 394 + } 395 + .hero.idle { color: var(--text-dim); font-size: 1.2em; } 396 + .hero-card { 397 + background: var(--bg-card); 398 + border: 2px solid var(--border); 399 + border-radius: 6px; 400 + display: flex; 401 + flex-direction: row; 402 + align-items: stretch; 403 + height: 90px; 404 + min-width: 200px; 405 + max-width: 320px; 406 + flex-shrink: 0; 407 + overflow: hidden; 408 + } 409 + .hero-card.capturing { border-color: var(--accent); } 410 + .hero-card .preview { 411 + width: 86px; 412 + min-width: 86px; 413 + background: var(--preview-bg); 414 + overflow: hidden; 415 + display: flex; 416 + align-items: center; 417 + justify-content: center; 418 + } 419 + .hero-card .preview img { 420 + width: 100%; 421 + height: 100%; 422 + object-fit: cover; 423 + image-rendering: pixelated; 424 + } 425 + .hero-card .preview .placeholder { color: var(--text-dim); font-size: 1.2em; } 426 + .hero-card .info { 427 + flex: 1; 428 + padding: 4px 6px; 429 + display: flex; 430 + flex-direction: column; 431 + justify-content: center; 432 + gap: 2px; 433 + min-width: 0; 434 + overflow: hidden; 435 + } 436 + .hero-card .piece-name { color: var(--accent); font-weight: bold; font-size: 0.95em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 437 + .hero-card .meta { color: var(--text-dim); font-size: 0.75em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; } 438 + .hero-card .meta .author { color: var(--text-secondary); } 439 + .hero-card .stage { color: var(--text-muted); font-size: 0.8em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; } 440 + .hero-card .progress-bar { 441 + width: 100%; 442 + height: 3px; 443 + background: var(--border); 444 + flex-shrink: 0; 445 + margin-top: auto; 446 + } 447 + .hero-card .progress-bar .fill { 448 + height: 100%; 449 + background: var(--accent); 450 + transition: width 0.3s ease; 451 + } 452 + 453 + .strip { 454 + background: var(--bg-deep); 455 + border-top: 1px solid var(--border); 456 + padding: 5px 12px; 457 + flex-shrink: 0; 458 + overflow: hidden; 459 + } 460 + .strip-label { 461 + color: var(--text-muted); 462 + font-size: 0.75em; 463 + text-transform: uppercase; 464 + letter-spacing: 1px; 465 + margin-bottom: 4px; 466 + } 467 + .strip-items { 468 + display: flex; 469 + gap: 6px; 470 + padding-bottom: 2px; 471 + white-space: nowrap; 472 + } 473 + .strip-items.train { 474 + animation: train-scroll 30s linear infinite; 475 + width: max-content; 476 + } 477 + @keyframes train-scroll { 478 + 0% { transform: translateX(0); } 479 + 100% { transform: translateX(-50%); } 480 + } 481 + .strip-item { 482 + background: var(--bg-card); 483 + border: 1px solid var(--border); 484 + border-radius: 3px; 485 + padding: 2px 8px; 486 + white-space: nowrap; 487 + font-size: 0.9em; 488 + flex-shrink: 0; 489 + } 490 + .strip-item.queue { color: var(--accent); } 491 + .strip-empty { color: var(--text-dim); font-size: 0.9em; padding: 2px 0; } 492 + 493 + .history { 494 + background: var(--bg-deep); 495 + border-top: 1px solid var(--border); 496 + flex: 1; 497 + min-height: 0; 498 + overflow-y: auto; 499 + padding: 0; 500 + } 501 + .history .strip-label { padding: 5px 12px 3px; } 502 + .history-row { 503 + display: flex; 504 + align-items: center; 505 + gap: 10px; 506 + padding: 5px 12px; 507 + border-bottom: 1px solid var(--border-subtle); 508 + } 509 + .history-row:hover { background: var(--bg-hover); } 510 + .history-row .h-thumb { 511 + width: 44px; 512 + height: 44px; 513 + min-width: 44px; 514 + border-radius: 3px; 515 + background: var(--bg-card); 516 + flex-shrink: 0; 517 + overflow: hidden; 518 + display: flex; 519 + align-items: center; 520 + justify-content: center; 521 + } 522 + .history-row .h-thumb img { 523 + width: 100%; 524 + height: 100%; 525 + object-fit: cover; 526 + image-rendering: pixelated; 527 + } 528 + .history-row .h-thumb .h-none { color: var(--text-dim); font-size: 1em; } 529 + .history-row .h-main { flex: 1; min-width: 0; } 530 + .history-row .h-piece { font-weight: bold; font-size: 0.9em; } 531 + .history-row .h-piece a { color: inherit; text-decoration: none; } 532 + .history-row .h-piece a:hover { text-decoration: underline; } 533 + .history-row .h-meta { color: var(--text-muted); font-size: 0.78em; margin-top: 2px; display: flex; gap: 10px; flex-wrap: wrap; } 534 + .history-row .h-meta span { white-space: nowrap; } 535 + .history-row .h-error { color: var(--error); font-size: 0.78em; margin-top: 2px; opacity: 0.8; } 536 + .history-row .h-links { margin-top: 2px; display: flex; gap: 8px; } 537 + .history-row .h-links a { color: var(--accent); font-size: 0.78em; text-decoration: none; } 538 + .history-row .h-links a:hover { text-decoration: underline; } 539 + .history-row .h-right { 540 + flex-shrink: 0; 541 + text-align: right; 542 + font-size: 0.8em; 543 + } 544 + .history-row .h-status-done { color: var(--success); } 545 + .history-row .h-status-failed { color: var(--error); } 546 + .history-row .h-status-other { color: var(--text-muted); } 547 + .history-row .h-ago { color: var(--text-dim); font-size: 0.85em; } 548 + 549 + @keyframes card-enter { 550 + from { opacity: 0; transform: translateX(-120px) scale(0.9); } 551 + to { opacity: 1; transform: translateX(0) scale(1); } 552 + } 553 + @keyframes card-exit { 554 + from { opacity: 1; transform: translateX(0) scale(1); } 555 + to { opacity: 0; transform: translateX(120px) scale(0.9); } 556 + } 557 + .hero-card { 558 + animation: card-enter 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; 559 + } 560 + .hero-card.exiting { 561 + animation: card-exit 0.5s cubic-bezier(0.55, 0, 1, 0.45) forwards; 562 + pointer-events: none; 563 + } 564 + 565 + .capture-bar { display: none; } 566 + 567 + .log-overlay { 568 + display: none; 569 + position: fixed; 570 + top: 0; left: 0; right: 0; bottom: 0; 571 + background: var(--overlay-bg); 572 + z-index: 200; 573 + padding: 32px 16px 16px; 574 + overflow-y: auto; 575 + } 576 + .log-overlay.open { display: block; } 577 + .log-overlay .close { 578 + position: fixed; 579 + top: 6px; 580 + right: 12px; 581 + background: none; 582 + border: none; 583 + color: var(--accent); 584 + font-size: 1.3em; 585 + cursor: pointer; 586 + font-family: inherit; 587 + } 588 + .log-entry { 589 + padding: 1px 0; 590 + font-size: 0.82em; 591 + color: var(--text-secondary); 592 + white-space: nowrap; 593 + overflow: hidden; 594 + text-overflow: ellipsis; 595 + } 596 + .log-entry .time { color: var(--text-muted); } 597 + .log-entry.error { color: var(--error); } 598 + .log-entry.success { color: var(--success); } 599 + </style> 600 + </head> 601 + <body> 602 + 603 + <div class="status-bar"> 604 + <span class="title">oven</span> 605 + <div class="stats"> 606 + <span class="active" id="stat-active">0/6 active</span> 607 + <span class="queued" id="stat-queued">0 queued</span> 608 + <span id="stat-uptime">--</span> 609 + <span id="stat-version">--</span> 610 + </div> 611 + <div style="display:flex;gap:4px"> 612 + <a href="/tools" class="sb-btn">Tools</a> 613 + <button class="sb-btn" id="log-btn" onclick="document.getElementById('log-overlay').classList.toggle('open')">Log</button> 614 + </div> 615 + </div> 616 + 617 + <div class="hero idle" id="hero">Waiting for grabs...</div> 618 + 619 + <div class="strip" id="queue-strip"> 620 + <div class="strip-label">Up Next</div> 621 + <div class="strip-items" id="queue-items"> 622 + <span class="strip-empty">No items queued</span> 623 + </div> 624 + </div> 625 + 626 + <div class="history" id="history"> 627 + <div class="strip-label">Recent</div> 628 + <div id="history-items"> 629 + <span class="strip-empty">No recent grabs</span> 630 + </div> 631 + </div> 632 + 633 + <div class="log-overlay" id="log-overlay"> 634 + <button class="close" onclick="this.parentElement.classList.remove('open')">x</button> 635 + <div id="log-entries"></div> 636 + </div> 637 + 638 + <script> 639 + let serverVersion = null; 640 + let ws = null; 641 + let reconnectTimer = null; 642 + 643 + function connect() { 644 + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; 645 + ws = new WebSocket(proto + '//' + location.host + '/ws'); 646 + 647 + ws.onopen = () => { 648 + document.getElementById('stat-version').textContent = 'connected'; 649 + document.getElementById('stat-version').style.color = 'var(--success)'; 650 + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } 651 + }; 652 + 653 + ws.onclose = () => { 654 + document.getElementById('stat-version').textContent = 'disconnected'; 655 + document.getElementById('stat-version').style.color = 'var(--error)'; 656 + reconnectTimer = setTimeout(connect, 2000); 657 + }; 658 + 659 + ws.onmessage = (event) => { 660 + const data = JSON.parse(event.data); 661 + 662 + if (data.logEntry) { addLog(data.logEntry); return; } 663 + if (serverVersion && data.version && data.version !== serverVersion) { 664 + location.reload(); 665 + return; 666 + } 667 + serverVersion = data.version; 668 + 669 + if (data.recentLogs) { 670 + data.recentLogs.forEach(addLog); 671 + } 672 + 673 + updateStatusBar(data); 674 + renderHero(data.grabProgress || {}); 675 + renderQueue(data.grabs?.queue || []); 676 + renderHistory(data.grabs?.recent || []); 677 + }; 678 + } 679 + 680 + function updateStatusBar(data) { 681 + const c = data.concurrency || {}; 682 + document.getElementById('stat-active').textContent = (c.active || 0) + '/' + (c.max || 6) + ' active'; 683 + document.getElementById('stat-queued').textContent = (c.queueDepth || 0) + ' queued'; 684 + 685 + if (data.uptime) { 686 + const s = Math.floor(data.uptime / 1000); 687 + const m = Math.floor(s / 60); 688 + const h = Math.floor(m / 60); 689 + const d = Math.floor(h / 24); 690 + let upStr; 691 + if (d > 0) upStr = d + 'd ' + (h % 24) + 'h'; 692 + else if (h > 0) upStr = h + 'h ' + (m % 60) + 'm'; 693 + else upStr = m + 'm ' + (s % 60) + 's'; 694 + document.getElementById('stat-uptime').textContent = 'up ' + upStr; 695 + } 696 + if (data.version) { 697 + document.getElementById('stat-version').textContent = data.version; 698 + document.getElementById('stat-version').style.color = 'var(--text-muted)'; 699 + } 700 + } 701 + 702 + let heroCards = {}; // grabId → DOM element 703 + let exitingCards = new Set(); // grabIds currently animating out 704 + 705 + function ago(ms) { 706 + const s = Math.floor(ms / 1000); 707 + if (s < 60) return s + 's ago'; 708 + const m = Math.floor(s / 60); 709 + return m + 'm ' + (s % 60) + 's ago'; 710 + } 711 + 712 + function shortDate(iso) { 713 + if (!iso) return ''; 714 + const d = new Date(iso); 715 + if (isNaN(d)) return ''; 716 + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 717 + return months[d.getMonth()] + ' ' + d.getDate(); 718 + } 719 + 720 + function renderHero(grabProgress) { 721 + const hero = document.getElementById('hero'); 722 + const entries = Object.entries(grabProgress).filter(([, p]) => p.stage); 723 + 724 + // Animate out cards no longer in progress 725 + const activeIds = new Set(entries.map(([id]) => id)); 726 + for (const id of Object.keys(heroCards)) { 727 + if (!activeIds.has(id) && !exitingCards.has(id)) { 728 + exitingCards.add(id); 729 + const card = heroCards[id]; 730 + card.classList.add('exiting'); 731 + card.addEventListener('animationend', () => { 732 + card.remove(); 733 + delete heroCards[id]; 734 + exitingCards.delete(id); 735 + // Check if hero should go idle after last card exits 736 + if (Object.keys(heroCards).length === 0 && exitingCards.size === 0) { 737 + hero.className = 'hero idle'; 738 + hero.textContent = 'Waiting for grabs\\u2026'; 739 + } 740 + }, { once: true }); 741 + } 742 + } 743 + 744 + if (entries.length === 0 && Object.keys(heroCards).length === 0 && exitingCards.size === 0) { 745 + if (!hero.classList.contains('idle')) { 746 + hero.className = 'hero idle'; 747 + hero.textContent = 'Waiting for grabs\\u2026'; 748 + } 749 + return; 750 + } 751 + 752 + // Clear idle text when transitioning to active 753 + if (hero.classList.contains('idle')) { 754 + hero.innerHTML = ''; 755 + } 756 + hero.className = 'hero'; 757 + 758 + // Update or create cards 759 + entries.forEach(([grabId, p]) => { 760 + const previewSrc = p.previewFrame 761 + ? 'data:image/jpeg;base64,' + p.previewFrame 762 + : ''; 763 + const stageLabel = p.stage ? (p.stage.charAt(0).toUpperCase() + p.stage.slice(1)) : ''; 764 + const detail = p.stageDetail || ''; 765 + const pct = p.percent || 0; 766 + const now = Date.now(); 767 + const reqAgo = p.requestedAt ? ago(now - p.requestedAt) : ''; 768 + const authorStr = p.author || ''; 769 + const createdStr = shortDate(p.pieceCreatedAt); 770 + const sourceStr = p.source || ''; 771 + const originStr = p.requestOrigin ? p.requestOrigin.replace(/^https?:\\/\\//, '').split('/')[0] : ''; 772 + 773 + let metaParts = []; 774 + if (authorStr) metaParts.push('<span class="author">' + esc(authorStr) + '</span>'); 775 + if (sourceStr) metaParts.push(sourceStr); 776 + if (originStr) metaParts.push(originStr); 777 + if (createdStr) metaParts.push('created ' + createdStr); 778 + if (reqAgo) metaParts.push(reqAgo); 779 + const metaHtml = metaParts.join(' Ā· '); 780 + 781 + let card = heroCards[grabId]; 782 + if (!card) { 783 + card = document.createElement('div'); 784 + card.className = 'hero-card' + (p.stage === 'capturing' ? ' capturing' : ''); 785 + card.innerHTML = 786 + '<div class="preview">' + (previewSrc ? '<img src="' + previewSrc + '" alt="">' : '<span class="placeholder">Ā·Ā·Ā·</span>') + '</div>' + 787 + '<div class="info">' + 788 + '<div class="piece-name">' + esc(p.piece || grabId) + '</div>' + 789 + '<div class="meta">' + metaHtml + '</div>' + 790 + '<div class="stage">' + esc(stageLabel + (detail ? ' — ' + detail : '')) + '</div>' + 791 + '<div class="progress-bar"><div class="fill" style="width:' + pct + '%"></div></div>' + 792 + '</div>'; 793 + hero.appendChild(card); 794 + heroCards[grabId] = card; 795 + } else { 796 + card.className = 'hero-card' + (p.stage === 'capturing' ? ' capturing' : ''); 797 + const img = card.querySelector('.preview img'); 798 + if (previewSrc && img) { 799 + if (img.src !== previewSrc) img.src = previewSrc; 800 + } else if (previewSrc && !img) { 801 + card.querySelector('.preview').innerHTML = '<img src="' + previewSrc + '" alt="">'; 802 + } 803 + card.querySelector('.piece-name').textContent = p.piece || grabId; 804 + card.querySelector('.meta').innerHTML = metaHtml; 805 + card.querySelector('.stage').textContent = stageLabel + (detail ? ' — ' + detail : ''); 806 + card.querySelector('.fill').style.width = pct + '%'; 807 + } 808 + }); 809 + } 810 + 811 + let lastQueueKey = ''; 812 + function renderQueue(queue) { 813 + const el = document.getElementById('queue-items'); 814 + if (!queue || queue.length === 0) { 815 + if (lastQueueKey !== 'empty') { 816 + el.innerHTML = '<span class="strip-empty">No items queued</span>'; 817 + el.classList.remove('train'); 818 + lastQueueKey = 'empty'; 819 + } 820 + return; 821 + } 822 + // Only re-render if queue contents changed (avoids restarting CSS animation) 823 + const queueKey = queue.map(q => q.piece).join(','); 824 + if (queueKey === lastQueueKey) return; 825 + lastQueueKey = queueKey; 826 + 827 + const items = queue.map((item, i) => 828 + '<div class="strip-item queue">' + 829 + '#' + (i + 1) + ' ' + esc(item.piece || '?') + 830 + ' <span style="color:var(--text-muted)">(' + esc(item.format || '?') + ')</span>' + 831 + (item.estimatedWait ? ' <span style="color:var(--text-dim)">~' + Math.ceil(item.estimatedWait / 1000) + 's</span>' : '') + 832 + '</div>' 833 + ).join(''); 834 + // Duplicate items for seamless looping train effect 835 + if (queue.length > 4) { 836 + el.innerHTML = items + items; 837 + el.classList.add('train'); 838 + el.style.animationDuration = Math.max(10, queue.length * 2) + 's'; 839 + } else { 840 + el.innerHTML = items; 841 + el.classList.remove('train'); 842 + } 843 + } 844 + 845 + let lastHistoryKey = ''; 846 + function renderHistory(recent) { 847 + const el = document.getElementById('history-items'); 848 + if (!recent || recent.length === 0) { 849 + if (lastHistoryKey !== 'empty') { 850 + el.innerHTML = '<span class="strip-empty" style="padding:5px 12px">No recent grabs</span>'; 851 + lastHistoryKey = 'empty'; 852 + } 853 + return; 854 + } 855 + const historyKey = recent.slice(0, 30).map(g => (g.id || g.piece) + ':' + g.status).join(','); 856 + if (historyKey === lastHistoryKey) return; 857 + lastHistoryKey = historyKey; 858 + el.innerHTML = recent.slice(0, 30).map(grab => { 859 + const thumbImg = grab.cdnUrl 860 + ? '<img src="' + esc(grab.cdnUrl) + '" alt="">' 861 + : '<span class="h-none">--</span>'; 862 + 863 + const pieceClass = grab.status === 'failed' ? 'h-status-failed' : ''; 864 + 865 + const dur = grab.duration ? Math.round(grab.duration / 1000) + 's' : ''; 866 + const dim = grab.dimensions ? grab.dimensions.width + 'x' + grab.dimensions.height : ''; 867 + const fmt = (grab.format || '').toUpperCase(); 868 + const size = grab.size ? (grab.size > 1024*1024 ? (grab.size/1024/1024).toFixed(1)+'MB' : Math.round(grab.size/1024)+'KB') : ''; 869 + 870 + const metaParts = [fmt, dim, dur, size].filter(Boolean); 871 + const metaHTML = metaParts.map(m => '<span>' + esc(m) + '</span>').join(''); 872 + 873 + const errorHTML = grab.error 874 + ? '<div class="h-error">' + esc(grab.error) + '</div>' 875 + : ''; 876 + 877 + let linksHTML = ''; 878 + if (grab.cdnUrl) { 879 + linksHTML = '<div class="h-links">' + 880 + '<a href="' + esc(grab.cdnUrl) + '" target="_blank">Open</a>' + 881 + '<a href="' + esc(grab.cdnUrl) + '" download>Download</a>' + 882 + '</div>'; 883 + } 884 + 885 + const statusClass = grab.status === 'complete' ? 'h-status-done' : 886 + grab.status === 'failed' ? 'h-status-failed' : 'h-status-other'; 887 + const statusLabel = grab.status === 'complete' ? 'done' : 888 + grab.status === 'failed' ? 'failed' : 889 + esc(grab.status || '?'); 890 + 891 + const ago = grab.completedAt ? timeAgo(grab.completedAt) : ''; 892 + 893 + const pieceName = esc(grab.piece || grab.id || '?'); 894 + const pieceLink = grab.cdnUrl 895 + ? '<a href="' + esc(grab.cdnUrl) + '" target="_blank">' + pieceName + '</a>' 896 + : pieceName; 897 + 898 + return '<div class="history-row">' + 899 + '<div class="h-thumb">' + thumbImg + '</div>' + 900 + '<div class="h-main">' + 901 + '<div class="h-piece ' + pieceClass + '">' + pieceLink + '</div>' + 902 + '<div class="h-meta">' + metaHTML + '</div>' + 903 + errorHTML + 904 + linksHTML + 905 + '</div>' + 906 + '<div class="h-right">' + 907 + '<div class="' + statusClass + '">' + statusLabel + '</div>' + 908 + '<div class="h-ago">' + esc(ago) + '</div>' + 909 + '</div>' + 910 + '</div>'; 911 + }).join(''); 912 + } 913 + 914 + function timeAgo(ts) { 915 + const s = Math.floor((Date.now() - ts) / 1000); 916 + if (s < 60) return s + 's ago'; 917 + const m = Math.floor(s / 60); 918 + if (m < 60) return m + 'm ago'; 919 + const h = Math.floor(m / 60); 920 + if (h < 24) return h + 'h ago'; 921 + return Math.floor(h / 24) + 'd ago'; 922 + } 923 + 924 + function addLog(entry) { 925 + if (!entry) return; 926 + const el = document.getElementById('log-entries'); 927 + const div = document.createElement('div'); 928 + div.className = 'log-entry' + (entry.type === 'error' ? ' error' : entry.type === 'success' ? ' success' : ''); 929 + const time = entry.time ? new Date(entry.time).toLocaleTimeString() : ''; 930 + div.innerHTML = '<span class="time">' + time + '</span> ' + esc((entry.icon || '') + ' ' + (entry.msg || '')); 931 + el.prepend(div); 932 + while (el.children.length > 200) el.removeChild(el.lastChild); 933 + } 934 + 935 + function esc(s) { 936 + if (!s) return ''; 937 + return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 938 + } 939 + 940 + connect(); 941 + </script> 942 + </body> 943 + </html>`; 944 + 945 + // Tools submenu — links to OG images, app screenshots, bundles, status pages 946 + app.get('/tools', (req, res) => { 947 + res.setHeader('Content-Type', 'text/html'); 948 + res.send(`<!DOCTYPE html> 949 + <html> 950 + <head> 951 + <meta charset="utf-8"> 952 + <title>oven / tools</title> 953 + <meta name="viewport" content="width=device-width, initial-scale=1"> 954 + <style> 955 + :root { 956 + --bg: #f7f7f7; --text: #111; --text-muted: #888; --text-dim: #aaa; 957 + --accent: rgb(205, 92, 155); --border: #ddd; 958 + } 959 + @media (prefers-color-scheme: dark) { 960 + :root { 961 + --bg: #1e1e1e; --text: #d4d4d4; --text-muted: #666; --text-dim: #444; 962 + --accent: rgb(205, 92, 155); --border: #3e3e42; 963 + } 964 + } 965 + * { box-sizing: border-box; margin: 0; padding: 0; } 966 + body { font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; font-size: 12px; background: var(--bg); color: var(--text); padding: 20px; } 967 + a { color: var(--accent); text-decoration: none; } 968 + a:hover { text-decoration: underline; } 969 + h1 { color: var(--accent); margin-bottom: 20px; font-size: 1.1em; } 970 + h1 a { color: var(--text-muted); } 971 + h2 { color: var(--text-muted); margin: 16px 0 6px; font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; } 972 + .links { display: flex; flex-direction: column; gap: 4px; margin-left: 10px; } 973 + .links a { padding: 2px 0; } 974 + .desc { color: var(--text-dim); font-size: 0.85em; margin-left: 8px; } 975 + .panel { 976 + margin: 8px 0 0 10px; 977 + padding: 8px; 978 + border: 1px solid var(--border); 979 + max-width: 920px; 980 + border-radius: 4px; 981 + background: rgba(0,0,0,0.02); 982 + } 983 + .row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; } 984 + input, button { 985 + font: inherit; 986 + font-size: 0.9em; 987 + border: 1px solid var(--border); 988 + background: transparent; 989 + color: var(--text); 990 + padding: 4px 6px; 991 + border-radius: 3px; 992 + } 993 + input { min-width: 160px; } 994 + button { cursor: pointer; } 995 + button:hover { border-color: var(--accent); color: var(--accent); } 996 + pre { 997 + border: 1px solid var(--border); 998 + padding: 8px; 999 + overflow: auto; 1000 + max-height: 240px; 1001 + white-space: pre-wrap; 1002 + word-break: break-word; 1003 + } 1004 + hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; } 1005 + </style> 1006 + </head> 1007 + <body> 1008 + <h1><a href="/">oven</a> / tools</h1> 1009 + 1010 + <h2>OG Images</h2> 1011 + <div class="links"> 1012 + <div><a href="/kidlisp-og.png">/kidlisp-og.png</a><span class="desc">KidLisp OG image</span></div> 1013 + <div><a href="/kidlisp-og">/kidlisp-og</a><span class="desc">KidLisp OG HTML page</span></div> 1014 + <div><a href="/kidlisp-og/status">/kidlisp-og/status</a><span class="desc">OG cache status</span></div> 1015 + <div><a href="/kidlisp-og/preview">/kidlisp-og/preview</a><span class="desc">OG preview</span></div> 1016 + <div><a href="/og-preview">/og-preview</a><span class="desc">OG preview (alt)</span></div> 1017 + <div><a href="/notepat-og.png">/notepat-og.png</a><span class="desc">Notepat OG image</span></div> 1018 + <div><a href="/kidlisp-backdrop.webp">/kidlisp-backdrop.webp</a><span class="desc">KidLisp backdrop animation</span></div> 1019 + <div><a href="/kidlisp-backdrop">/kidlisp-backdrop</a><span class="desc">KidLisp backdrop page</span></div> 1020 + </div> 1021 + 1022 + <h2>App Screenshots</h2> 1023 + <div class="links"> 1024 + <div><a href="/app-screenshots">/app-screenshots</a><span class="desc">Screenshot dashboard</span></div> 1025 + </div> 1026 + 1027 + <h2>Bundles</h2> 1028 + <div class="links"> 1029 + <div><a href="/bundle-status">/bundle-status</a><span class="desc">Bundle cache status</span></div> 1030 + <div><a href="/bundle-html?piece=prompt">/bundle-html?piece=...</a><span class="desc">Generate HTML bundle (SSE)</span></div> 1031 + </div> 1032 + 1033 + <h2>Grabs</h2> 1034 + <div class="links"> 1035 + <div><a href="/grab-status">/grab-status</a><span class="desc">Active grabs + queue (JSON)</span></div> 1036 + <div><a href="/api/frozen">/api/frozen</a><span class="desc">Frozen pieces list</span></div> 1037 + <div><a href="/keeps/all">/keeps/all</a><span class="desc">All latest IPFS uploads</span></div> 1038 + <div><a href="/keeps/latest">/keeps/latest</a><span class="desc">Latest keep thumbnail</span></div> 1039 + </div> 1040 + 1041 + <h2>Status</h2> 1042 + <div class="links"> 1043 + <div><a href="/health">/health</a><span class="desc">Health check</span></div> 1044 + <div><a href="/status">/status</a><span class="desc">Server status + recent bakes</span></div> 1045 + </div> 1046 + 1047 + <h2>OS Base Builds</h2> 1048 + <div class="links"> 1049 + <div><a href="/os-base-build">/os-base-build</a><span class="desc">Background FedOS base-image jobs</span></div> 1050 + </div> 1051 + <div class="panel"> 1052 + <div class="row"> 1053 + <input id="os-admin-key" type="password" placeholder="admin key (x-oven-os-key)"> 1054 + <select id="os-flavor" style="width:100px"><option value="alpine" selected>Alpine</option><option value="fedora">Fedora</option><option value="native">Native</option></select> 1055 + <input id="os-image-size" type="number" min="1" max="32" value="1" style="width:90px"> 1056 + <button id="os-start-btn" type="button">Start Base Build</button> 1057 + <button id="os-refresh-btn" type="button">Refresh</button> 1058 + </div> 1059 + <div id="os-job-meta" class="desc">Loading base-build status...</div> 1060 + <pre id="os-job-log">No active base-image job</pre> 1061 + </div> 1062 + 1063 + <script> 1064 + const osMetaEl = document.getElementById('os-job-meta'); 1065 + const osLogEl = document.getElementById('os-job-log'); 1066 + const osKeyEl = document.getElementById('os-admin-key'); 1067 + const osSizeEl = document.getElementById('os-image-size'); 1068 + const osFlavorEl = document.getElementById('os-flavor'); 1069 + const osStartBtn = document.getElementById('os-start-btn'); 1070 + const osRefreshBtn = document.getElementById('os-refresh-btn'); 1071 + osFlavorEl.addEventListener('change', function() { 1072 + osSizeEl.value = osFlavorEl.value === 'alpine' ? '1' : '4'; 1073 + }); 1074 + let osPollTimer = null; 1075 + 1076 + function esc(str) { 1077 + return String(str || '').replace(/[&<>"']/g, function (ch) { 1078 + return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]; 1079 + }); 1080 + } 1081 + 1082 + async function fetchJSON(url, opts) { 1083 + const res = await fetch(url, opts || {}); 1084 + const text = await res.text(); 1085 + let data = {}; 1086 + try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } 1087 + if (!res.ok) { 1088 + throw new Error((data && data.error) || ('HTTP ' + res.status)); 1089 + } 1090 + return data; 1091 + } 1092 + 1093 + function jobLabel(job) { 1094 + if (!job) return 'none'; 1095 + const pct = Number.isFinite(job.percent) ? (' ' + job.percent + '%') : ''; 1096 + return job.id + ' | ' + job.status + pct + ' | ' + (job.stage || '--'); 1097 + } 1098 + 1099 + async function refreshOSBaseBuild() { 1100 + try { 1101 + const summary = await fetchJSON('/os-base-build'); 1102 + const active = summary.active || null; 1103 + const recent = Array.isArray(summary.recent) ? summary.recent : []; 1104 + if (active) { 1105 + osMetaEl.innerHTML = 'Active: <strong>' + esc(jobLabel(active)) + '</strong>'; 1106 + const detail = await fetchJSON('/os-base-build/' + encodeURIComponent(active.id) + '?logs=1&tail=100'); 1107 + const lines = Array.isArray(detail.logs) ? detail.logs : []; 1108 + osLogEl.textContent = lines.map(function (l) { 1109 + return '[' + (l.ts || '') + '][' + (l.stream || 'out') + '] ' + (l.line || ''); 1110 + }).join('\n') || 'No logs yet'; 1111 + return; 1112 + } 1113 + 1114 + const latest = recent.length > 0 ? recent[0] : null; 1115 + osMetaEl.innerHTML = 'Active: none' + (latest ? ' | Latest: <strong>' + esc(jobLabel(latest)) + '</strong>' : ''); 1116 + osLogEl.textContent = latest 1117 + ? ((latest.message || '(no message)') + '\n\nUse /os-base-build/' + latest.id + '?logs=1&tail=200 for full logs.') 1118 + : 'No base-image jobs yet'; 1119 + } catch (error) { 1120 + osMetaEl.textContent = 'Status error: ' + error.message; 1121 + } 1122 + } 1123 + 1124 + async function startOSBaseBuild() { 1125 + const key = osKeyEl.value.trim(); 1126 + const flavor = osFlavorEl.value || 'alpine'; 1127 + const defaultSize = flavor === 'alpine' ? 1 : 4; 1128 + const imageSizeGB = Math.max(1, parseInt(osSizeEl.value || String(defaultSize), 10) || defaultSize); 1129 + osStartBtn.disabled = true; 1130 + try { 1131 + const data = await fetchJSON('/os-base-build', { 1132 + method: 'POST', 1133 + headers: { 1134 + 'Content-Type': 'application/json', 1135 + 'x-oven-os-key': key, 1136 + }, 1137 + body: JSON.stringify({ imageSizeGB, publish: true, flavor }), 1138 + }); 1139 + osMetaEl.textContent = 'Started ' + flavor + ' base build job ' + data.id; 1140 + } catch (error) { 1141 + osMetaEl.textContent = 'Start failed: ' + error.message; 1142 + } finally { 1143 + osStartBtn.disabled = false; 1144 + refreshOSBaseBuild(); 1145 + } 1146 + } 1147 + 1148 + osStartBtn.addEventListener('click', startOSBaseBuild); 1149 + osRefreshBtn.addEventListener('click', refreshOSBaseBuild); 1150 + refreshOSBaseBuild(); 1151 + osPollTimer = setInterval(refreshOSBaseBuild, 3000); 1152 + window.addEventListener('beforeunload', function () { 1153 + if (osPollTimer) clearInterval(osPollTimer); 1154 + }); 1155 + </script> 1156 + </body> 1157 + </html>`); 1158 + }); 1159 + 1160 + // API endpoints 1161 + app.get('/health', healthHandler); 1162 + 1163 + // Override status to include grabs 1164 + app.get('/status', async (req, res) => { 1165 + await cleanupStaleBakes(); 1166 + res.json({ 1167 + version: GIT_VERSION, 1168 + serverStartTime: SERVER_START_TIME, 1169 + uptime: Date.now() - SERVER_START_TIME, 1170 + incoming: Array.from(getIncomingBakes().values()), 1171 + active: Array.from(getActiveBakes().values()), 1172 + recent: getRecentBakes(), 1173 + grabs: { 1174 + active: getActiveGrabs(), 1175 + recent: getRecentGrabs(), 1176 + ipfsThumbs: getAllLatestIPFSUploads() 1177 + }, 1178 + osBaseBuilds: getOSBaseBuildsSummary(), 1179 + }); 1180 + }); 1181 + 1182 + app.post('/bake', bakeHandler); 1183 + app.post('/bake-complete', bakeCompleteHandler); 1184 + app.post('/bake-status', bakeStatusHandler); 1185 + 1186 + // Icon endpoint - small square thumbnails (compatible with grab.aesthetic.computer) 1187 + // GET /icon/{width}x{height}/{piece}.png 1188 + // Uses 24h Spaces cache to avoid regenerating on every request 1189 + app.get('/icon/:size/:piece.png', async (req, res) => { 1190 + const { size, piece } = req.params; 1191 + const [width, height] = size.split('x').map(n => parseInt(n) || 128); 1192 + const w = Math.min(width, 512); 1193 + const h = Math.min(height, 512); 1194 + 1195 + try { 1196 + const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('icons', piece, w, h, async () => { 1197 + const result = await grabPiece(piece, { 1198 + format: 'png', 1199 + width: w, 1200 + height: h, 1201 + density: 1, 1202 + }); 1203 + if (!result.success) throw new Error(result.error); 1204 + // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer) 1205 + if (result.cached && result.cdnUrl && !result.buffer) { 1206 + // Fetch the buffer from the CDN URL 1207 + const response = await fetch(result.cdnUrl); 1208 + if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`); 1209 + return Buffer.from(await response.arrayBuffer()); 1210 + } 1211 + return result.buffer; 1212 + }); 1213 + 1214 + if (fromCache && cdnUrl) { 1215 + res.setHeader('X-Cache', 'HIT'); 1216 + res.setHeader('Cache-Control', 'public, max-age=86400'); 1217 + return res.redirect(302, cdnUrl); 1218 + } 1219 + 1220 + res.setHeader('Content-Type', 'image/png'); 1221 + res.setHeader('Content-Length', buffer.length); 1222 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1223 + res.setHeader('X-Cache', 'MISS'); 1224 + res.send(buffer); 1225 + } catch (error) { 1226 + console.error('Icon handler error:', error); 1227 + res.status(500).json({ error: error.message }); 1228 + } 1229 + }); 1230 + 1231 + // Animated WebP Icon endpoint - small animated square favicons 1232 + // GET /icon/{width}x{height}/{piece}.webp 1233 + // Uses 7-day Spaces cache since animated icons are expensive to generate 1234 + app.get('/icon/:size/:piece.webp', async (req, res) => { 1235 + const { size, piece } = req.params; 1236 + const [width, height] = size.split('x').map(n => parseInt(n) || 128); 1237 + // Keep animated icons small for performance (max 128x128) 1238 + const w = Math.min(width, 128); 1239 + const h = Math.min(height, 128); 1240 + 1241 + // Query params for customization 1242 + const frames = Math.min(parseInt(req.query.frames) || 30, 60); // Default 30 frames, max 60 1243 + const fps = Math.min(parseInt(req.query.fps) || 15, 30); // Default 15 fps, max 30 1244 + 1245 + try { 1246 + const cacheKey = `${piece}-${w}x${h}-f${frames}-fps${fps}`; 1247 + const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('animated-icons', cacheKey, w, h, async () => { 1248 + const result = await grabPiece(piece, { 1249 + format: 'webp', 1250 + width: w, 1251 + height: h, 1252 + density: 1, 1253 + frames: frames, 1254 + fps: fps, 1255 + }); 1256 + if (!result.success) throw new Error(result.error); 1257 + // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer) 1258 + if (result.cached && result.cdnUrl && !result.buffer) { 1259 + const response = await fetch(result.cdnUrl); 1260 + if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`); 1261 + return Buffer.from(await response.arrayBuffer()); 1262 + } 1263 + return result.buffer; 1264 + }, 'webp'); 1265 + 1266 + if (fromCache && cdnUrl) { 1267 + res.setHeader('X-Cache', 'HIT'); 1268 + res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days 1269 + return res.redirect(302, cdnUrl); 1270 + } 1271 + 1272 + res.setHeader('Content-Type', 'image/webp'); 1273 + res.setHeader('Content-Length', buffer.length); 1274 + res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day for fresh 1275 + res.setHeader('X-Cache', 'MISS'); 1276 + res.send(buffer); 1277 + } catch (error) { 1278 + console.error('Animated icon handler error:', error); 1279 + res.status(500).json({ error: error.message }); 1280 + } 1281 + }); 1282 + 1283 + // Preview endpoint - larger social media images (compatible with grab.aesthetic.computer) 1284 + // GET /preview/{width}x{height}/{piece}.png 1285 + // Uses 24h Spaces cache to avoid regenerating on every request 1286 + app.get('/preview/:size/:piece.png', async (req, res) => { 1287 + const { size, piece } = req.params; 1288 + const [width, height] = size.split('x').map(n => parseInt(n) || 1200); 1289 + const w = Math.min(width, 1920); 1290 + const h = Math.min(height, 1080); 1291 + 1292 + try { 1293 + const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('previews', piece, w, h, async () => { 1294 + const result = await grabPiece(piece, { 1295 + format: 'png', 1296 + width: w, 1297 + height: h, 1298 + density: 4, 1299 + viewportScale: 1, 1300 + }); 1301 + if (!result.success) throw new Error(result.error); 1302 + // Handle case where grabPiece returns from its own cache (cdnUrl but no buffer) 1303 + if (result.cached && result.cdnUrl && !result.buffer) { 1304 + const response = await fetch(result.cdnUrl); 1305 + if (!response.ok) throw new Error(`Failed to fetch cached preview: ${response.status}`); 1306 + return Buffer.from(await response.arrayBuffer()); 1307 + } 1308 + return result.buffer; 1309 + }); 1310 + 1311 + if (fromCache && cdnUrl) { 1312 + res.setHeader('X-Cache', 'HIT'); 1313 + res.setHeader('Cache-Control', 'public, max-age=86400'); 1314 + return res.redirect(302, cdnUrl); 1315 + } 1316 + 1317 + res.setHeader('Content-Type', 'image/png'); 1318 + res.setHeader('Content-Length', buffer.length); 1319 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1320 + res.setHeader('X-Cache', 'MISS'); 1321 + res.send(buffer); 1322 + } catch (error) { 1323 + console.error('Preview handler error:', error); 1324 + res.status(500).json({ error: error.message }); 1325 + } 1326 + }); 1327 + 1328 + // Product images — static assets for AC hardware/products 1329 + // GET /product/{name}.png — redirects to Spaces CDN: products/{name}.png 1330 + const PRODUCT_CDN = process.env.ART_CDN_BASE || 'https://art.aesthetic.computer'; 1331 + app.get('/product/:name.png', (req, res) => { 1332 + const { name } = req.params; 1333 + if (!/^[a-z0-9-]+$/.test(name)) return res.status(400).json({ error: 'Invalid product name' }); 1334 + res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days 1335 + res.redirect(302, `${PRODUCT_CDN}/products/${name}.png`); 1336 + }); 1337 + 1338 + // Grab endpoint - capture screenshots/GIFs from KidLisp pieces 1339 + app.post('/grab', grabHandler); 1340 + app.get('/grab/:format/:width/:height/:piece', grabGetHandler); 1341 + app.post('/grab-ipfs', grabIPFSHandler); 1342 + 1343 + // Grab status endpoint 1344 + app.get('/grab-status', (req, res) => { 1345 + res.json({ 1346 + active: getActiveGrabs(), 1347 + recent: getRecentGrabs(), 1348 + queue: getQueueStatus(), 1349 + progress: getCurrentProgress(), 1350 + grabProgress: getAllProgress(), 1351 + concurrency: getConcurrencyStatus(), 1352 + osBaseBuilds: getOSBaseBuildsSummary(), 1353 + }); 1354 + }); 1355 + 1356 + // Cleanup stale grabs (grabs stuck for > 5 minutes) 1357 + app.post('/grab-cleanup', (req, res) => { 1358 + const result = cleanupStaleGrabs(); 1359 + addServerLog('cleanup', '🧹', `Manual cleanup: ${result.cleaned} stale grabs removed`); 1360 + res.json({ 1361 + success: true, 1362 + ...result 1363 + }); 1364 + }); 1365 + 1366 + // Emergency clear all active grabs (admin only) 1367 + app.post('/grab-clear', (req, res) => { 1368 + const result = clearAllActiveGrabs(); 1369 + addServerLog('cleanup', 'šŸ—‘ļø', `Emergency clear: ${result.cleared} grabs force-cleared`); 1370 + res.json({ 1371 + success: true, 1372 + ...result 1373 + }); 1374 + }); 1375 + 1376 + // Frozen pieces API - get list of frozen pieces 1377 + app.get('/api/frozen', (req, res) => { 1378 + res.json({ 1379 + frozen: getFrozenPieces() 1380 + }); 1381 + }); 1382 + 1383 + // Clear a piece from the frozen list 1384 + app.delete('/api/frozen/:piece', async (req, res) => { 1385 + const piece = decodeURIComponent(req.params.piece); 1386 + const result = await clearFrozenPiece(piece); 1387 + addServerLog('cleanup', 'āœ…', `Cleared frozen piece: ${piece}`); 1388 + res.json(result); 1389 + }); 1390 + 1391 + // Live collection thumbnail endpoint - redirects to most recent kept WebP 1392 + // Use this as the collection imageUri for a dynamic thumbnail 1393 + app.get('/keeps/latest', async (req, res) => { 1394 + let latest = getLatestKeepThumbnail(); 1395 + if (!latest) { 1396 + latest = await ensureLatestKeepThumbnail(); 1397 + } 1398 + if (!latest) { 1399 + return res.status(404).json({ 1400 + error: 'No keeps have been captured yet', 1401 + hint: 'No minted keep thumbnail found in oven or kidlisp records yet' 1402 + }); 1403 + } 1404 + 1405 + // Redirect to IPFS gateway 1406 + const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`; 1407 + res.redirect(302, gatewayUrl); 1408 + }); 1409 + 1410 + // Get latest thumbnail for a specific piece 1411 + app.get('/keeps/latest/:piece', (req, res) => { 1412 + const latest = getLatestIPFSUpload(req.params.piece); 1413 + if (!latest) { 1414 + return res.status(404).json({ 1415 + error: `No keeps captured for piece: ${req.params.piece}`, 1416 + hint: `Mint ${req.params.piece} with --thumbnail flag to populate this endpoint` 1417 + }); 1418 + } 1419 + 1420 + const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`; 1421 + res.redirect(302, gatewayUrl); 1422 + }); 1423 + 1424 + // Get all latest thumbnails as JSON (for debugging/monitoring) 1425 + app.get('/keeps/all', (req, res) => { 1426 + res.json({ 1427 + latest: getLatestKeepThumbnail(), 1428 + byPiece: getAllLatestIPFSUploads() 1429 + }); 1430 + }); 1431 + 1432 + // ============================================================================= 1433 + // KidLisp.com OG Preview Image Endpoint 1434 + // ============================================================================= 1435 + 1436 + // Fast static PNG endpoint - redirects instantly to CDN (for social media crawlers) 1437 + // Use this URL in og:image and twitter:image meta tags 1438 + app.get('/kidlisp-og.png', async (req, res) => { 1439 + try { 1440 + const layout = req.query.layout || 'mosaic'; 1441 + 1442 + // Get cached URL without triggering generation (fast!) 1443 + const url = await getLatestOGImageUrl(layout); 1444 + 1445 + if (url) { 1446 + // Redirect to CDN - instant response 1447 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1448 + res.setHeader('X-Cache', 'CDN'); 1449 + return res.redirect(301, url); 1450 + } 1451 + 1452 + // No cached image yet - trigger background regeneration and serve a recent fallback 1453 + addServerLog('warn', 'āš ļø', `OG cache miss for ${layout}, triggering regen`); 1454 + 1455 + // Trigger async regeneration (don't await) 1456 + regenerateOGImagesBackground().catch(err => { 1457 + addServerLog('error', 'āŒ', `Async OG regen failed: ${err.message}`); 1458 + }); 1459 + 1460 + // Use yesterday's image as fallback (likely exists) 1461 + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; 1462 + const fallbackUrl = `https://art.aesthetic.computer/og/kidlisp/${yesterday}-${layout}.png`; 1463 + 1464 + res.setHeader('Cache-Control', 'public, max-age=300'); // Short cache for fallback 1465 + return res.redirect(302, fallbackUrl); 1466 + 1467 + } catch (error) { 1468 + console.error('KidLisp OG PNG error:', error); 1469 + // Ultimate fallback - yesterday's mosaic 1470 + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; 1471 + return res.redirect(302, `https://art.aesthetic.computer/og/kidlisp/${yesterday}-mosaic.png`); 1472 + } 1473 + }); 1474 + 1475 + // ─── TzKT dapp images ─────────────────────────────────────────────────────── 1476 + app.get('/kidlisp-og/tzkt-cover.jpg', async (req, res) => { 1477 + try { 1478 + addServerLog('info', 'šŸ–¼ļø', 'TzKT cover (640x360)'); 1479 + const result = await generateKidlispOGImage('mosaic', true, { noDotCom: true }); 1480 + const jpg = await sharp(result.buffer).resize(640, 360, { fit: 'cover' }).jpeg({ quality: 90 }).toBuffer(); 1481 + res.setHeader('Content-Type', 'image/jpeg'); 1482 + res.setHeader('Content-Length', jpg.length); 1483 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1484 + res.send(jpg); 1485 + } catch (error) { 1486 + console.error('TzKT cover error:', error); 1487 + res.status(500).json({ error: error.message }); 1488 + } 1489 + }); 1490 + 1491 + app.get('/kidlisp-og/tzkt-logo.jpg', async (req, res) => { 1492 + try { 1493 + addServerLog('info', 'šŸ–¼ļø', 'TzKT logo (200x200)'); 1494 + // Render $ in Comic Relief via Puppeteer (lightweight, ~3s) 1495 + const puppeteer = await import('puppeteer'); 1496 + const browser = await puppeteer.default.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] }); 1497 + const page = await browser.newPage(); 1498 + try { 1499 + await page.setViewport({ width: 200, height: 200, deviceScaleFactor: 2 }); 1500 + await page.setContent(`<!DOCTYPE html><html><head> 1501 + <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:wght@700&display=swap" rel="stylesheet"> 1502 + <style> 1503 + *{margin:0;padding:0} 1504 + body{width:200px;height:200px;display:flex;align-items:center;justify-content:center;background:#9370DB} 1505 + .d{font-family:'Comic Relief',cursive;font-size:170px;font-weight:700;color:limegreen;text-shadow:6px 6px 0 rgba(0,0,0,0.5);margin-top:-10px} 1506 + </style></head> 1507 + <body><div class="d">$</div></body></html>`, { waitUntil: 'networkidle0' }); 1508 + await page.evaluate(() => document.fonts.ready); 1509 + await new Promise(r => setTimeout(r, 300)); 1510 + const png = await page.screenshot({ type: 'png' }); 1511 + const jpg = await sharp(png).resize(200, 200).jpeg({ quality: 90 }).toBuffer(); 1512 + res.setHeader('Content-Type', 'image/jpeg'); 1513 + res.setHeader('Content-Length', jpg.length); 1514 + res.setHeader('Cache-Control', 'public, max-age=86400'); 1515 + res.send(jpg); 1516 + } finally { await page.close(); await browser.close(); } 1517 + } catch (error) { 1518 + console.error('TzKT logo error:', error); 1519 + res.status(500).json({ error: error.message }); 1520 + } 1521 + }); 1522 + 1523 + // ─── Site-specific OG images ───────────────────────────────────────────────── 1524 + app.get('/kidlisp-og/site/:site.png', async (req, res) => { 1525 + const site = req.params.site; 1526 + if (!['keeps', 'buy'].includes(site)) return res.status(400).json({ error: 'Invalid site', valid: ['keeps', 'buy'] }); 1527 + try { 1528 + addServerLog('info', 'šŸ–¼ļø', `Site OG: ${site}.kidlisp.com`); 1529 + const result = await generateKidlispOGImage('mosaic', true); 1530 + const bg = await sharp(result.buffer).blur(6).toBuffer(); 1531 + const darkOverlay = Buffer.from(`<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="rgba(0,0,0,0.4)"/></svg>`); 1532 + const prefixLetters = site === 'keeps' 1533 + ? 'keeps'.split('').map(c => `<tspan fill="#00ff41">${c}</tspan>`).join('') 1534 + : [['b','#FF6B6B'],['u','#4ECDC4'],['y','#FFE66D']].map(([c,col]) => `<tspan fill="${col}">${c}</tspan>`).join(''); 1535 + const kidlispLetters = [['K','#FF6B6B'],['i','#4ECDC4'],['d','#FFE66D'],['L','#A8E6CF'],['i','#FF8B94'],['s','#F7DC6F'],['p','#BB8FCE']].map(([c,col]) => `<tspan fill="${col}">${c}</tspan>`).join(''); 1536 + const tspans = `${prefixLetters}<tspan fill="#70D6FF">.</tspan>${kidlispLetters}<tspan fill="#70D6FF">.com</tspan>`; 1537 + const brandingSvg = Buffer.from(`<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg"><text x="600" y="340" font-family="Comic Sans MS, cursive" font-size="90" font-weight="700" text-anchor="middle" style="paint-order: stroke; stroke: black; stroke-width: 6px;">${tspans}</text></svg>`); 1538 + const composited = await sharp(bg).composite([{ input: darkOverlay }, { input: brandingSvg }]).png().toBuffer(); 1539 + res.setHeader('Content-Type', 'image/png'); 1540 + res.setHeader('Content-Length', composited.length); 1541 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1542 + res.send(composited); 1543 + } catch (error) { 1544 + console.error(`Site OG error (${site}):`, error); 1545 + res.status(500).json({ error: error.message }); 1546 + } 1547 + }); 1548 + 1549 + // Dynamic OG image for kidlisp.com - rotates daily based on top hits 1550 + // Supports multiple layout options: featured, mosaic, filmstrip, code-split 1551 + app.get('/kidlisp-og', async (req, res) => { 1552 + try { 1553 + const layout = req.query.layout || 'featured'; 1554 + const force = req.query.force === 'true'; 1555 + 1556 + // Validate layout 1557 + const validLayouts = ['featured', 'mosaic', 'filmstrip', 'code-split']; 1558 + if (!validLayouts.includes(layout)) { 1559 + return res.status(400).json({ 1560 + error: 'Invalid layout', 1561 + valid: validLayouts, 1562 + }); 1563 + } 1564 + 1565 + addServerLog('info', 'šŸ–¼ļø', `KidLisp OG request: ${layout}${force ? ' (force)' : ''}`); 1566 + 1567 + const result = await generateKidlispOGImage(layout, force); 1568 + 1569 + if (result.cached && result.url) { 1570 + // Redirect to CDN URL for cached images 1571 + addServerLog('success', 'šŸ“¦', `OG cache hit → ${result.url.split('/').pop()}`); 1572 + res.setHeader('X-Cache', 'HIT'); 1573 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1574 + return res.redirect(302, result.url); 1575 + } 1576 + 1577 + // Fresh generation - return the buffer directly 1578 + addServerLog('success', 'šŸŽØ', `OG generated: ${layout} (${result.featuredPiece?.code || 'mosaic'})`); 1579 + res.setHeader('Content-Type', 'image/png'); 1580 + res.setHeader('Content-Length', result.buffer.length); 1581 + res.setHeader('Cache-Control', 'public, max-age=86400'); // 24hr cache 1582 + res.setHeader('X-Cache', 'MISS'); 1583 + res.setHeader('X-OG-Layout', layout); 1584 + res.setHeader('X-OG-Generated', result.generatedAt); 1585 + if (result.featuredPiece) { 1586 + res.setHeader('X-OG-Featured', result.featuredPiece.code); 1587 + } 1588 + res.send(result.buffer); 1589 + 1590 + } catch (error) { 1591 + console.error('KidLisp OG error:', error); 1592 + addServerLog('error', 'āŒ', `OG error: ${error.message}`); 1593 + res.status(500).json({ 1594 + error: 'Failed to generate OG image', 1595 + message: error.message 1596 + }); 1597 + } 1598 + }); 1599 + 1600 + // OG image cache status endpoint 1601 + app.get('/kidlisp-og/status', (req, res) => { 1602 + res.json({ 1603 + ...getOGImageCacheStatus(), 1604 + availableLayouts: ['featured', 'mosaic', 'filmstrip', 'code-split'], 1605 + usage: { 1606 + recommended: '/kidlisp-og.png (instant, for og:image tags)', 1607 + withLayout: '/kidlisp-og.png?layout=mosaic', 1608 + dynamic: '/kidlisp-og (may regenerate on-demand)', 1609 + forceRegenerate: '/kidlisp-og?force=true', 1610 + }, 1611 + note: 'Use /kidlisp-og.png for social media meta tags - it redirects instantly to cached CDN images' 1612 + }); 1613 + }); 1614 + 1615 + // Preview all OG images (generalized for kidlisp, notepat, etc) 1616 + app.get('/og-preview', (req, res) => { 1617 + const baseUrl = req.protocol + '://' + req.get('host'); 1618 + 1619 + const ogImages = [ 1620 + { 1621 + name: 'KidLisp', 1622 + slug: 'kidlisp-og', 1623 + prodUrls: [ 1624 + 'https://kidlisp.com', 1625 + 'https://aesthetic.computer/kidlisp' 1626 + ], 1627 + layouts: ['featured', 'mosaic', 'filmstrip', 'code-split'], 1628 + description: 'Dynamic layouts featuring recent KidLisp pieces' 1629 + }, 1630 + { 1631 + name: 'Notepat', 1632 + slug: 'notepat-og', 1633 + prodUrls: [ 1634 + 'https://notepat.com', 1635 + 'https://aesthetic.computer/notepat' 1636 + ], 1637 + layouts: null, // Single layout 1638 + description: 'Split-layout chromatic piano interface' 1639 + } 1640 + ]; 1641 + 1642 + res.setHeader('Content-Type', 'text/html'); 1643 + res.send(`<!DOCTYPE html> 1644 + <html> 1645 + <head> 1646 + <title>OG Image Preview</title> 1647 + <style> 1648 + body { font-family: monospace; background: #1a1a2e; color: white; padding: 20px; max-width: 1400px; margin: 0 auto; } 1649 + h1 { color: #88ff88; } 1650 + h2 { color: #ffaa00; margin-top: 40px; } 1651 + h3 { color: #ff88aa; margin-top: 20px; } 1652 + .note { background: #2a2a4e; padding: 16px; border-radius: 8px; margin: 20px 0; line-height: 1.6; } 1653 + .note code { background: #3a3a5e; padding: 2px 6px; border-radius: 4px; color: #88ffaa; } 1654 + .og-section { border: 2px solid #333; padding: 20px; border-radius: 8px; margin: 30px 0; background: #16162e; } 1655 + .prod-urls { margin: 15px 0; } 1656 + .prod-urls a { 1657 + display: inline-block; 1658 + color: #88ccff; 1659 + text-decoration: none; 1660 + background: #2a2a4e; 1661 + padding: 6px 12px; 1662 + border-radius: 4px; 1663 + margin: 4px 4px 4px 0; 1664 + } 1665 + .prod-urls a:hover { background: #3a3a5e; } 1666 + .layout { margin: 20px 0; padding: 15px; background: #0a0a1e; border-radius: 6px; } 1667 + .layout h4 { color: #ffcc66; margin: 0 0 10px 0; } 1668 + .layout img { max-width: 100%; border: 2px solid #444; border-radius: 4px; } 1669 + .layout .actions { margin: 10px 0; } 1670 + .layout .actions a { 1671 + color: #88ccff; 1672 + margin-right: 15px; 1673 + text-decoration: none; 1674 + } 1675 + .layout .actions a:hover { text-decoration: underline; } 1676 + .single-image { margin: 20px 0; } 1677 + .single-image img { max-width: 100%; border: 2px solid #444; border-radius: 4px; } 1678 + .back-link { display: inline-block; margin-top: 40px; color: #888; text-decoration: none; } 1679 + .back-link:hover { color: #aaa; } 1680 + </style> 1681 + </head> 1682 + <body> 1683 + <h1>šŸ–¼ļø OG Image Preview Dashboard</h1> 1684 + <div class="note"> 1685 + <strong>About:</strong> This page shows all Open Graph (OG) images used for social media previews.<br> 1686 + <strong>Usage:</strong> Use the <code>.png</code> endpoints in meta tags for instant CDN redirects (no timeouts).<br> 1687 + <strong>Testing:</strong> Click production URLs below to verify OG tags are working correctly. 1688 + </div> 1689 + 1690 + ${ogImages.map(og => ` 1691 + <div class="og-section"> 1692 + <h2>${og.name}</h2> 1693 + <p style="color: #aaa; margin: 10px 0;">${og.description}</p> 1694 + 1695 + <div class="prod-urls"> 1696 + <strong style="color: #88ff88;">Production URLs:</strong><br> 1697 + ${og.prodUrls.map(url => `<a href="${url}" target="_blank">${url} →</a>`).join(' ')} 1698 + </div> 1699 + 1700 + <div class="note"> 1701 + <strong>OG Endpoint:</strong> <code>${baseUrl}/${og.slug}.png</code> 1702 + </div> 1703 + 1704 + ${og.layouts ? ` 1705 + <h3>Layouts:</h3> 1706 + ${og.layouts.map(layout => ` 1707 + <div class="layout"> 1708 + <h4>${layout.charAt(0).toUpperCase() + layout.slice(1)}</h4> 1709 + <div class="actions"> 1710 + <a href="${baseUrl}/${og.slug}?layout=${layout}&force=true">Force Regenerate</a> 1711 + <a href="${baseUrl}/${og.slug}/status">Cache Status</a> 1712 + </div> 1713 + <img src="${baseUrl}/${og.slug}?layout=${layout}" alt="${layout} layout" loading="lazy"> 1714 + </div> 1715 + `).join('')} 1716 + ` : ` 1717 + <div class="single-image"> 1718 + <div class="actions"> 1719 + <a href="${baseUrl}/${og.slug}.png?force=true">Force Regenerate</a> 1720 + </div> 1721 + <img src="${baseUrl}/${og.slug}.png" alt="${og.name} OG image" loading="lazy"> 1722 + </div> 1723 + `} 1724 + </div> 1725 + `).join('')} 1726 + 1727 + <a href="/" class="back-link">← Back to Oven Dashboard</a> 1728 + </body> 1729 + </html>`); 1730 + }); 1731 + 1732 + // Legacy redirect for old kidlisp preview URL 1733 + app.get('/kidlisp-og/preview', (req, res) => { 1734 + res.redirect(302, '/og-preview'); 1735 + }); 1736 + 1737 + // Notepat branded OG image for notepat.com 1738 + app.get('/notepat-og.png', async (req, res) => { 1739 + try { 1740 + const force = req.query.force === 'true'; 1741 + 1742 + addServerLog('info', 'šŸŽ¹', `Notepat OG request${force ? ' (force)' : ''}`); 1743 + 1744 + const result = await generateNotepatOGImage(force); 1745 + 1746 + if (result.cached && result.url) { 1747 + // Proxy the image back instead of redirecting (iOS crawlers won't follow 301s on og:image) 1748 + addServerLog('success', 'šŸ“¦', `Notepat OG cache hit → proxying`); 1749 + try { 1750 + const cdnResponse = await fetch(result.url); 1751 + if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`); 1752 + const buffer = Buffer.from(await cdnResponse.arrayBuffer()); 1753 + res.setHeader('Content-Type', 'image/png'); 1754 + res.setHeader('Content-Length', buffer.length); 1755 + res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache 1756 + res.setHeader('X-Cache', 'HIT'); 1757 + return res.send(buffer); 1758 + } catch (fetchErr) { 1759 + // Fall back to redirect if proxy fails 1760 + addServerLog('warn', 'āš ļø', `Notepat OG proxy failed, falling back to redirect: ${fetchErr.message}`); 1761 + return res.redirect(301, result.url); 1762 + } 1763 + } 1764 + 1765 + // Fresh generation - return the buffer directly 1766 + addServerLog('success', 'šŸŽØ', `Notepat OG generated`); 1767 + res.setHeader('Content-Type', 'image/png'); 1768 + res.setHeader('Content-Length', result.buffer.length); 1769 + res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache 1770 + res.setHeader('X-Cache', 'MISS'); 1771 + res.send(result.buffer); 1772 + 1773 + } catch (error) { 1774 + console.error('Notepat OG error:', error); 1775 + addServerLog('error', 'āŒ', `Notepat OG error: ${error.message}`); 1776 + res.status(500).json({ 1777 + error: 'Failed to generate Notepat OG image', 1778 + message: error.message 1779 + }); 1780 + } 1781 + }); 1782 + 1783 + // ============================================================================= 1784 + // KidLisp Backdrop - Animated WebP for login screens, Auth0, etc. 1785 + // ============================================================================= 1786 + 1787 + // Fast redirect to CDN-cached 2048px animated webp 1788 + app.get('/kidlisp-backdrop.webp', async (req, res) => { 1789 + try { 1790 + // Get cached URL without triggering generation (fast!) 1791 + const url = await getLatestBackdropUrl(); 1792 + 1793 + if (url) { 1794 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1795 + res.setHeader('X-Cache', 'CDN'); 1796 + return res.redirect(301, url); 1797 + } 1798 + 1799 + // No cached backdrop - generate synchronously (first request will be slow) 1800 + addServerLog('warn', 'āš ļø', 'Backdrop cache miss, generating...'); 1801 + 1802 + const result = await generateKidlispBackdrop(false); 1803 + if (result.url) { 1804 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1805 + res.setHeader('X-Cache', 'MISS'); 1806 + return res.redirect(302, result.url); 1807 + } 1808 + 1809 + res.status(503).json({ error: 'Backdrop generation in progress, try again shortly' }); 1810 + 1811 + } catch (error) { 1812 + console.error('Backdrop error:', error); 1813 + res.status(500).json({ error: 'Failed to get backdrop', message: error.message }); 1814 + } 1815 + }); 1816 + 1817 + // Dynamic backdrop generation (may regenerate on-demand) 1818 + app.get('/kidlisp-backdrop', async (req, res) => { 1819 + try { 1820 + const force = req.query.force === 'true'; 1821 + 1822 + addServerLog('info', 'šŸ–¼ļø', `Backdrop request${force ? ' (force)' : ''}`); 1823 + 1824 + const result = await generateKidlispBackdrop(force); 1825 + 1826 + if (result.url) { 1827 + addServerLog('success', 'šŸŽØ', `Backdrop: ${result.piece} → ${result.cached ? 'cached' : 'generated'}`); 1828 + res.setHeader('Cache-Control', 'public, max-age=3600'); 1829 + res.setHeader('X-Cache', result.cached ? 'HIT' : 'MISS'); 1830 + res.setHeader('X-Backdrop-Piece', result.piece || 'unknown'); 1831 + return res.redirect(302, result.url); 1832 + } 1833 + 1834 + res.status(500).json({ error: 'Failed to generate backdrop' }); 1835 + 1836 + } catch (error) { 1837 + console.error('Backdrop error:', error); 1838 + addServerLog('error', 'āŒ', `Backdrop error: ${error.message}`); 1839 + res.status(500).json({ error: 'Failed to generate backdrop', message: error.message }); 1840 + } 1841 + }); 1842 + 1843 + // ============================================================================= 1844 + // App Store Screenshots - Generate screenshots for Google Play / App Store 1845 + // ============================================================================= 1846 + 1847 + // App screenshots dashboard 1848 + app.get('/app-screenshots', (req, res) => { 1849 + const piece = req.query.piece || 'prompt'; 1850 + const presets = Object.entries(APP_SCREENSHOT_PRESETS); 1851 + 1852 + res.setHeader('Content-Type', 'text/html'); 1853 + res.send(`<!DOCTYPE html> 1854 + <html> 1855 + <head> 1856 + <meta charset="utf-8"> 1857 + <title>šŸ“± App Store Screenshots - Oven</title> 1858 + <meta name="viewport" content="width=device-width, initial-scale=1"> 1859 + <style> 1860 + * { box-sizing: border-box; margin: 0; padding: 0; } 1861 + body { 1862 + font-family: monospace; 1863 + font-size: 14px; 1864 + background: #0a0a12; 1865 + color: #fff; 1866 + min-height: 100vh; 1867 + padding: 20px; 1868 + } 1869 + header { 1870 + display: flex; 1871 + align-items: center; 1872 + justify-content: space-between; 1873 + flex-wrap: wrap; 1874 + gap: 1em; 1875 + padding-bottom: 20px; 1876 + border-bottom: 2px solid #333; 1877 + margin-bottom: 20px; 1878 + } 1879 + h1 { color: #88ff88; font-size: 1.5em; } 1880 + .controls { 1881 + display: flex; 1882 + gap: 1em; 1883 + align-items: center; 1884 + flex-wrap: wrap; 1885 + } 1886 + input, select, button { 1887 + font-family: monospace; 1888 + font-size: 14px; 1889 + padding: 8px 12px; 1890 + border: 1px solid #444; 1891 + background: #1a1a2e; 1892 + color: #fff; 1893 + border-radius: 4px; 1894 + } 1895 + button { 1896 + cursor: pointer; 1897 + background: #2a2a4e; 1898 + } 1899 + button:hover { background: #3a3a5e; border-color: #88ff88; } 1900 + button:disabled { opacity: 0.5; cursor: not-allowed; } 1901 + .btn-primary { background: #226622; border-color: #88ff88; } 1902 + .btn-primary:hover { background: #338833; } 1903 + 1904 + .requirements { 1905 + background: #1a1a2e; 1906 + padding: 15px; 1907 + border-radius: 8px; 1908 + margin-bottom: 20px; 1909 + border: 1px solid #333; 1910 + } 1911 + .requirements h3 { color: #ffaa00; margin-bottom: 10px; } 1912 + .requirements ul { list-style: none; } 1913 + .requirements li { margin: 5px 0; padding-left: 20px; position: relative; } 1914 + .requirements li::before { content: 'āœ“'; position: absolute; left: 0; color: #88ff88; } 1915 + 1916 + .category { margin-bottom: 30px; } 1917 + .category h2 { 1918 + color: #ffaa00; 1919 + margin-bottom: 15px; 1920 + padding-bottom: 10px; 1921 + border-bottom: 1px solid #333; 1922 + } 1923 + .screenshots { 1924 + display: grid; 1925 + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 1926 + gap: 20px; 1927 + } 1928 + .screenshot { 1929 + background: #1a1a2e; 1930 + border: 1px solid #333; 1931 + border-radius: 8px; 1932 + overflow: hidden; 1933 + } 1934 + .screenshot:hover { border-color: #88ff88; } 1935 + .screenshot-preview { 1936 + background: #000; 1937 + display: flex; 1938 + align-items: center; 1939 + justify-content: center; 1940 + min-height: 200px; 1941 + position: relative; 1942 + } 1943 + .screenshot-preview img { 1944 + max-width: 100%; 1945 + max-height: 300px; 1946 + object-fit: contain; 1947 + } 1948 + .screenshot-preview .loading { 1949 + position: absolute; 1950 + color: #888; 1951 + text-align: center; 1952 + padding: 10px; 1953 + display: flex; 1954 + flex-direction: column; 1955 + align-items: center; 1956 + justify-content: center; 1957 + } 1958 + .screenshot-preview .loading .preview-img { 1959 + width: 80px; 1960 + height: 80px; 1961 + image-rendering: pixelated; 1962 + border: 1px solid #333; 1963 + margin-bottom: 8px; 1964 + display: none; 1965 + } 1966 + .screenshot-preview .loading .progress-text { 1967 + font-size: 11px; 1968 + margin-top: 8px; 1969 + color: #88ff88; 1970 + font-family: monospace; 1971 + max-width: 150px; 1972 + word-break: break-word; 1973 + } 1974 + .screenshot-preview .loading .progress-bar { 1975 + width: 80%; 1976 + max-width: 150px; 1977 + height: 4px; 1978 + background: #333; 1979 + border-radius: 2px; 1980 + margin: 8px auto 0; 1981 + overflow: hidden; 1982 + } 1983 + .screenshot-preview .loading .progress-bar-fill { 1984 + height: 100%; 1985 + background: #88ff88; 1986 + width: 0%; 1987 + transition: width 0.3s ease; 1988 + } 1989 + .screenshot-preview .error { 1990 + color: #ff4444; 1991 + padding: 20px; 1992 + text-align: center; 1993 + } 1994 + .screenshot-info { 1995 + padding: 15px; 1996 + } 1997 + .screenshot-info h4 { margin-bottom: 8px; } 1998 + .screenshot-info .dims { 1999 + color: #888; 2000 + font-size: 12px; 2001 + margin-bottom: 10px; 2002 + } 2003 + .screenshot-info .actions { 2004 + display: flex; 2005 + gap: 8px; 2006 + flex-wrap: wrap; 2007 + } 2008 + .screenshot-info .actions a, .screenshot-info .actions button { 2009 + font-size: 12px; 2010 + padding: 6px 10px; 2011 + text-decoration: none; 2012 + color: #88ccff; 2013 + } 2014 + .status { 2015 + position: fixed; 2016 + bottom: 20px; 2017 + right: 20px; 2018 + background: #2a2a4e; 2019 + padding: 15px 20px; 2020 + border-radius: 8px; 2021 + border: 1px solid #444; 2022 + display: none; 2023 + } 2024 + .status.show { display: block; } 2025 + .status.success { border-color: #88ff88; } 2026 + .status.error { border-color: #ff4444; } 2027 + 2028 + .back-link { 2029 + color: #88ccff; 2030 + text-decoration: none; 2031 + margin-bottom: 20px; 2032 + display: inline-block; 2033 + } 2034 + .back-link:hover { text-decoration: underline; } 2035 + </style> 2036 + </head> 2037 + <body> 2038 + <a href="/" class="back-link">← Back to Oven Dashboard</a> 2039 + 2040 + <header> 2041 + <h1>šŸ“± App Store Screenshots</h1> 2042 + <div class="controls"> 2043 + <label>Piece: <input type="text" id="piece-input" value="${piece}" placeholder="prompt"></label> 2044 + <button onclick="changePiece()">Load</button> 2045 + <button onclick="regenerateAll()" class="btn-primary">šŸ”„ Regenerate All</button> 2046 + <button onclick="downloadZip()" class="btn-primary">šŸ“¦ Download ZIP</button> 2047 + </div> 2048 + </header> 2049 + 2050 + <div class="requirements"> 2051 + <h3>šŸ“‹ Google Play Requirements</h3> 2052 + <ul> 2053 + <li>PNG or JPEG, max 8MB each</li> 2054 + <li>16:9 or 9:16 aspect ratio</li> 2055 + <li>Phone: 320-3840px per side, 1080px min for promotion</li> 2056 + <li>7" Tablet: 320-3840px per side</li> 2057 + <li>10" Tablet: 1080-7680px per side</li> 2058 + <li>2-8 screenshots per category required</li> 2059 + </ul> 2060 + </div> 2061 + 2062 + <div class="category"> 2063 + <h2>šŸ“± Phone Screenshots</h2> 2064 + <div class="screenshots"> 2065 + ${presets.filter(([k, v]) => v.category === 'phone').map(([key, preset]) => ` 2066 + <div class="screenshot" data-preset="${key}"> 2067 + <div class="screenshot-preview"> 2068 + <span class="loading" data-loading="${key}"> 2069 + <img class="preview-img" alt="preview"> 2070 + <span class="loading-text">šŸ”„ Loading...</span> 2071 + <div class="progress-text" data-progress-text="${key}"></div> 2072 + <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div> 2073 + </span> 2074 + <img src="/app-screenshots/${key}/${piece}.png" 2075 + alt="${preset.label}" 2076 + data-img="${key}" 2077 + onload="this.previousElementSibling.style.display='none'" 2078 + onerror="this.style.display='none'; this.previousElementSibling.innerHTML='āŒ Failed to load'"> 2079 + </div> 2080 + <div class="screenshot-info"> 2081 + <h4>${preset.label}</h4> 2082 + <div class="dims">${preset.width} Ɨ ${preset.height}px</div> 2083 + <div class="actions"> 2084 + <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">ā¬‡ļø Download</a> 2085 + <button onclick="regenerate('${key}')">šŸ”„ Regenerate</button> 2086 + </div> 2087 + </div> 2088 + </div> 2089 + `).join('')} 2090 + </div> 2091 + </div> 2092 + 2093 + <div class="category"> 2094 + <h2>šŸ“± 7-inch Tablet Screenshots</h2> 2095 + <div class="screenshots"> 2096 + ${presets.filter(([k, v]) => v.category === 'tablet7').map(([key, preset]) => ` 2097 + <div class="screenshot" data-preset="${key}"> 2098 + <div class="screenshot-preview"> 2099 + <span class="loading" data-loading="${key}"> 2100 + <img class="preview-img" alt="preview"> 2101 + <span class="loading-text">šŸ”„ Loading...</span> 2102 + <div class="progress-text" data-progress-text="${key}"></div> 2103 + <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div> 2104 + </span> 2105 + <img src="/app-screenshots/${key}/${piece}.png" 2106 + alt="${preset.label}" 2107 + data-img="${key}" 2108 + onload="this.previousElementSibling.style.display='none'" 2109 + onerror="this.style.display='none'; this.previousElementSibling.innerHTML='āŒ Failed to load'"> 2110 + </div> 2111 + <div class="screenshot-info"> 2112 + <h4>${preset.label}</h4> 2113 + <div class="dims">${preset.width} Ɨ ${preset.height}px</div> 2114 + <div class="actions"> 2115 + <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">ā¬‡ļø Download</a> 2116 + <button onclick="regenerate('${key}')">šŸ”„ Regenerate</button> 2117 + </div> 2118 + </div> 2119 + </div> 2120 + `).join('')} 2121 + </div> 2122 + </div> 2123 + 2124 + <div class="category"> 2125 + <h2>šŸ“± 10-inch Tablet Screenshots</h2> 2126 + <div class="screenshots"> 2127 + ${presets.filter(([k, v]) => v.category === 'tablet10').map(([key, preset]) => ` 2128 + <div class="screenshot" data-preset="${key}"> 2129 + <div class="screenshot-preview"> 2130 + <span class="loading" data-loading="${key}"> 2131 + <img class="preview-img" alt="preview"> 2132 + <span class="loading-text">šŸ”„ Loading...</span> 2133 + <div class="progress-text" data-progress-text="${key}"></div> 2134 + <div class="progress-bar"><div class="progress-bar-fill" data-progress-bar="${key}"></div></div> 2135 + </span> 2136 + <img src="/app-screenshots/${key}/${piece}.png" 2137 + alt="${preset.label}" 2138 + data-img="${key}" 2139 + onload="this.previousElementSibling.style.display='none'" 2140 + onerror="this.style.display='none'; this.previousElementSibling.innerHTML='āŒ Failed to load'"> 2141 + </div> 2142 + <div class="screenshot-info"> 2143 + <h4>${preset.label}</h4> 2144 + <div class="dims">${preset.width} Ɨ ${preset.height}px</div> 2145 + <div class="actions"> 2146 + <a href="/app-screenshots/${key}/${piece}.png" download="${piece}-${key}.png">ā¬‡ļø Download</a> 2147 + <button onclick="regenerate('${key}')">šŸ”„ Regenerate</button> 2148 + </div> 2149 + </div> 2150 + </div> 2151 + `).join('')} 2152 + </div> 2153 + </div> 2154 + 2155 + <div id="status" class="status"></div> 2156 + 2157 + <script> 2158 + const currentPiece = '${piece}'; 2159 + 2160 + function showStatus(msg, type = 'info') { 2161 + const el = document.getElementById('status'); 2162 + el.textContent = msg; 2163 + el.className = 'status show ' + type; 2164 + setTimeout(() => el.className = 'status', 3000); 2165 + } 2166 + 2167 + function changePiece() { 2168 + const piece = document.getElementById('piece-input').value.trim() || 'prompt'; 2169 + window.location.href = '/app-screenshots?piece=' + encodeURIComponent(piece); 2170 + } 2171 + 2172 + document.getElementById('piece-input').addEventListener('keydown', (e) => { 2173 + if (e.key === 'Enter') changePiece(); 2174 + }); 2175 + 2176 + async function regenerate(preset) { 2177 + showStatus('Regenerating ' + preset + '... (this takes ~30s)'); 2178 + console.log('šŸ”„ Starting regeneration for:', preset); 2179 + 2180 + // Show loading indicator and hide current image 2181 + const card = document.querySelector('[data-preset="' + preset + '"]'); 2182 + const img = card.querySelector('[data-img]'); 2183 + const loading = card.querySelector('.loading'); 2184 + 2185 + img.style.display = 'none'; 2186 + loading.style.display = 'flex'; 2187 + loading.innerHTML = '<img class="preview-img" alt="preview"><span class="loading-text">šŸ”„ Regenerating...</span><div class="progress-text"></div><div class="progress-bar"><div class="progress-bar-fill" style="width:0%"></div></div>'; 2188 + 2189 + const startTime = Date.now(); 2190 + 2191 + try { 2192 + const controller = new AbortController(); 2193 + const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 min timeout 2194 + 2195 + console.log('šŸ“” Fetching with force=true...'); 2196 + const res = await fetch('/app-screenshots/' + preset + '/' + currentPiece + '.png?force=true&t=' + Date.now(), { 2197 + signal: controller.signal, 2198 + cache: 'no-store', 2199 + headers: { 'Cache-Control': 'no-cache' } 2200 + }); 2201 + clearTimeout(timeoutId); 2202 + 2203 + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); 2204 + console.log('šŸ“” Response received after ' + elapsed + 's, status:', res.status); 2205 + 2206 + if (res.ok) { 2207 + // Force reload the image with cache-busting 2208 + const newSrc = '/app-screenshots/' + preset + '/' + currentPiece + '.png?t=' + Date.now(); 2209 + console.log('šŸ–¼ļø Setting new image src:', newSrc); 2210 + img.src = newSrc; 2211 + img.style.display = 'block'; 2212 + loading.style.display = 'none'; 2213 + showStatus('āœ… ' + preset + ' regenerated in ' + elapsed + 's!', 'success'); 2214 + } else { 2215 + const error = await res.text(); 2216 + console.error('āŒ Regeneration failed:', res.status, error); 2217 + loading.innerHTML = 'āŒ Failed: ' + (error || res.status); 2218 + showStatus('āŒ Failed to regenerate: ' + res.status, 'error'); 2219 + } 2220 + } catch (err) { 2221 + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); 2222 + console.error('āŒ Regeneration error after ' + elapsed + 's:', err); 2223 + if (err.name === 'AbortError') { 2224 + loading.innerHTML = 'ā±ļø Timeout - still processing?'; 2225 + showStatus('ā±ļø Request timed out - try refreshing', 'error'); 2226 + } else { 2227 + loading.innerHTML = 'āŒ ' + err.message; 2228 + showStatus('āŒ ' + err.message, 'error'); 2229 + } 2230 + } 2231 + } 2232 + 2233 + async function regenerateAll() { 2234 + const presets = ${JSON.stringify(Object.keys(APP_SCREENSHOT_PRESETS))}; 2235 + showStatus('Regenerating all screenshots...'); 2236 + 2237 + for (const preset of presets) { 2238 + showStatus('Regenerating ' + preset + '...'); 2239 + try { 2240 + await fetch('/app-screenshots/' + preset + '/' + currentPiece + '.png?force=true'); 2241 + } catch (err) { 2242 + console.error('Failed:', preset, err); 2243 + } 2244 + } 2245 + 2246 + showStatus('āœ… All screenshots regenerated! Reloading...', 'success'); 2247 + setTimeout(() => window.location.reload(), 1000); 2248 + } 2249 + 2250 + function downloadZip() { 2251 + showStatus('Preparing ZIP download...'); 2252 + window.location.href = '/app-screenshots/download/' + currentPiece; 2253 + } 2254 + 2255 + // WebSocket for real-time progress updates 2256 + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; 2257 + let ws = null; 2258 + let reconnectAttempts = 0; 2259 + 2260 + function connectWebSocket() { 2261 + ws = new WebSocket(protocol + '//' + location.host + '/ws'); 2262 + 2263 + ws.onopen = () => { 2264 + console.log('šŸ“” WebSocket connected'); 2265 + reconnectAttempts = 0; 2266 + }; 2267 + 2268 + ws.onclose = () => { 2269 + console.log('šŸ“” WebSocket disconnected, reconnecting...'); 2270 + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); 2271 + reconnectAttempts++; 2272 + setTimeout(connectWebSocket, delay); 2273 + }; 2274 + 2275 + ws.onerror = () => ws.close(); 2276 + 2277 + ws.onmessage = (event) => { 2278 + try { 2279 + const data = JSON.parse(event.data); 2280 + 2281 + // Check if there's active grab progress for our piece 2282 + if (data.grabs && data.grabs.active) { 2283 + const activeGrab = data.grabs.active.find(g => 2284 + g.piece === currentPiece || g.piece === '$' + currentPiece 2285 + ); 2286 + 2287 + if (activeGrab) { 2288 + // Find which preset this matches (by dimensions) 2289 + for (const [preset, config] of Object.entries(${JSON.stringify(APP_SCREENSHOT_PRESETS)})) { 2290 + if (activeGrab.dimensions && 2291 + activeGrab.dimensions.width === config.width && 2292 + activeGrab.dimensions.height === config.height) { 2293 + updateProgressUI(preset, activeGrab.status, null); 2294 + } 2295 + } 2296 + } 2297 + } 2298 + } catch (err) { 2299 + console.error('WebSocket parse error:', err); 2300 + } 2301 + }; 2302 + } 2303 + 2304 + // Poll for detailed progress since grabs report to /grab-status 2305 + async function pollProgress() { 2306 + try { 2307 + const res = await fetch('/grab-status'); 2308 + const data = await res.json(); 2309 + 2310 + if (data.progress && data.progress.piece) { 2311 + const piece = data.progress.piece; 2312 + if (piece === currentPiece || piece === '$' + currentPiece) { 2313 + // Check queue position for this piece 2314 + let queuePosition = null; 2315 + if (data.queue && data.queue.length > 0) { 2316 + const queueItem = data.queue.find(q => q.piece === piece); 2317 + if (queueItem) queuePosition = queueItem.position; 2318 + } 2319 + 2320 + // Find matching preset by checking dimensions in active grabs 2321 + if (data.active && data.active.length > 0) { 2322 + const activeGrab = data.active.find(g => 2323 + g.piece === currentPiece || g.piece === '$' + currentPiece 2324 + ); 2325 + if (activeGrab && activeGrab.dimensions) { 2326 + for (const [preset, config] of Object.entries(${JSON.stringify(APP_SCREENSHOT_PRESETS)})) { 2327 + if (activeGrab.dimensions.width === config.width && 2328 + activeGrab.dimensions.height === config.height) { 2329 + updateProgressUI(preset, data.progress.stage, data.progress.percent, data.progress.stageDetail, null, queuePosition); 2330 + break; 2331 + } 2332 + } 2333 + } 2334 + } 2335 + 2336 + // Fallback: update all visible loading indicators with generic progress 2337 + document.querySelectorAll('.loading[data-loading]').forEach(el => { 2338 + if (el.style.display !== 'none') { 2339 + const progressText = el.querySelector('.progress-text'); 2340 + const progressBar = el.querySelector('.progress-bar-fill'); 2341 + const previewImg = el.querySelector('.preview-img'); 2342 + 2343 + if (progressText && data.progress.stageDetail) { 2344 + progressText.textContent = data.progress.stageDetail; 2345 + } 2346 + if (progressBar && data.progress.percent) { 2347 + progressBar.style.width = data.progress.percent + '%'; 2348 + } 2349 + // Display streaming preview if available 2350 + if (previewImg && data.progress.previewFrame) { 2351 + previewImg.src = 'data:image/jpeg;base64,' + data.progress.previewFrame; 2352 + previewImg.style.display = 'block'; 2353 + } 2354 + } 2355 + }); 2356 + } 2357 + } 2358 + } catch (err) { 2359 + // Ignore polling errors 2360 + } 2361 + } 2362 + 2363 + function updateProgressUI(preset, stage, percent, detail, previewFrame, queuePosition) { 2364 + const loading = document.querySelector('[data-loading="' + preset + '"]'); 2365 + if (!loading || loading.style.display === 'none') return; 2366 + 2367 + const progressText = loading.querySelector('.progress-text'); 2368 + const progressBar = loading.querySelector('.progress-bar-fill'); 2369 + const previewImg = loading.querySelector('.preview-img'); 2370 + 2371 + // Map stage to friendly text 2372 + const stageText = { 2373 + 'loading': 'šŸš€ Loading piece...', 2374 + 'waiting-content': 'ā³ Waiting for render...', 2375 + 'settling': 'āøļø Settling...', 2376 + 'capturing': 'šŸ“ø Capturing...', 2377 + 'encoding': 'šŸ”„ Processing...', 2378 + 'uploading': 'ā˜ļø Uploading...', 2379 + 'queued': queuePosition ? 'ā³ In queue (#' + queuePosition + ')...' : 'ā³ In queue...', 2380 + }; 2381 + 2382 + if (progressText) { 2383 + progressText.textContent = detail || stageText[stage] || stage || ''; 2384 + } 2385 + if (progressBar && percent != null) { 2386 + progressBar.style.width = percent + '%'; 2387 + } 2388 + // Show streaming preview 2389 + if (previewImg && previewFrame) { 2390 + previewImg.src = 'data:image/jpeg;base64,' + previewFrame; 2391 + previewImg.style.display = 'block'; 2392 + } 2393 + } 2394 + 2395 + // Start WebSocket and polling 2396 + connectWebSocket(); 2397 + const pollInterval = setInterval(pollProgress, 150); // Poll fast for smooth previews 2398 + 2399 + // Cleanup on page unload 2400 + window.addEventListener('beforeunload', () => { 2401 + clearInterval(pollInterval); 2402 + if (ws) ws.close(); 2403 + }); 2404 + </script> 2405 + </body> 2406 + </html>`); 2407 + }); 2408 + 2409 + // Individual app screenshot endpoint 2410 + app.get('/app-screenshots/:preset/:piece.png', async (req, res) => { 2411 + const { preset, piece } = req.params; 2412 + const force = req.query.force === 'true'; 2413 + 2414 + const presetConfig = APP_SCREENSHOT_PRESETS[preset]; 2415 + if (!presetConfig) { 2416 + return res.status(400).json({ 2417 + error: 'Invalid preset', 2418 + valid: Object.keys(APP_SCREENSHOT_PRESETS) 2419 + }); 2420 + } 2421 + 2422 + const { width, height } = presetConfig; 2423 + 2424 + try { 2425 + addServerLog('capture', 'šŸ“±', `App screenshot: ${piece} (${preset} ${width}Ɨ${height}${force ? ' FORCE' : ''})`); 2426 + 2427 + const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate( 2428 + 'app-screenshots', 2429 + `${piece}-${preset}`, 2430 + width, 2431 + height, 2432 + async () => { 2433 + const result = await grabPiece(piece, { 2434 + format: 'png', 2435 + width, 2436 + height, 2437 + density: 4, // Pixel art - larger art pixels (4x) 2438 + viewportScale: 1, // Capture at exact output size 2439 + skipCache: force, 2440 + }); 2441 + 2442 + if (!result.success) throw new Error(result.error); 2443 + 2444 + // Handle cached result (cdnUrl but no buffer) 2445 + if (result.cached && result.cdnUrl && !result.buffer) { 2446 + const response = await fetch(result.cdnUrl); 2447 + if (!response.ok) throw new Error(`Failed to fetch cached screenshot: ${response.status}`); 2448 + return Buffer.from(await response.arrayBuffer()); 2449 + } 2450 + 2451 + return result.buffer; 2452 + }, 2453 + 'png', // ext 2454 + force // skipCache - pass force flag to skip CDN cache 2455 + ); 2456 + 2457 + if (fromCache && cdnUrl && !force) { 2458 + res.setHeader('X-Cache', 'HIT'); 2459 + res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days 2460 + return res.redirect(302, cdnUrl); 2461 + } 2462 + 2463 + res.setHeader('Content-Type', 'image/png'); 2464 + res.setHeader('Content-Length', buffer.length); 2465 + // When force=true, prevent caching 2466 + res.setHeader('Cache-Control', force ? 'no-store, no-cache, must-revalidate' : 'public, max-age=86400'); 2467 + res.setHeader('X-Cache', force ? 'REGENERATED' : 'MISS'); 2468 + res.setHeader('X-Screenshot-Preset', preset); 2469 + res.setHeader('X-Screenshot-Dimensions', `${width}x${height}`); 2470 + res.send(buffer); 2471 + 2472 + } catch (error) { 2473 + console.error('App screenshot error:', error); 2474 + addServerLog('error', 'āŒ', `App screenshot failed: ${piece} ${preset} - ${error.message}`); 2475 + res.status(500).json({ error: error.message }); 2476 + } 2477 + }); 2478 + 2479 + // Bulk ZIP download endpoint 2480 + app.get('/app-screenshots/download/:piece', async (req, res) => { 2481 + const { piece } = req.params; 2482 + const presets = Object.entries(APP_SCREENSHOT_PRESETS); 2483 + 2484 + addServerLog('info', 'šŸ“¦', `Generating ZIP for ${piece} (${presets.length} screenshots)`); 2485 + 2486 + res.setHeader('Content-Type', 'application/zip'); 2487 + res.setHeader('Content-Disposition', `attachment; filename="${piece}-app-screenshots.zip"`); 2488 + 2489 + const archive = archiver('zip', { zlib: { level: 9 } }); 2490 + archive.pipe(res); 2491 + 2492 + for (const [presetKey, preset] of presets) { 2493 + try { 2494 + const { cdnUrl, buffer } = await getCachedOrGenerate( 2495 + 'app-screenshots', 2496 + `${piece}-${presetKey}`, 2497 + preset.width, 2498 + preset.height, 2499 + async () => { 2500 + const result = await grabPiece(piece, { 2501 + format: 'png', 2502 + width: preset.width, 2503 + height: preset.height, 2504 + density: 4, // Pixel art - larger art pixels (4x) 2505 + viewportScale: 1, // Capture at exact output size 2506 + }); 2507 + 2508 + if (!result.success) throw new Error(result.error); 2509 + 2510 + if (result.cached && result.cdnUrl && !result.buffer) { 2511 + const response = await fetch(result.cdnUrl); 2512 + if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`); 2513 + return Buffer.from(await response.arrayBuffer()); 2514 + } 2515 + 2516 + return result.buffer; 2517 + } 2518 + ); 2519 + 2520 + // Get buffer from CDN if we only have URL 2521 + let imageBuffer = buffer; 2522 + if (!imageBuffer && cdnUrl) { 2523 + const response = await fetch(cdnUrl); 2524 + if (response.ok) { 2525 + imageBuffer = Buffer.from(await response.arrayBuffer()); 2526 + } 2527 + } 2528 + 2529 + if (imageBuffer) { 2530 + const filename = `${preset.category}/${piece}-${presetKey}.png`; 2531 + archive.append(imageBuffer, { name: filename }); 2532 + addServerLog('success', 'āœ…', `Added to ZIP: ${filename}`); 2533 + } 2534 + } catch (err) { 2535 + console.error(`Failed to add ${presetKey} to ZIP:`, err); 2536 + addServerLog('error', 'āŒ', `ZIP: Failed ${presetKey} - ${err.message}`); 2537 + } 2538 + } 2539 + 2540 + archive.finalize(); 2541 + }); 2542 + 2543 + // JSON API for app screenshots status 2544 + app.get('/api/app-screenshots/:piece', async (req, res) => { 2545 + const { piece } = req.params; 2546 + const screenshots = {}; 2547 + 2548 + for (const [key, preset] of Object.entries(APP_SCREENSHOT_PRESETS)) { 2549 + screenshots[key] = { 2550 + ...preset, 2551 + url: `/app-screenshots/${key}/${piece}.png`, 2552 + downloadUrl: `/app-screenshots/${key}/${piece}.png?download=true`, 2553 + }; 2554 + } 2555 + 2556 + res.json({ 2557 + piece, 2558 + presets: screenshots, 2559 + zipUrl: `/app-screenshots/download/${piece}`, 2560 + dashboardUrl: `/app-screenshots?piece=${piece}`, 2561 + }); 2562 + }); 2563 + 2564 + // ─── Pack HTML endpoint (alias: /bundle-html) ────────────────────── 2565 + 2566 + app.get(['/pack-html', '/bundle-html'], async (req, res) => { 2567 + const code = req.query.code; 2568 + const piece = req.query.piece; 2569 + const format = req.query.format || 'html'; 2570 + const nocache = req.query.nocache === '1' || req.query.nocache === 'true'; 2571 + const nocompress = req.query.nocompress === '1' || req.query.nocompress === 'true'; 2572 + const nominify = req.query.nominify === '1' || req.query.nominify === 'true'; 2573 + const brotli = req.query.brotli === '1' || req.query.brotli === 'true'; 2574 + const inline = req.query.inline === '1' || req.query.inline === 'true'; 2575 + const noboxart = req.query.noboxart === '1' || req.query.noboxart === 'true'; 2576 + const keeplabel = req.query.keeplabel === '1' || req.query.keeplabel === 'true'; 2577 + const density = parseInt(req.query.density) || null; 2578 + const mode = req.query.mode; 2579 + 2580 + // Device mode: simple iframe wrapper (fast path) 2581 + if (mode === 'device') { 2582 + const pieceCode = code || piece; 2583 + if (!pieceCode) return res.status(400).send('Missing code or piece parameter'); 2584 + return res.set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=60' }).send(generateDeviceHTML(pieceCode, density)); 2585 + } 2586 + 2587 + setSkipMinification(nominify); 2588 + 2589 + const isJSPiece = !!piece; 2590 + const bundleTarget = piece || code; 2591 + if (!bundleTarget) { 2592 + return res.status(400).json({ error: "Missing 'code' or 'piece' parameter.", usage: { kidlisp: "/pack-html?code=39j", javascript: "/pack-html?piece=notepat" } }); 2593 + } 2594 + 2595 + // M4D mode: .amxd binary 2596 + if (format === 'm4d') { 2597 + try { 2598 + const onProgress = (p) => console.log(`[bundler] m4d ${p.stage}: ${p.message}`); 2599 + const { binary, filename } = await createM4DBundle(bundleTarget, isJSPiece, onProgress, density); 2600 + res.set({ 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-cache' }); 2601 + return res.send(binary); 2602 + } catch (error) { 2603 + console.error('M4D bundle failed:', error); 2604 + return res.status(500).json({ error: error.message }); 2605 + } 2606 + } 2607 + 2608 + // SSE streaming mode 2609 + if (format === 'stream') { 2610 + res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); 2611 + res.flushHeaders(); 2612 + 2613 + const sendEvent = (type, data) => { 2614 + res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 2615 + if (typeof res.flush === 'function') res.flush(); 2616 + }; 2617 + 2618 + try { 2619 + const onProgress = (p) => sendEvent('progress', p); 2620 + const { html, filename, sizeKB } = isJSPiece 2621 + ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel) 2622 + : await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel); 2623 + sendEvent('complete', { filename, content: Buffer.from(html).toString('base64'), sizeKB }); 2624 + } catch (error) { 2625 + console.error('Bundle failed:', error); 2626 + sendEvent('error', { error: error.message }); 2627 + } 2628 + return res.end(); 2629 + } 2630 + 2631 + // Non-streaming modes (json, html download, inline) 2632 + try { 2633 + const progressLog = []; 2634 + const onProgress = (p) => { progressLog.push(p.message); console.log(`[bundler] ${p.stage}: ${p.message}`); }; 2635 + const result = isJSPiece 2636 + ? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel) 2637 + : await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel); 2638 + const { html, filename, sizeKB, mainSource, authorHandle, userCode, packDate, depCount } = result; 2639 + 2640 + if (format === 'json' || format === 'base64') { 2641 + return res.json({ filename, content: Buffer.from(html).toString('base64'), sizeKB, progress: progressLog, sourceCode: mainSource, authorHandle, userCode, packDate, depCount }); 2642 + } 2643 + 2644 + const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }; 2645 + if (!inline) headers['Content-Disposition'] = `attachment; filename="${filename}"`; 2646 + return res.set(headers).send(html); 2647 + } catch (error) { 2648 + console.error('Bundle failed:', error); 2649 + return res.status(500).json({ error: error.message }); 2650 + } 2651 + }); 2652 + 2653 + // Prewarm the core bundle cache (called by deploy.sh after restart) 2654 + app.post(['/pack-prewarm', '/bundle-prewarm'], async (req, res) => { 2655 + try { 2656 + addServerLog('info', 'šŸ“¦', 'Bundle prewarm started...'); 2657 + const result = await prewarmCache(); 2658 + addServerLog('success', 'šŸ“¦', `Bundle cache ready: ${result.fileCount} files in ${result.elapsed}ms (${result.commit})`); 2659 + res.json(result); 2660 + } catch (error) { 2661 + addServerLog('error', 'āŒ', `Bundle prewarm failed: ${error.message}`); 2662 + res.status(500).json({ error: error.message }); 2663 + } 2664 + }); 2665 + 2666 + // Cache status 2667 + app.get(['/pack-status', '/bundle-status'], (req, res) => { 2668 + res.json(getCacheStatus()); 2669 + }); 2670 + 2671 + // ===== OS IMAGE BUILDER ===== 2672 + // Assembles bootable FedAC OS artifacts with a piece injected into the FEDAC-PIECE partition. 2673 + // Requires: pre-baked base image on CDN + e2fsprogs (debugfs) on server. 2674 + 2675 + app.get('/os', async (req, res) => { 2676 + const code = req.query.code; 2677 + const piece = req.query.piece; 2678 + const format = req.query.format || 'download'; 2679 + const density = parseInt(req.query.density) || 8; 2680 + const flavor = (req.query.flavor || 'alpine').toLowerCase(); 2681 + const nocache = req.query.nocache === '1' || req.query.nocache === 'true'; 2682 + 2683 + if (!['alpine', 'fedora', 'native'].includes(flavor)) { 2684 + return res.status(400).json({ error: "Invalid flavor. Use 'alpine', 'fedora', or 'native'." }); 2685 + } 2686 + 2687 + // Native flavor: pre-built bare-metal kernel images on CDN (no dynamic assembly) 2688 + if (flavor === 'native') { 2689 + const nativePiece = piece || code || 'notepat'; 2690 + const cdnUrl = `https://releases.aesthetic.computer/os/native-${nativePiece}-latest.img.gz`; 2691 + const filename = `${nativePiece}-native.img.gz`; 2692 + addServerLog('info', 'šŸ’æ', `OS native redirect: ${nativePiece}`); 2693 + 2694 + if (format === 'stream') { 2695 + res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); 2696 + res.flushHeaders(); 2697 + const sendEvent = (type, data) => { res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); if (typeof res.flush === 'function') res.flush(); }; 2698 + sendEvent('progress', { stage: 'native', message: 'Native image ready on CDN', percent: 100 }); 2699 + sendEvent('complete', { message: 'Native OS image ready', downloadUrl: cdnUrl, filename, cached: true, flavor: 'native', elapsed: 0 }); 2700 + return res.end(); 2701 + } 2702 + return res.redirect(cdnUrl); 2703 + } 2704 + 2705 + const isJSPiece = !!piece; 2706 + const target = piece || code; 2707 + if (!target) { 2708 + return res.status(400).json({ 2709 + error: "Missing 'code' or 'piece' parameter.", 2710 + usage: { kidlisp: "/os?code=39j", javascript: "/os?piece=notepat" }, 2711 + }); 2712 + } 2713 + 2714 + addServerLog('info', 'šŸ’æ', `OS ISO build started: ${target} (${flavor})${nocache ? ' [nocache]' : ''}`); 2715 + 2716 + // SSE streaming progress mode (for UI) 2717 + if (format === 'stream') { 2718 + res.set({ 2719 + 'Content-Type': 'text/event-stream', 2720 + 'Cache-Control': 'no-cache', 2721 + 'Connection': 'keep-alive', 2722 + 'X-Accel-Buffering': 'no', 2723 + }); 2724 + res.flushHeaders(); 2725 + 2726 + const sendEvent = (type, data) => { 2727 + res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 2728 + if (typeof res.flush === 'function') res.flush(); 2729 + }; 2730 + 2731 + try { 2732 + const result = await streamOSImage(null, target, isJSPiece, density, (p) => sendEvent('progress', p), flavor, { nocache }); 2733 + const downloadParam = isJSPiece ? `piece=${encodeURIComponent(target)}` : `code=${encodeURIComponent(target)}`; 2734 + // Prefer CDN URL for fast download; fall back to oven direct. 2735 + const downloadUrl = result.cdnUrl || `/os?${downloadParam}&density=${density}&flavor=${flavor}`; 2736 + sendEvent('complete', { 2737 + message: result.cached ? 'OS ISO ready (CDN cached)' : 'OS ISO ready', 2738 + downloadUrl, 2739 + elapsed: result.elapsed, 2740 + filename: result.filename, 2741 + timings: result.timings, 2742 + cached: result.cached || false, 2743 + flavor, 2744 + }); 2745 + } catch (err) { 2746 + console.error('[os] SSE build failed:', err); 2747 + sendEvent('error', { error: err.message }); 2748 + } 2749 + return res.end(); 2750 + } 2751 + 2752 + // Direct download mode 2753 + try { 2754 + const result = await streamOSImage(res, target, isJSPiece, density, (p) => { 2755 + console.log(`[os] ${p.stage}: ${p.message}`); 2756 + }, flavor, { nocache }); 2757 + addServerLog('success', 'šŸ’æ', `OS ISO build complete: ${target}/${flavor} (${Math.round(result.elapsed / 1000)}s)`); 2758 + } catch (err) { 2759 + console.error('[os] Build failed:', err); 2760 + addServerLog('error', 'āŒ', `OS build failed: ${err.message}`); 2761 + if (!res.headersSent) { 2762 + res.status(500).json({ error: err.message }); 2763 + } 2764 + } 2765 + }); 2766 + 2767 + app.get('/os-status', (req, res) => { 2768 + res.json(getOSBuildStatus()); 2769 + }); 2770 + 2771 + // Proxy releases.json with CORS for the web os.mjs piece. 2772 + app.get('/os-releases', async (req, res) => { 2773 + try { 2774 + const r = await fetch(`${RELEASES_BASE}/releases.json`); 2775 + if (!r.ok) return res.status(r.status).json({ error: 'Failed to fetch releases' }); 2776 + const data = await r.json(); 2777 + res.json(data); 2778 + } catch (err) { 2779 + res.status(502).json({ error: err.message }); 2780 + } 2781 + }); 2782 + 2783 + // Flush the cached OS template so the next download gets the fresh one. 2784 + app.post('/os-cache-flush', (req, res) => { 2785 + templateCache = null; 2786 + templateCacheTime = 0; 2787 + console.log('[os-image] Template cache flushed'); 2788 + res.json({ flushed: true }); 2789 + }); 2790 + 2791 + // Personalized FedAC OS .iso download for authenticated AC users. 2792 + // Downloads the template .iso from DO Spaces, patches config.json in-place, 2793 + // and streams back. Compatible with Fedora Media Writer, Balena Etcher, dd. 2794 + const RELEASES_BASE = 'https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os'; 2795 + const TEMPLATE_ISO_URL = `${RELEASES_BASE}/native-notepat-latest.iso`; 2796 + const TEMPLATE_GZ_URL = `${RELEASES_BASE}/native-notepat-latest.img.gz`; // legacy fallback 2797 + const CONFIG_MARKER_LEGACY = '{"handle":"","piece":"notepat","sub":"","email":""}'; 2798 + const CONFIG_PAD_SIZE_LEGACY = 4096; 2799 + const IDENTITY_MARKER = 'AC_IDENTITY_BLOCK_V1'; 2800 + const IDENTITY_BLOCK_SIZE = 32768; 2801 + 2802 + // Cache the decompressed template in memory 2803 + let templateCache = null; 2804 + let templateCacheTime = 0; 2805 + const TEMPLATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour 2806 + 2807 + async function getTemplate() { 2808 + if (templateCache && Date.now() - templateCacheTime < TEMPLATE_CACHE_TTL) { 2809 + return templateCache; 2810 + } 2811 + // Try .iso first, fall back to legacy .img.gz 2812 + let raw; 2813 + const isoRes = await fetch(TEMPLATE_ISO_URL); 2814 + if (isoRes.ok) { 2815 + console.log('[os-image] Downloading template .iso...'); 2816 + raw = Buffer.from(await isoRes.arrayBuffer()); 2817 + } else { 2818 + console.log('[os-image] No .iso found, trying legacy .img.gz fallback...'); 2819 + const gzRes = await fetch(TEMPLATE_GZ_URL); 2820 + if (gzRes.ok) { 2821 + const compressed = Buffer.from(await gzRes.arrayBuffer()); 2822 + console.log(`[os-image] Decompressing ${(compressed.length / 1048576).toFixed(1)}MB...`); 2823 + raw = gunzipSync(compressed); 2824 + } else { 2825 + throw new Error(`Template download failed (no .iso or .img.gz available)`); 2826 + } 2827 + } 2828 + templateCache = raw; 2829 + templateCacheTime = Date.now(); 2830 + console.log(`[os-image] Template cached: ${(templateCache.length / 1048576).toFixed(1)}MB`); 2831 + return templateCache; 2832 + } 2833 + 2834 + // User config endpoint for edge worker ISO patching 2835 + app.get('/api/user-config', async (req, res) => { 2836 + const authHeader = req.headers.authorization || ''; 2837 + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; 2838 + if (!token) return res.status(401).json({ error: 'Authorization required' }); 2839 + 2840 + let userInfo; 2841 + try { 2842 + const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', { 2843 + headers: { Authorization: `Bearer ${token}` }, 2844 + }); 2845 + if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`); 2846 + userInfo = await uiRes.json(); 2847 + } catch (err) { 2848 + return res.status(401).json({ error: `Authentication failed: ${err.message}` }); 2849 + } 2850 + 2851 + const sub = userInfo.sub || ''; 2852 + let handle = ''; 2853 + try { 2854 + const handleRes = await fetch(`https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}`); 2855 + if (handleRes.ok) { 2856 + const data = await handleRes.json(); 2857 + handle = data.handle || ''; 2858 + } 2859 + } catch (_) {} 2860 + 2861 + if (!handle) return res.status(403).json({ error: 'No handle found' }); 2862 + 2863 + let claudeToken = '', githubPat = ''; 2864 + try { 2865 + const mongoUri = process.env.MONGODB_CONNECTION_STRING; 2866 + const dbName = process.env.MONGODB_NAME; 2867 + if (mongoUri) { 2868 + const { MongoClient } = await import('mongodb'); 2869 + const client = new MongoClient(mongoUri); 2870 + await client.connect(); 2871 + const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub }); 2872 + if (doc) { 2873 + claudeToken = doc.claudeCodeToken || ''; 2874 + githubPat = doc.githubPat || ''; 2875 + } 2876 + await client.close(); 2877 + } 2878 + } catch (err) { 2879 + console.warn(`[user-config] Token lookup failed: ${err.message}`); 2880 + } 2881 + 2882 + const reqPiece = req.query.piece || 'notepat'; 2883 + const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken']; 2884 + const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat'; 2885 + const wifiParam = req.query.wifi; 2886 + const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false'; 2887 + 2888 + const config = { handle, piece: bootPiece, sub, email: userInfo.email || '', token }; 2889 + if (claudeToken) config.claudeToken = claudeToken; 2890 + if (githubPat) config.githubPat = githubPat; 2891 + if (!wifiEnabled) config.wifi = false; 2892 + 2893 + res.json(config); 2894 + }); 2895 + 2896 + app.get('/os-image', async (req, res) => { 2897 + // Auth: verify AC token 2898 + const authHeader = req.headers.authorization || ''; 2899 + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; 2900 + if (!token) { 2901 + return res.status(401).json({ error: 'Authorization required. Log in at aesthetic.computer first.' }); 2902 + } 2903 + 2904 + let userInfo; 2905 + try { 2906 + const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', { 2907 + headers: { Authorization: `Bearer ${token}` }, 2908 + }); 2909 + if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`); 2910 + userInfo = await uiRes.json(); 2911 + } catch (err) { 2912 + return res.status(401).json({ error: `Authentication failed: ${err.message}` }); 2913 + } 2914 + 2915 + // Look up handle by sub (avoids stale /user cache for new handles) 2916 + let handle = ''; 2917 + const sub = userInfo.sub || ''; 2918 + try { 2919 + const handleRes = await fetch( 2920 + `https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}` 2921 + ); 2922 + if (handleRes.ok) { 2923 + const data = await handleRes.json(); 2924 + handle = data.handle || ''; 2925 + } 2926 + } catch (_) {} 2927 + 2928 + if (!handle) { 2929 + return res.status(403).json({ error: 'You need a handle first. Visit aesthetic.computer/handle to claim one.' }); 2930 + } 2931 + 2932 + // Boot-to piece preference (default: notepat) 2933 + const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken']; 2934 + const reqPiece = req.query.piece || 'notepat'; 2935 + const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat'; 2936 + 2937 + // WiFi/internet toggle (default: enabled) 2938 + const wifiParam = req.query.wifi; 2939 + const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false'; 2940 + 2941 + // Fetch device tokens (Claude + GitHub) from DB 2942 + let claudeToken = '', githubPat = ''; 2943 + try { 2944 + const mongoUri = process.env.MONGODB_CONNECTION_STRING; 2945 + const dbName = process.env.MONGODB_NAME; 2946 + if (mongoUri) { 2947 + const { MongoClient } = await import('mongodb'); 2948 + const client = new MongoClient(mongoUri); 2949 + await client.connect(); 2950 + const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub }); 2951 + if (doc) { 2952 + claudeToken = doc.claudeCodeToken || ''; 2953 + githubPat = doc.githubPat || ''; 2954 + } 2955 + await client.close(); 2956 + } 2957 + } catch (err) { 2958 + console.warn(`[os-image] Token lookup failed: ${err.message}`); 2959 + } 2960 + 2961 + console.log(`[os-image] Building personalized image for @${handle} (boot: ${bootPiece}, wifi: ${wifiEnabled}, claude: ${!!claudeToken}, git: ${!!githubPat})`); 2962 + 2963 + // Get template (cached in memory) 2964 + let imgData; 2965 + try { 2966 + const template = await getTemplate(); 2967 + imgData = Buffer.from(template); // copy so we don't mutate cache 2968 + } catch (err) { 2969 + return res.status(503).json({ error: `Template not available: ${err.message}` }); 2970 + } 2971 + 2972 + // Build personalized config JSON 2973 + const configObj = { 2974 + handle, 2975 + piece: bootPiece, 2976 + sub: userInfo.sub || '', 2977 + email: userInfo.email || '', 2978 + token: token, 2979 + }; 2980 + if (claudeToken) configObj.claudeToken = claudeToken; 2981 + if (githubPat) configObj.githubPat = githubPat; 2982 + if (!wifiEnabled) configObj.wifi = false; 2983 + const configJson = JSON.stringify(configObj); 2984 + 2985 + // Try new identity block format first (32KB, marker-prefixed) 2986 + const identityMarkerBuf = Buffer.from(IDENTITY_MARKER + '\n'); 2987 + let idx = imgData.indexOf(identityMarkerBuf); 2988 + let patchCount = 0; 2989 + 2990 + if (idx !== -1) { 2991 + // New format: marker + newline + JSON + zero-padding to 32KB 2992 + while (idx !== -1) { 2993 + const block = Buffer.alloc(IDENTITY_BLOCK_SIZE, 0); 2994 + const header = Buffer.from(IDENTITY_MARKER + '\n' + configJson); 2995 + header.copy(block); 2996 + block.copy(imgData, idx); 2997 + patchCount++; 2998 + idx = imgData.indexOf(identityMarkerBuf, idx + IDENTITY_BLOCK_SIZE); 2999 + } 3000 + console.log(`[os-image] Patched ${patchCount} identity block(s) for @${handle} (v1, 32KB)`); 3001 + } else { 3002 + // Legacy format: plain JSON padded to 4KB with spaces 3003 + const legacyMarkerBuf = Buffer.from(CONFIG_MARKER_LEGACY); 3004 + idx = imgData.indexOf(legacyMarkerBuf); 3005 + if (idx === -1) { 3006 + return res.status(500).json({ error: 'Template image missing config placeholder' }); 3007 + } 3008 + const padded = configJson + ' '.repeat(Math.max(0, CONFIG_PAD_SIZE_LEGACY - configJson.length)); 3009 + const configBytes = Buffer.from(padded); 3010 + while (idx !== -1) { 3011 + configBytes.copy(imgData, idx); 3012 + patchCount++; 3013 + idx = imgData.indexOf(legacyMarkerBuf, idx + CONFIG_PAD_SIZE_LEGACY); 3014 + } 3015 + console.log(`[os-image] Patched ${patchCount} config location(s) for @${handle} (legacy, 4KB)`); 3016 + } 3017 + 3018 + // Stream the patched ISO (Fedora Media Writer / Balena Etcher / dd compatible) 3019 + addServerLog('success', 'šŸ’æ', `OS ISO for @${handle} (${(imgData.length / 1048576).toFixed(1)}MB)`); 3020 + res.setHeader('Content-Type', 'application/x-iso9660-image'); 3021 + // Get latest build name for filename 3022 + let releaseName = 'native'; 3023 + try { 3024 + const relRes = await fetch(`${RELEASES_BASE}/releases.json`); 3025 + if (relRes.ok) { 3026 + const relData = await relRes.json(); 3027 + releaseName = relData?.releases?.[0]?.name || releaseName; 3028 + } 3029 + } catch (_) {} 3030 + const coreName = 'AC-' + releaseName; 3031 + const d = new Date(); 3032 + const p = (n) => String(n).padStart(2, '0'); 3033 + const ts = `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())}.${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}`; 3034 + res.setHeader('Content-Disposition', `attachment; filename="@${handle}-os-${bootPiece}-${coreName}-${ts}.iso"`); 3035 + res.setHeader('Content-Length', imgData.length); 3036 + res.end(imgData); 3037 + }); 3038 + 3039 + // Background base image jobs (build + upload) for FedOS pipeline. 3040 + app.get('/os-base-build', (req, res) => { 3041 + res.json(getOSBaseBuildsSummary()); 3042 + }); 3043 + 3044 + app.get('/os-base-build/:jobId', (req, res) => { 3045 + const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200)); 3046 + const includeLogs = req.query.logs === '1' || req.query.logs === 'true'; 3047 + const job = getOSBaseBuild(req.params.jobId, { includeLogs, tail }); 3048 + if (!job) return res.status(404).json({ error: 'Job not found' }); 3049 + return res.json(job); 3050 + }); 3051 + 3052 + app.get('/os-base-build/:jobId/stream', (req, res) => { 3053 + const jobId = req.params.jobId; 3054 + const initial = getOSBaseBuild(jobId, { includeLogs: true, tail: 500 }); 3055 + if (!initial) return res.status(404).json({ error: 'Job not found' }); 3056 + 3057 + res.set({ 3058 + 'Content-Type': 'text/event-stream', 3059 + 'Cache-Control': 'no-cache', 3060 + 'Connection': 'keep-alive', 3061 + 'X-Accel-Buffering': 'no', 3062 + }); 3063 + res.flushHeaders(); 3064 + 3065 + let sentLogs = 0; 3066 + const sendEvent = (type, data) => { 3067 + res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 3068 + if (typeof res.flush === 'function') res.flush(); 3069 + }; 3070 + 3071 + const sendSnapshot = () => { 3072 + const job = getOSBaseBuild(jobId, { includeLogs: true, tail: 2000 }); 3073 + if (!job) { 3074 + sendEvent('error', { error: 'Job not found' }); 3075 + return false; 3076 + } 3077 + 3078 + const logs = Array.isArray(job.logs) ? job.logs : []; 3079 + if (logs.length > sentLogs) { 3080 + sendEvent('logs', { logs: logs.slice(sentLogs) }); 3081 + sentLogs = logs.length; 3082 + } 3083 + sendEvent('status', { 3084 + id: job.id, 3085 + status: job.status, 3086 + stage: job.stage, 3087 + message: job.message, 3088 + percent: job.percent, 3089 + updatedAt: job.updatedAt, 3090 + finishedAt: job.finishedAt, 3091 + error: job.error, 3092 + upload: job.upload, 3093 + }); 3094 + if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') { 3095 + sendEvent('complete', { 3096 + status: job.status, 3097 + error: job.error, 3098 + upload: job.upload, 3099 + }); 3100 + return false; 3101 + } 3102 + return true; 3103 + }; 3104 + 3105 + sendEvent('status', { 3106 + id: initial.id, 3107 + status: initial.status, 3108 + stage: initial.stage, 3109 + message: initial.message, 3110 + percent: initial.percent, 3111 + updatedAt: initial.updatedAt, 3112 + }); 3113 + if (Array.isArray(initial.logs) && initial.logs.length > 0) { 3114 + sendEvent('logs', { logs: initial.logs }); 3115 + sentLogs = initial.logs.length; 3116 + } 3117 + 3118 + const timer = setInterval(() => { 3119 + if (!sendSnapshot()) { 3120 + clearInterval(timer); 3121 + res.end(); 3122 + } 3123 + }, 1000); 3124 + 3125 + req.on('close', () => { 3126 + clearInterval(timer); 3127 + }); 3128 + }); 3129 + 3130 + app.post('/os-base-build', requireOSBuildAdmin, async (req, res) => { 3131 + const flavor = (req.body?.flavor || 'alpine').toLowerCase(); 3132 + if (!['alpine', 'fedora'].includes(flavor)) { 3133 + return res.status(400).json({ error: "Invalid flavor. Use 'alpine' or 'fedora'." }); 3134 + } 3135 + const defaultSize = flavor === 'alpine' ? 1 : 4; 3136 + const imageSizeGB = Math.max(1, Math.min(32, parseInt(req.body?.imageSizeGB, 10) || defaultSize)); 3137 + const publish = req.body?.publish !== false; 3138 + const requestedWorkBase = typeof req.body?.workBase === 'string' ? req.body.workBase.trim() : ''; 3139 + const workBase = requestedWorkBase || undefined; 3140 + 3141 + try { 3142 + const job = await startOSBaseBuild( 3143 + { imageSizeGB, publish, flavor, workBase }, 3144 + { 3145 + onStart: (j) => addServerLog('info', 'šŸ’æ', `OS base build started: ${j.id} (${flavor}, ${imageSizeGB}GiB${workBase ? `, workBase=${workBase}` : ''})`), 3146 + onUploadComplete: async (j) => { 3147 + addServerLog('success', 'ā˜ļø', `OS base upload complete: ${j.upload.imageKey}`); 3148 + invalidateManifest(flavor); 3149 + addServerLog('info', 'šŸ’æ', `OS manifest cache invalidated (${flavor}) after base upload`); 3150 + // Purge all cached per-piece builds for this flavor — the new base image 3151 + // changes the image layout, so old cached builds are stale. 3152 + const purgeResult = await purgeOSBuildCache(flavor); 3153 + addServerLog('info', 'šŸ—‘ļø', `Purged ${purgeResult.deleted} cached ${flavor} build(s) from CDN`); 3154 + }, 3155 + onSuccess: (j) => addServerLog('success', 'šŸ’æ', `OS base build complete: ${j.id} (${flavor})`), 3156 + onError: (j) => addServerLog('error', 'āŒ', `OS base build failed: ${j.id} (${j.error})`), 3157 + }, 3158 + ); 3159 + return res.status(202).json(job); 3160 + } catch (error) { 3161 + if (error.code === 'OS_BASE_BUSY') { 3162 + return res.status(409).json({ error: error.message, activeJobId: error.activeJobId }); 3163 + } 3164 + return res.status(500).json({ error: error.message }); 3165 + } 3166 + }); 3167 + 3168 + app.post('/os-base-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { 3169 + const result = cancelOSBaseBuild(req.params.jobId); 3170 + if (!result.ok) { 3171 + return res.status(400).json(result); 3172 + } 3173 + addServerLog('info', 'šŸ›‘', `OS base build cancel requested: ${req.params.jobId}`); 3174 + return res.json(result); 3175 + }); 3176 + 3177 + // ── Native OTA Build ────────────────────────────────────────────────────── 3178 + // Builds fedac/native kernel + uploads vmlinuz to DO Spaces CDN. 3179 + // Auth: same OS_BUILD_ADMIN_KEY used for /os-base-build. 3180 + // Auto-triggered by native-git-poller.mjs (polls origin/main every 60s). 3181 + // Can also be triggered manually via POST with admin key. 3182 + 3183 + app.get('/native-build', (req, res) => { 3184 + res.json({ ...getNativeBuildsSummary(), poller: getNativePollerStatus() }); 3185 + }); 3186 + 3187 + app.get('/native-build/:jobId', (req, res) => { 3188 + const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200)); 3189 + const includeLogs = req.query.logs === '1' || req.query.logs === 'true'; 3190 + const job = getNativeBuild(req.params.jobId, { includeLogs, tail }); 3191 + if (!job) return res.status(404).json({ error: 'Job not found' }); 3192 + return res.json(job); 3193 + }); 3194 + 3195 + app.get('/native-build/:jobId/stream', (req, res) => { 3196 + const jobId = req.params.jobId; 3197 + const initial = getNativeBuild(jobId, { includeLogs: true, tail: 500 }); 3198 + if (!initial) return res.status(404).json({ error: 'Job not found' }); 3199 + 3200 + res.set({ 3201 + 'Content-Type': 'text/event-stream', 3202 + 'Cache-Control': 'no-cache', 3203 + 'Connection': 'keep-alive', 3204 + 'X-Accel-Buffering': 'no', 3205 + }); 3206 + res.flushHeaders(); 3207 + 3208 + let sentLogs = 0; 3209 + const sendEvent = (type, data) => { 3210 + res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 3211 + if (typeof res.flush === 'function') res.flush(); 3212 + }; 3213 + 3214 + if (Array.isArray(initial.logs) && initial.logs.length > 0) { 3215 + sendEvent('logs', { logs: initial.logs }); 3216 + sentLogs = initial.logs.length; 3217 + } 3218 + sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent }); 3219 + 3220 + const timer = setInterval(() => { 3221 + const job = getNativeBuild(jobId, { includeLogs: true, tail: 2000 }); 3222 + if (!job) { clearInterval(timer); res.end(); return; } 3223 + const logs = Array.isArray(job.logs) ? job.logs : []; 3224 + if (logs.length > sentLogs) { 3225 + sendEvent('logs', { logs: logs.slice(sentLogs) }); 3226 + sentLogs = logs.length; 3227 + } 3228 + sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error }); 3229 + if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') { 3230 + sendEvent('complete', { status: job.status, error: job.error }); 3231 + clearInterval(timer); 3232 + res.end(); 3233 + } 3234 + }, 1000); 3235 + 3236 + req.on('close', () => clearInterval(timer)); 3237 + }); 3238 + 3239 + app.post('/native-build', requireOSBuildAdmin, async (req, res) => { 3240 + try { 3241 + const job = await startNativeBuild({ 3242 + ref: req.body?.ref || 'unknown', 3243 + changed_paths: req.body?.changed_paths || '', 3244 + }); 3245 + addServerLog('info', 'šŸ”Ø', `Native OTA build started: ${job.id} (ref=${job.ref}, flags=${job.flags.join(' ') || 'none'})`); 3246 + return res.status(202).json(job); 3247 + } catch (err) { 3248 + if (err.code === 'NATIVE_BUILD_BUSY') { 3249 + return res.status(409).json({ error: err.message, activeJobId: err.activeJobId }); 3250 + } 3251 + return res.status(500).json({ error: err.message }); 3252 + } 3253 + }); 3254 + 3255 + app.post('/native-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { 3256 + const result = cancelNativeBuild(req.params.jobId); 3257 + if (!result.ok) return res.status(400).json(result); 3258 + addServerLog('info', 'šŸ›‘', `Native build cancel requested: ${req.params.jobId}`); 3259 + return res.json(result); 3260 + }); 3261 + 3262 + // ── Papers PDF Build ────────────────────────────────────────────────────── 3263 + // Builds all AC paper PDFs from LaTeX sources using xelatex. 3264 + // Auth: same OS_BUILD_ADMIN_KEY used for /native-build. 3265 + // Auto-triggered by papers-git-poller.mjs (polls origin/main every 60s). 3266 + // Can also be triggered manually via POST with admin key. 3267 + 3268 + app.get('/papers-build', (req, res) => { 3269 + res.json({ ...getPapersBuildsSummary(), poller: getPapersPollerStatus() }); 3270 + }); 3271 + 3272 + app.get('/papers-build/:jobId', (req, res) => { 3273 + const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200)); 3274 + const includeLogs = req.query.logs === '1' || req.query.logs === 'true'; 3275 + const job = getPapersBuild(req.params.jobId, { includeLogs, tail }); 3276 + if (!job) return res.status(404).json({ error: 'Job not found' }); 3277 + return res.json(job); 3278 + }); 3279 + 3280 + app.get('/papers-build/:jobId/stream', (req, res) => { 3281 + const jobId = req.params.jobId; 3282 + const initial = getPapersBuild(jobId, { includeLogs: true, tail: 500 }); 3283 + if (!initial) return res.status(404).json({ error: 'Job not found' }); 3284 + 3285 + res.set({ 3286 + 'Content-Type': 'text/event-stream', 3287 + 'Cache-Control': 'no-cache', 3288 + 'Connection': 'keep-alive', 3289 + 'X-Accel-Buffering': 'no', 3290 + }); 3291 + res.flushHeaders(); 3292 + 3293 + let sentLogs = 0; 3294 + const sendEvent = (type, data) => { 3295 + res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); 3296 + if (typeof res.flush === 'function') res.flush(); 3297 + }; 3298 + 3299 + if (Array.isArray(initial.logs) && initial.logs.length > 0) { 3300 + sendEvent('logs', { logs: initial.logs }); 3301 + sentLogs = initial.logs.length; 3302 + } 3303 + sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent }); 3304 + 3305 + const timer = setInterval(() => { 3306 + const job = getPapersBuild(jobId, { includeLogs: true, tail: 2000 }); 3307 + if (!job) { clearInterval(timer); res.end(); return; } 3308 + const logs = Array.isArray(job.logs) ? job.logs : []; 3309 + if (logs.length > sentLogs) { 3310 + sendEvent('logs', { logs: logs.slice(sentLogs) }); 3311 + sentLogs = logs.length; 3312 + } 3313 + sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error }); 3314 + if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') { 3315 + sendEvent('complete', { status: job.status, error: job.error }); 3316 + clearInterval(timer); 3317 + res.end(); 3318 + } 3319 + }, 1000); 3320 + 3321 + req.on('close', () => clearInterval(timer)); 3322 + }); 3323 + 3324 + app.post('/papers-build', requireOSBuildAdmin, async (req, res) => { 3325 + try { 3326 + const job = await startPapersBuild({ 3327 + ref: req.body?.ref || 'unknown', 3328 + changed_paths: req.body?.changed_paths || '', 3329 + }); 3330 + addServerLog('info', 'šŸ“„', `Papers PDF build started: ${job.id} (ref=${job.ref})`); 3331 + return res.status(202).json(job); 3332 + } catch (err) { 3333 + if (err.code === 'PAPERS_BUILD_BUSY') { 3334 + return res.status(409).json({ error: err.message, activeJobId: err.activeJobId }); 3335 + } 3336 + return res.status(500).json({ error: err.message }); 3337 + } 3338 + }); 3339 + 3340 + app.post('/papers-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { 3341 + const result = cancelPapersBuild(req.params.jobId); 3342 + if (!result.ok) return res.status(400).json(result); 3343 + addServerLog('info', 'šŸ›‘', `Papers build cancel requested: ${req.params.jobId}`); 3344 + return res.json(result); 3345 + }); 3346 + 3347 + // ── OS Release Upload ────────────────────────────────────────────────────── 3348 + // Accepts a vmlinuz binary + metadata, uploads to DO Spaces as OTA release. 3349 + // Auth: AC token (Bearer) verified against Auth0 userinfo. 3350 + // Usage: curl -X POST /os-release-upload \ 3351 + // -H "Authorization: Bearer <ac_token>" \ 3352 + // -H "X-Build-Name: swift-egret" \ 3353 + // -H "X-Git-Hash: abc1234" \ 3354 + // -H "X-Build-Ts: 2026-03-11T12:00" \ 3355 + // --data-binary @build/vmlinuz 3356 + app.post('/os-release-upload', async (req, res) => { 3357 + // Auth: verify AC token 3358 + const authHeader = req.headers.authorization || ''; 3359 + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; 3360 + if (!token) { 3361 + return res.status(401).json({ error: 'Missing Authorization: Bearer <ac_token>' }); 3362 + } 3363 + 3364 + // Verify token against Auth0 3365 + const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'hi.aesthetic.computer'; 3366 + let user; 3367 + try { 3368 + const uiRes = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, { 3369 + headers: { Authorization: `Bearer ${token}` }, 3370 + }); 3371 + if (!uiRes.ok) throw new Error(`Auth0 returned ${uiRes.status}`); 3372 + user = await uiRes.json(); 3373 + } catch (err) { 3374 + addServerLog('error', 'šŸ”’', `OS release upload auth failed: ${err.message}`); 3375 + return res.status(401).json({ error: 'Invalid or expired token. Run: ac-login' }); 3376 + } 3377 + 3378 + const userSub = user.sub || 'unknown'; 3379 + const userName = user.name || user.nickname || user.email || userSub; 3380 + addServerLog('info', 'šŸ“¦', `OS release upload from ${userName} (${userSub})`); 3381 + 3382 + // Collect binary body 3383 + const chunks = []; 3384 + for await (const chunk of req) chunks.push(chunk); 3385 + const vmlinuz = Buffer.concat(chunks); 3386 + 3387 + if (vmlinuz.length < 1_000_000) { 3388 + return res.status(400).json({ error: `File too small (${vmlinuz.length} bytes). Expected vmlinuz ~35-45MB.` }); 3389 + } 3390 + 3391 + // Metadata from headers 3392 + const buildName = req.headers['x-build-name'] || `upload-${Date.now()}`; 3393 + const gitHash = req.headers['x-git-hash'] || 'unknown'; 3394 + const buildTs = req.headers['x-build-ts'] || new Date().toISOString().slice(0, 16); 3395 + const commitMsg = req.headers['x-commit-msg'] || ''; 3396 + const version = `${buildName} ${gitHash}-${buildTs}`; 3397 + 3398 + // SHA256 3399 + const crypto = await import('crypto'); 3400 + const sha256 = crypto.createHash('sha256').update(vmlinuz).digest('hex'); 3401 + 3402 + // Upload to DO Spaces 3403 + const { S3Client, PutObjectCommand, GetObjectCommand } = await import('@aws-sdk/client-s3'); 3404 + const accessKeyId = process.env.OS_SPACES_KEY || process.env.ART_SPACES_KEY; 3405 + const secretAccessKey = process.env.OS_SPACES_SECRET || process.env.ART_SPACES_SECRET; 3406 + if (!accessKeyId || !secretAccessKey) { 3407 + return res.status(503).json({ error: 'OS Spaces credentials not configured on server.' }); 3408 + } 3409 + 3410 + const spacesEndpoint = process.env.OS_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com'; 3411 + const spacesBucket = process.env.OS_SPACES_BUCKET || 'releases-aesthetic-computer'; 3412 + const cdnBase = process.env.OS_SPACES_CDN_BASE || `https://${spacesBucket}.sfo3.digitaloceanspaces.com`; 3413 + 3414 + const s3 = new S3Client({ 3415 + region: process.env.OS_SPACES_REGION || 'us-east-1', 3416 + endpoint: spacesEndpoint, 3417 + credentials: { accessKeyId, secretAccessKey }, 3418 + }); 3419 + 3420 + const upload = async (key, body, contentType) => { 3421 + await s3.send(new PutObjectCommand({ 3422 + Bucket: spacesBucket, 3423 + Key: key, 3424 + Body: body, 3425 + ContentType: contentType, 3426 + ACL: 'public-read', 3427 + })); 3428 + }; 3429 + 3430 + try { 3431 + addServerLog('info', 'ā˜ļø', `Uploading ${buildName}: ${(vmlinuz.length / 1048576).toFixed(1)}MB, sha=${sha256.slice(0, 12)}...`); 3432 + 3433 + // Upload version + sha256 first (canary), then vmlinuz 3434 + // Version file: line 1 = version string, line 2 = kernel size in bytes 3435 + const versionWithSize = `${version}\n${vmlinuz.length}`; 3436 + await upload('os/native-notepat-latest.version', versionWithSize, 'text/plain'); 3437 + await upload('os/native-notepat-latest.sha256', sha256, 'text/plain'); 3438 + await upload('os/native-notepat-latest.vmlinuz', vmlinuz, 'application/octet-stream'); 3439 + 3440 + // Update releases.json 3441 + let releases = { releases: [] }; 3442 + try { 3443 + const existing = await s3.send(new GetObjectCommand({ 3444 + Bucket: spacesBucket, Key: 'os/releases.json', 3445 + })); 3446 + const body = await existing.Body.transformToString(); 3447 + releases = JSON.parse(body); 3448 + } catch { /* first release or missing */ } 3449 + 3450 + releases.releases = releases.releases || []; 3451 + const userHandle = req.headers['x-handle'] || user.nickname || user.name || userName; 3452 + // Mark all existing builds as deprecated 3453 + for (const r of releases.releases) r.deprecated = true; 3454 + 3455 + releases.releases.unshift({ 3456 + version, name: buildName, sha256, size: vmlinuz.length, 3457 + git_hash: gitHash, build_ts: buildTs, commit_msg: commitMsg, 3458 + user: userSub, handle: userHandle, 3459 + url: `${cdnBase}/os/native-notepat-latest.vmlinuz`, 3460 + }); 3461 + releases.releases = releases.releases.slice(0, 50); 3462 + releases.latest = version; 3463 + releases.latest_name = buildName; 3464 + 3465 + await upload('os/releases.json', JSON.stringify(releases, null, 2), 'application/json'); 3466 + 3467 + // Broadcast new build to all connected WebSocket clients (os.mjs pieces) 3468 + if (wss && wss.clients) { 3469 + const buildMsg = JSON.stringify({ type: 'os:new-build', releases }); 3470 + wss.clients.forEach(client => { 3471 + if (client.readyState === 1) client.send(buildMsg); 3472 + }); 3473 + } 3474 + 3475 + addServerLog('success', 'šŸš€', `OS release published: ${buildName} (${gitHash}) by ${userName}`); 3476 + return res.json({ 3477 + ok: true, 3478 + name: buildName, 3479 + version, 3480 + sha256, 3481 + size: vmlinuz.length, 3482 + url: `${cdnBase}/os/native-notepat-latest.vmlinuz`, 3483 + user: userSub, 3484 + }); 3485 + } catch (err) { 3486 + addServerLog('error', 'āŒ', `OS release upload failed: ${err.message}`); 3487 + return res.status(500).json({ error: `Upload failed: ${err.message}` }); 3488 + } 3489 + }); 3490 + 3491 + app.post('/os-invalidate', async (req, res) => { 3492 + const purge = req.body?.purge === true; 3493 + const clearLocal = req.body?.local === true || req.body?.clearLocal === true; 3494 + const flavor = req.body?.flavor; 3495 + invalidateManifest(flavor); 3496 + addServerLog('info', 'šŸ’æ', `OS base image manifest cache invalidated${flavor ? ` (${flavor})` : ''}`); 3497 + 3498 + let localResult = null; 3499 + if (clearLocal) { 3500 + localResult = await clearOSBuildLocalCache(flavor); 3501 + addServerLog('info', '🧹', `Cleared ${localResult.deleted} local base-image cache file(s)${flavor ? ` (${flavor})` : ''}`); 3502 + } 3503 + 3504 + if (purge) { 3505 + const purgeResult = await purgeOSBuildCache(flavor); 3506 + addServerLog('info', 'šŸ—‘ļø', `Purged ${purgeResult.deleted} cached build(s) from CDN${flavor ? ` (${flavor})` : ''}`); 3507 + return res.json({ 3508 + ok: true, 3509 + message: clearLocal 3510 + ? 'Manifest invalidated, local base cache cleared, and CDN build cache purged.' 3511 + : 'Manifest + CDN build cache purged.', 3512 + purged: purgeResult.deleted, 3513 + localCleared: localResult?.deleted || 0, 3514 + localDirs: localResult?.dirs || [], 3515 + }); 3516 + } 3517 + 3518 + if (clearLocal) { 3519 + return res.json({ 3520 + ok: true, 3521 + message: 'Manifest invalidated and local base-image cache cleared.', 3522 + localCleared: localResult.deleted, 3523 + localDirs: localResult.dirs, 3524 + }); 3525 + } 3526 + 3527 + res.json({ ok: true, message: 'Manifest cache invalidated — next build will re-fetch.' }); 3528 + }); 3529 + 3530 + // 404 handler 3531 + app.use((req, res) => { 3532 + res.status(404).json({ error: 'Not found' }); 3533 + }); 3534 + 3535 + // Error handler 3536 + app.use((err, req, res, next) => { 3537 + console.error('āŒ Server error:', err); 3538 + res.status(500).json({ error: 'Internal server error', message: err.message }); 3539 + }); 3540 + 3541 + // Create server and WebSocket 3542 + let server; 3543 + if (dev) { 3544 + // Load local SSL certs in development mode 3545 + const httpsOptions = { 3546 + key: fs.readFileSync('../ssl-dev/localhost-key.pem'), 3547 + cert: fs.readFileSync('../ssl-dev/localhost.pem'), 3548 + }; 3549 + 3550 + server = https.createServer(httpsOptions, app); 3551 + server.listen(PORT, () => { 3552 + console.log(`šŸ”„ Oven server running on https://localhost:${PORT} (dev mode)`); 3553 + }); 3554 + } else { 3555 + // Production - plain HTTP (Caddy handles SSL) 3556 + server = http.createServer(app); 3557 + server.listen(PORT, () => { 3558 + console.log(`šŸ”„ Oven server running on http://localhost:${PORT}`); 3559 + addServerLog('success', 'šŸ”„', `Oven server ready (v${GIT_VERSION.slice(0,8)})`); 3560 + 3561 + // Pre-warm Puppeteer browser so first keep thumbnail bake is fast 3562 + setTimeout(() => { 3563 + addServerLog('info', '🌐', 'Pre-warming grab browser...'); 3564 + prewarmGrabBrowser().then(() => { 3565 + addServerLog('success', '🌐', 'Browser pre-warm complete'); 3566 + }).catch(err => { 3567 + addServerLog('error', 'āš ļø', `Browser pre-warm failed: ${err.message}`); 3568 + }); 3569 + }, 5000); // Give server 5s to settle first 3570 + 3571 + // Start background OG image regeneration after a short delay 3572 + setTimeout(() => { 3573 + addServerLog('info', 'šŸ–¼ļø', 'Starting background OG regeneration...'); 3574 + regenerateOGImagesBackground().then(() => { 3575 + addServerLog('success', 'šŸ–¼ļø', 'OG images ready for social sharing'); 3576 + }).catch(err => { 3577 + addServerLog('error', 'āŒ', `OG regen failed: ${err.message}`); 3578 + }); 3579 + }, 10000); // Wait 10s for server to fully initialize 3580 + 3581 + // Schedule periodic regeneration (every 6 hours) 3582 + setInterval(() => { 3583 + addServerLog('info', 'šŸ–¼ļø', 'Scheduled OG regeneration starting...'); 3584 + regenerateOGImagesBackground().catch(err => { 3585 + addServerLog('error', 'āŒ', `Scheduled OG regen failed: ${err.message}`); 3586 + }); 3587 + }, 6 * 60 * 60 * 1000); // 6 hours 3588 + 3589 + // Start native OTA git poller — watches for fedac/native/ changes 3590 + startNativeGitPoller({ startNativeBuild, addServerLog }); 3591 + 3592 + // Start papers PDF git poller — watches for papers/ changes 3593 + startPapersGitPoller({ startPapersBuild, addServerLog }); 3594 + }); 3595 + } 3596 + 3597 + // WebSocket server 3598 + wss = new WebSocketServer({ server, path: '/ws' }); 3599 + 3600 + // Wire up grabber notifications to broadcast to all WebSocket clients 3601 + setNotifyCallback(() => { 3602 + wss.clients.forEach((client) => { 3603 + if (client.readyState === 1) { // OPEN 3604 + client.send(JSON.stringify({ 3605 + version: GIT_VERSION, 3606 + serverStartTime: SERVER_START_TIME, 3607 + uptime: Date.now() - SERVER_START_TIME, 3608 + incoming: Array.from(getIncomingBakes().values()), 3609 + active: Array.from(getActiveBakes().values()), 3610 + recent: getRecentBakes(), 3611 + grabs: { 3612 + active: getActiveGrabs(), 3613 + recent: getRecentGrabs(), 3614 + queue: getQueueStatus(), 3615 + ipfsThumbs: getAllLatestIPFSUploads() 3616 + }, 3617 + grabProgress: getAllProgress(), 3618 + concurrency: getConcurrencyStatus(), 3619 + osBaseBuilds: getOSBaseBuildsSummary(), 3620 + })); 3621 + } 3622 + }); 3623 + }); 3624 + 3625 + // Wire up grabber log messages to broadcast to clients 3626 + setLogCallback((type, icon, msg) => { 3627 + addServerLog(type, icon, msg); 3628 + }); 3629 + 3630 + wss.on('connection', async (ws) => { 3631 + console.log('šŸ“” WebSocket client connected'); 3632 + addServerLog('info', 'šŸ“”', 'Dashboard client connected'); 3633 + 3634 + // Clean up stale bakes before sending initial state 3635 + await cleanupStaleBakes(); 3636 + 3637 + // Send initial state with recent logs 3638 + ws.send(JSON.stringify({ 3639 + version: GIT_VERSION, 3640 + serverStartTime: SERVER_START_TIME, 3641 + uptime: Date.now() - SERVER_START_TIME, 3642 + incoming: Array.from(getIncomingBakes().values()), 3643 + active: Array.from(getActiveBakes().values()), 3644 + recent: getRecentBakes(), 3645 + grabs: { 3646 + active: getActiveGrabs(), 3647 + recent: getRecentGrabs(), 3648 + queue: getQueueStatus(), 3649 + ipfsThumbs: getAllLatestIPFSUploads() 3650 + }, 3651 + grabProgress: getAllProgress(), 3652 + concurrency: getConcurrencyStatus(), 3653 + osBaseBuilds: getOSBaseBuildsSummary(), 3654 + frozen: getFrozenPieces(), 3655 + recentLogs: activityLogBuffer.slice(0, 50) // Send last 50 log entries 3656 + })); 3657 + 3658 + // Subscribe to updates 3659 + const unsubscribe = subscribeToUpdates((update) => { 3660 + if (ws.readyState === 1) { // OPEN 3661 + ws.send(JSON.stringify({ 3662 + version: GIT_VERSION, 3663 + serverStartTime: SERVER_START_TIME, 3664 + uptime: Date.now() - SERVER_START_TIME, 3665 + incoming: Array.from(getIncomingBakes().values()), 3666 + active: Array.from(getActiveBakes().values()), 3667 + recent: getRecentBakes(), 3668 + grabs: { 3669 + active: getActiveGrabs(), 3670 + recent: getRecentGrabs(), 3671 + queue: getQueueStatus(), 3672 + ipfsThumbs: getAllLatestIPFSUploads() 3673 + }, 3674 + grabProgress: getAllProgress(), 3675 + concurrency: getConcurrencyStatus(), 3676 + osBaseBuilds: getOSBaseBuildsSummary(), 3677 + frozen: getFrozenPieces() 3678 + })); 3679 + } 3680 + }); 3681 + 3682 + ws.on('close', () => { 3683 + console.log('šŸ“” WebSocket client disconnected'); 3684 + unsubscribe(); 3685 + }); 3686 + }); 3687 + 3688 + // Graceful shutdown handling 3689 + async function shutdown(signal) { 3690 + console.log(`\nšŸ›‘ Received ${signal}, shutting down gracefully...`); 3691 + 3692 + // Close WebSocket connections 3693 + wss.clients.forEach(ws => ws.close()); 3694 + 3695 + // Close HTTP server 3696 + server.close(() => { 3697 + console.log('āœ… HTTP server closed'); 3698 + }); 3699 + 3700 + // Close browser if open 3701 + try { 3702 + const { closeBrowser } = await import('./grabber.mjs'); 3703 + await closeBrowser?.(); 3704 + console.log('āœ… Browser closed'); 3705 + } catch (e) { 3706 + // Browser close is optional 3707 + } 3708 + 3709 + // Exit after a short delay 3710 + setTimeout(() => { 3711 + console.log('šŸ‘‹ Goodbye!'); 3712 + process.exit(0); 3713 + }, 500); 3714 + } 3715 + 3716 + process.on('SIGTERM', () => shutdown('SIGTERM')); 3717 + process.on('SIGINT', () => shutdown('SIGINT'));