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 = ``;
+
+ 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) => {
+
@@ -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) {