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