diff --git a/CHANGELOG.md b/CHANGELOG.md index b191ceb..a3aafef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to The Meme Protocol are documented here. Versions follow Semantic Versioning. +## [Unreleased] + +### Fixed + +- Added `HEAD` support for public pages, discovery documents, assets, meme media, and health checks so social crawlers can validate card resources without receiving false `404` responses. +- Replaced the 1.6 MB social-preview PNG with a fingerprinted, metadata-stripped 226 KB JPEG for faster and more reliable link-card fetching across social platforms. + ## [1.1.0] - 2026-08-14 ### Added diff --git a/README.md b/README.md index 940d633..ab7be30 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Current release: `1.1.0`. The browser feed supports one-click image copy, downloads, shareable meme links, full-image viewing with live view/download counts, a fixed source footer, a two-build Homo Reseticus pet download chooser, and shareable lore at `/lore`. -Homepage and lore social shares use the versioned `1200x630` asset at `/assets/social-preview-v1.png`. Individual meme permalinks continue to use their own meme image. +Homepage and lore social shares use the fingerprinted, metadata-stripped `1200x630` JPEG at `/assets/social-preview-v2.78d008df9860.jpg`. Individual meme permalinks continue to use their own meme image. Browser tabs, bookmarks, Apple home-screen links, and installed web apps use the supplied favicon suite under `/assets/favicon/`; the legacy `/favicon.ico` URL remains available for automatic browser requests. diff --git a/public/assets/social-preview-v1.png b/public/assets/social-preview-v1.png deleted file mode 100644 index cfeb58a..0000000 Binary files a/public/assets/social-preview-v1.png and /dev/null differ diff --git a/public/assets/social-preview-v2.78d008df9860.jpg b/public/assets/social-preview-v2.78d008df9860.jpg new file mode 100644 index 0000000..881a02a Binary files /dev/null and b/public/assets/social-preview-v2.78d008df9860.jpg differ diff --git a/server.js b/server.js index 4638649..dba514a 100644 --- a/server.js +++ b/server.js @@ -67,8 +67,9 @@ const server = http.createServer(async (req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); withSecurityHeaders(res, { contentSecurityPolicy: !isDiscoveryRoute(req, url) }); const baseUrl = publicBaseUrl(req); + const isPublicRead = isReadRequest(req); - if (req.method === 'GET' && url.pathname === '/') { + if (isPublicRead && url.pathname === '/') { const nonce = crypto.randomBytes(16).toString('base64'); withSecurityHeaders(res, { scriptNonce: nonce }); return sendHtml(res, 200, renderIndex({ @@ -79,7 +80,7 @@ const server = http.createServer(async (req, res) => { })); } - if (req.method === 'GET' && (url.pathname === '/lore' || url.pathname === '/lore/')) { + if (isPublicRead && (url.pathname === '/lore' || url.pathname === '/lore/')) { const nonce = crypto.randomBytes(16).toString('base64'); withSecurityHeaders(res, { scriptNonce: nonce }); return sendHtml(res, 200, renderIndex({ @@ -92,7 +93,7 @@ const server = http.createServer(async (req, res) => { } const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/); - if (req.method === 'GET' && memePageMatch) { + if (isPublicRead && memePageMatch) { const meme = store.get(memePageMatch[1]); if (!meme || meme.status !== 'approved') { return sendNotFoundPage(res, { baseUrl, pathname: url.pathname }); @@ -108,37 +109,37 @@ const server = http.createServer(async (req, res) => { })); } - if (req.method === 'GET' && url.pathname === '/openapi.json') { + if (isPublicRead && url.pathname === '/openapi.json') { res.setHeader('Cache-Control', 'public, max-age=300'); return sendJson(res, 200, openApiSpecFor(baseUrl)); } - if (req.method === 'GET' && url.pathname === '/meme-api.skill.md') { + if (isPublicRead && url.pathname === '/meme-api.skill.md') { res.setHeader('Cache-Control', 'public, max-age=300'); return sendText(res, 200, renderMemeApiSkill(baseUrl), 'text/markdown; charset=utf-8'); } - if (req.method === 'GET' && url.pathname === '/robots.txt') { + if (isPublicRead && url.pathname === '/robots.txt') { return sendText(res, 200, robotsTxt(baseUrl)); } - if (req.method === 'GET' && url.pathname === '/llms.txt') { + if (isPublicRead && url.pathname === '/llms.txt') { return sendText(res, 200, llmsTxt(baseUrl)); } - if (req.method === 'GET' && url.pathname === '/site.webmanifest') { + if (isPublicRead && url.pathname === '/site.webmanifest') { return sendJson(res, 200, manifest(baseUrl)); } - if (req.method === 'GET' && url.pathname === '/favicon.ico') { + if (isPublicRead && url.pathname === '/favicon.ico') { return sendFile(res, './public/assets/favicon/favicon.ico'); } - if (req.method === 'GET' && url.pathname === '/feed.json') { + if (isPublicRead && url.pathname === '/feed.json') { return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes)); } - if (req.method === 'GET' && url.pathname === '/sitemap.xml') { + if (isPublicRead && url.pathname === '/sitemap.xml') { const body = sitemapXml(baseUrl, store.listForReview({ status: 'approved' }).memes); res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8', @@ -154,7 +155,7 @@ const server = http.createServer(async (req, res) => { return sendFile(res, './public/admin.html'); } - if (req.method === 'GET' && url.pathname.startsWith('/assets/')) { + if (isPublicRead && url.pathname.startsWith('/assets/')) { const isVersioned = url.searchParams.has('v') || FINGERPRINTED_ASSET.test(url.pathname); res.setHeader('Cache-Control', isVersioned ? IMMUTABLE_CACHE_CONTROL : REVALIDATING_CACHE_CONTROL); return sendFile(res, `./public${url.pathname}`); @@ -256,7 +257,7 @@ const server = http.createServer(async (req, res) => { } const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/); - if (req.method === 'GET' && mediaMatch) { + if (isPublicRead && mediaMatch) { checkReadLimit(req, res, mediaReadLimiter); const meme = store.get(mediaMatch[1]); if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found'); @@ -295,7 +296,7 @@ const server = http.createServer(async (req, res) => { return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true }); } - if (req.method === 'GET' && url.pathname === '/healthz') { + if (isPublicRead && url.pathname === '/healthz') { noIndex(res); return sendJson(res, 200, { ok: true }); } @@ -330,12 +331,12 @@ function positiveInt(value, fallback) { } function isDiscoveryRoute(req, url) { - if (req.method !== 'GET') return false; + if (!isReadRequest(req)) return false; return DISCOVERY_ROUTES.has(url.pathname); } function isBrowserPageRequest(req, url) { - if (req.method !== 'GET') return false; + if (!isReadRequest(req)) return false; return ![ '/api', '/assets', @@ -346,6 +347,10 @@ function isBrowserPageRequest(req, url) { ].some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)); } +function isReadRequest(req) { + return req.method === 'GET' || req.method === 'HEAD'; +} + function sendNotFoundPage(res, { baseUrl, pathname }) { noIndex(res); return sendHtml(res, 404, renderNotFound({ diff --git a/src/http.js b/src/http.js index f204c32..a8f15df 100644 --- a/src/http.js +++ b/src/http.js @@ -37,7 +37,7 @@ export function sendJson(res, statusCode, payload) { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': String(body.length) }); - res.end(body); + endBody(res, body); } export function sendText(res, statusCode, message, contentType = 'text/plain; charset=utf-8') { @@ -46,7 +46,7 @@ export function sendText(res, statusCode, message, contentType = 'text/plain; ch 'Content-Type': contentType, 'Content-Length': String(body.length) }); - res.end(body); + endBody(res, body); } export function sendHtml(res, statusCode, html) { @@ -55,7 +55,7 @@ export function sendHtml(res, statusCode, html) { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': String(body.length) }); - res.end(body); + endBody(res, body); } export function sendFile(res, filePath, options = {}) { @@ -75,8 +75,15 @@ export function sendFile(res, filePath, options = {}) { if (!res.getHeader('Content-Length')) { res.setHeader('Content-Length', String(stats.size)); } + if (res.req?.method === 'HEAD') { + return res.end(); + } fs.createReadStream(resolved) .on('error', () => sendText(res, 500, 'File read failed')) .pipe(res); }); } + +function endBody(res, body) { + return res.req?.method === 'HEAD' ? res.end() : res.end(body); +} diff --git a/src/seo.js b/src/seo.js index 1aaaf69..5aa86f7 100644 --- a/src/seo.js +++ b/src/seo.js @@ -1,7 +1,7 @@ const SITE_NAME = 'The Meme Protocol'; const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.'; const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree'; -const SOCIAL_IMAGE_PATH = '/assets/social-preview-v1.png'; +const SOCIAL_IMAGE_PATH = '/assets/social-preview-v2.78d008df9860.jpg'; const NOT_FOUND_IMAGE_PATH = '/assets/homo-reseticus-404-828.68be2119.webp'; const FAVICON_PATH = '/assets/favicon'; const FAVICON_VERSION = '20260814'; @@ -21,7 +21,7 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu const home = `${baseUrl}/`; const canonical = meme ? `${baseUrl}/meme/${meme.id}` : lore ? `${baseUrl}/lore` : home; const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}${SOCIAL_IMAGE_PATH}`; - const imageType = meme ? meme.mime : 'image/png'; + const imageType = meme ? meme.mime : 'image/jpeg'; const imageWidth = meme ? meme.width : 1200; const imageHeight = meme ? meme.height : 630; const imageAlt = meme ? `Meme ${shortId(meme.id)}` : `${SITE_NAME} — memes bring us together`; diff --git a/tests/seo.test.js b/tests/seo.test.js index 8c53398..c6aba9f 100644 --- a/tests/seo.test.js +++ b/tests/seo.test.js @@ -1,8 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; import { feedJson, manifest, renderIndex, sitemapXml } from '../src/seo.js'; +const socialPreviewPath = fileURLToPath(new URL('../public/assets/social-preview-v2.78d008df9860.jpg', import.meta.url)); + const id = 'a'.repeat(64); const meme = { id, @@ -17,6 +21,8 @@ const template = [ '', '', '', + '', + '', '' ].join(''); @@ -44,10 +50,24 @@ test('renders the homepage with the dedicated social preview image', () => { approvedCount: 12 }); - assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v1\.png"/); + assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/); + assert.match(html, /og:image:type" content="image\/jpeg"/); + assert.match(html, /twitter:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/); assert.doesNotMatch(html, /yoonect-logo\.png/); }); +test('keeps the social preview small and broadly compatible', async () => { + const metadata = await sharp(socialPreviewPath).metadata(); + const stats = fs.statSync(socialPreviewPath); + + assert.equal(metadata.format, 'jpeg'); + assert.deepEqual([metadata.width, metadata.height], [1200, 630]); + assert.equal(metadata.hasAlpha, false); + assert.equal(metadata.exif, undefined); + assert.equal(metadata.xmp, undefined); + assert.ok(stats.size < 300_000, `expected social preview under 300 KB, got ${stats.size}`); +}); + test('uses the supplied favicon suite in the page and web app manifest', () => { const index = fs.readFileSync(new URL('../public/index.html', import.meta.url), 'utf8'); const appManifest = manifest('https://memes.example'); @@ -76,7 +96,7 @@ test('renders a shareable lore page with text-file metadata', () => { assert.match(html, /