diff --git a/oven/grabber.mjs b/oven/grabber.mjs index 2c294644df..97e4aa34b5 100644 --- a/oven/grabber.mjs +++ b/oven/grabber.mjs @@ -4715,4 +4715,130 @@ export async function getLatestNotepatOGUrl() { } } +// ============================================================================= +// NEWS OG IMAGE GENERATION +// ============================================================================= + +// News OG image cache (memory, keyed by post code) +const newsOGCache = new Map(); + +/** + * Generate a branded OG image for a news.aesthetic.computer article (1200x630 PNG). + * Shows title, author handle, and "Aesthetic News" branding. + */ +export async function generateNewsOGImage(post, forceRegenerate = false) { + const code = post.code; + console.log(`\n๐Ÿ“ฐ News OG Image Request: ${code} (force: ${forceRegenerate})`); + + // Check memory cache. + if (!forceRegenerate) { + const cached = newsOGCache.get(code); + if (cached && Date.now() < cached.expires) { + return { url: cached.url, cached: true }; + } + } + + // Check Spaces cache. + const key = `og/news/${code}.png`; + if (!forceRegenerate) { + try { + await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key })); + const url = `${SPACES_CDN_BASE}/${key}`; + newsOGCache.set(code, { url, expires: Date.now() + 60 * 60 * 1000 }); + return { url, cached: true }; + } catch { + // Not cached, generate below. + } + } + + const W = 1200; + const H = 630; + + // Escape XML entities for SVG. + const esc = (s) => + (s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + + const title = esc(post.title || "(untitled)"); + const handle = esc(post.handle || "@anon"); + const date = post.when ? new Date(post.when).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : ""; + + // Word-wrap title into lines that fit within the card. + const maxCharsPerLine = 32; + const words = title.split(/\s+/); + const lines = []; + let currentLine = ""; + for (const word of words) { + if (currentLine && (currentLine + " " + word).length > maxCharsPerLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = currentLine ? currentLine + " " + word : word; + } + } + if (currentLine) lines.push(currentLine); + // Limit to 5 lines max. + if (lines.length > 5) { + lines.length = 5; + lines[4] = lines[4].slice(0, maxCharsPerLine - 3) + "..."; + } + + const titleFontSize = lines.length <= 2 ? 52 : lines.length <= 3 ? 44 : 36; + const titleLineHeight = titleFontSize * 1.3; + const titleBlockHeight = lines.length * titleLineHeight; + const titleStartY = (H - titleBlockHeight) / 2 + titleFontSize * 0.8; + + const titleLines = lines + .map((line, i) => `${line}`) + .join("\n "); + + const svg = ` + + + + + + + + + + + + + + + AESTHETIC NEWS + + + ${titleLines} + + + ${handle}${date ? ` ยท ${esc(date)}` : ""} + news.aesthetic.computer +`; + + const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); + + // Upload to Spaces. + await spacesClient.send( + new PutObjectCommand({ + Bucket: SPACES_BUCKET, + Key: key, + Body: buffer, + ContentType: "image/png", + ACL: "public-read", + CacheControl: "public, max-age=604800", + }), + ); + + const url = `${SPACES_CDN_BASE}/${key}`; + newsOGCache.set(code, { url, expires: Date.now() + 7 * 24 * 60 * 60 * 1000 }); + + console.log(`๐Ÿ“ค News OG image uploaded: ${url} (${(buffer.length / 1024).toFixed(1)} KB)`); + return { url, cached: false, buffer }; +} + export { IPFS_GATEWAY }; diff --git a/oven/server.mjs b/oven/server.mjs index 6918aa77c4..47da26aa8b 100644 --- a/oven/server.mjs +++ b/oven/server.mjs @@ -11,7 +11,7 @@ import { execSync } from 'child_process'; import { gunzipSync, gzipSync } from 'node:zlib'; import { WebSocketServer } from 'ws'; import { healthHandler, bakeHandler, statusHandler, bakeCompleteHandler, bakeStatusHandler, getActiveBakes, getIncomingBakes, getRecentBakes, subscribeToUpdates, cleanupStaleBakes } from './baker.mjs'; -import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser } from './grabber.mjs'; +import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser, generateNewsOGImage } from './grabber.mjs'; import archiver from 'archiver'; import sharp from 'sharp'; import { createBundle, createJSPieceBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs'; @@ -1108,6 +1108,7 @@ app.get('/tools', (req, res) => {
/kidlisp-og/previewOG preview
/og-previewOG preview (alt)
/notepat-og.pngNotepat OG image
+
/news-og/:code.pngNews article OG image
/kidlisp-backdrop.webpKidLisp backdrop animation
/kidlisp-backdropKidLisp backdrop page
@@ -1889,6 +1890,75 @@ app.get('/notepat-og.png', async (req, res) => { } }); +// ============================================================================= +// News OG Image - Dynamic social cards for news.aesthetic.computer articles +// ============================================================================= + +app.get('/news-og/:code.png', async (req, res) => { + try { + const code = req.params.code; + const force = req.query.force === 'true'; + + addServerLog('info', '๐Ÿ“ฐ', `News OG request: ${code}${force ? ' (force)' : ''}`); + + // Fetch post from MongoDB. + const mongoUri = process.env.MONGODB_CONNECTION_STRING; + const dbName = process.env.MONGODB_NAME; + if (!mongoUri || !dbName) { + return res.status(500).json({ error: 'MongoDB not configured' }); + } + + const client = new MongoClient(mongoUri); + await client.connect(); + const db = client.db(dbName); + const post = await db.collection('news-posts').findOne({ code, status: { $ne: 'dead' } }); + + if (!post) { + await client.close(); + return res.status(404).json({ error: 'Post not found' }); + } + + // Hydrate handle. + if (post.user) { + const handleDoc = await db.collection('@handles').findOne({ _id: post.user }); + post.handle = handleDoc ? `@${handleDoc.handle}` : '@anon'; + } else { + post.handle = '@anon'; + } + await client.close(); + + const result = await generateNewsOGImage(post, force); + + if (result.cached && result.url) { + addServerLog('success', '๐Ÿ“ฆ', `News OG cache hit: ${code} โ†’ proxying`); + try { + const cdnResponse = await fetch(result.url); + if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`); + const buffer = Buffer.from(await cdnResponse.arrayBuffer()); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Content-Length', buffer.length); + res.setHeader('Cache-Control', 'public, max-age=604800'); + res.setHeader('X-Cache', 'HIT'); + return res.send(buffer); + } catch (fetchErr) { + addServerLog('warn', 'โš ๏ธ', `News OG proxy failed: ${fetchErr.message}`); + return res.redirect(301, result.url); + } + } + + addServerLog('success', '๐ŸŽจ', `News OG generated: ${code}`); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Content-Length', result.buffer.length); + res.setHeader('Cache-Control', 'public, max-age=604800'); + res.setHeader('X-Cache', 'MISS'); + res.send(result.buffer); + } catch (error) { + console.error('News OG error:', error); + addServerLog('error', 'โŒ', `News OG error: ${error.message}`); + res.status(500).json({ error: 'Failed to generate News OG image', message: error.message }); + } +}); + // ============================================================================= // KidLisp Backdrop - Animated WebP for login screens, Auth0, etc. // ============================================================================= diff --git a/system/netlify/functions/news.mjs b/system/netlify/functions/news.mjs index f52324f892..03756f700a 100644 --- a/system/netlify/functions/news.mjs +++ b/system/netlify/functions/news.mjs @@ -440,14 +440,26 @@ function isSubdomainRequest(event) { return host.startsWith("news.aesthetic.computer"); } -function layout({ title, body, assetBase, assetOrigin }) { +function layout({ title, body, assetBase, assetOrigin, og }) { const origin = assetOrigin || ""; + const ogTags = og ? ` + + + + + + + + + + + ` : ''; return ` - ${escapeHtml(title)} + ${escapeHtml(title)}${ogTags} @@ -840,7 +852,20 @@ async function renderItemPage(database, basePath, code) { ${footer()}`; - return { title: pageTitle, body }; + // Build og metadata for social cards. + const ogDescription = hydratedPost.text + ? hydratedPost.text.slice(0, 200).replace(/\n/g, " ") + (hydratedPost.text.length > 200 ? "..." : "") + : ""; + const ogImage = `https://oven.aesthetic.computer/news-og/${encodeURIComponent(code)}.png`; + const ogUrl = `https://news.aesthetic.computer/${code}`; + const og = { + title: hydratedPost.title || "(untitled)", + description: ogDescription, + image: ogImage, + url: ogUrl, + }; + + return { title: pageTitle, body, og }; } async function renderReportPage(basePath) { @@ -911,6 +936,7 @@ export function createHandler({ connect: connectFn = connect, respond: respondFn try { database = await connectFn(); let body; + let og; if (!route || route === "") { title = "Aesthetic News"; @@ -935,9 +961,10 @@ export function createHandler({ connect: connectFn = connect, respond: respondFn const result = await renderItemPage(database, basePath, route); body = result.body || result; title = result.title || "Aesthetic News"; + og = result.og; } - const html = layout({ title, body, assetBase, assetOrigin }); + const html = layout({ title, body, assetBase, assetOrigin, og }); await database.disconnect(); return respondFn(200, html, { "Content-Type": "text/html" }); } catch (error) {