openvibers/openvibe.meme Commits

Make a meme from a Pics picture (#135)

OP OpenVibers committed 4 days ago · b08aa1a518 · 9 files, +161 −1

* Make a meme from a Pics picture

"Make a meme" on a Pics picture opens the Meme maker at /make?picture=<slug>. The
picture is read from the configured Pics origin (/api/v1/images/<slug> and its bytes)
with the visitor's own token, never a service one, then rendered as a top/bottom
caption PNG through the existing licensed template and bundled fonts.

- GET /api/v1/pics/<slug>/preview streams the selected picture to the signed-in page;
  POST /api/v1/pics/<slug>/render answers the rendered PNG as a download.
- The slug must match the Pics ID shape; the origin is fixed, redirects are refused and
  the picture is capped at 20 MB, so no supplied URL is ever fetched.
- OV_PICS_URL sets the Pics address; it defaults to https://openvibe.pics.

Checked: ov test (full), test/pics-page.test.js (page, preview, render, refusals),
test/pics-maker.test.js; node --check on every changed file; gitleaks.

* Pics maker: read only a picture, never another URL, and refuse cross-site writes

Changes

ddiff --git a/public/css/meme.css b/public/css/meme.css
iindex 5ead294..d541c43 100644
---- a/public/css/meme.css
++++ b/public/css/meme.css
@@@ -210,3 +210,5 @@ html[data-ov-motion=reduced] .mm-tree-lines path, html[data-ov-motion=reduced] .
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
++
++.mm-pics-source { max-width:100%; height:auto; display:block; margin-bottom:20px; }
ddiff --git a/server/app.js b/server/app.js
iindex 3b99596..763b1d2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -119,6 +119,7 @@ async function createApp(opts = {}) {
const ctx = {
config, s, keys, sso, principal, search, searchRelay, entries, upstreams, importer, reverifier, accountData, accountSend, log,
++ fetchImpl,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
media, trends, reddit, sourceRegistration,
redditReader, analyzer,
ddiff --git a/server/config.js b/server/config.js
iindex 74a0a2b..b98e8f2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -106,6 +106,12 @@ function load(env = process.env) {
contextLines: Math.max(1, int(env.MEME_JUDGE_CONTEXT_LINES, 6)),
},
++ // OpenVibe.Pics: a picture made into a meme. The public address of the Pics service; the picture itself
++ // is read with the visitor's own token, never with a service one.
++ pics: {
++ url: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
++ },
++
// OpenVibe.Media for a trend picture or a generated meme (object API v2, namespace `meme`). Off without
// the internal URL and Network client secret; tests inject a stand-in.
media: {
ddiff --git a/server/http/api.js b/server/http/api.js
iindex d390d55..ee4aadf 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -223,6 +223,24 @@ function createApi(ctx) {
// Render user-supplied lines as PNG with the same licensed template and bundled fonts as the maker.
// This path is deterministic and does not spend the AI research/generation budget.
++ const sameSiteWrite = (req, res, next) => sameOrigin(req, config.baseUrl) ? next() : problem(res, 403, 'meme.cross_site', 'A write must come from OpenVibe.Meme.');
++ r.get('/pics/:slug/preview', requirePerson, async (req, res) => {
++ try {
++ const picture = await require('../pics-maker').loadPicture(ctx, req, req.params.slug);
++ return res.set({ 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox" }).type(picture.mime_type).send(picture.buffer);
++ } catch (e) { return problem(res, e.status || 502, 'meme.picture_unavailable', 'This picture is unavailable.'); }
++ });
++ r.post('/pics/:slug/render', requirePerson, sameSiteWrite, express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
++ const checked = captionLines([String((req.body && req.body.top) || '').slice(0, 120), String((req.body && req.body.bottom) || '').slice(0, 120)]);
++ if (!checked.ok) return invalid(res, 'Write a caption of at most 120 characters per line.');
++ try {
++ const maker = require('../pics-maker');
++ const picture = await maker.loadPicture(ctx, req, req.params.slug);
++ const rendered = await maker.renderPicture(picture, checked.lines);
++ return res.set({ 'Cache-Control': 'private, no-store', 'Content-Disposition': 'attachment; filename="openvibe-meme.png"', 'X-Content-Type-Options': 'nosniff' }).type('png').send(rendered.png);
++ } catch (e) { return problem(res, e.status || 502, 'meme.picture_unavailable', 'This picture could not be rendered.'); }
++ });
++
r.post('/render', requirePerson, async (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const checked = captionLines(body.lines);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5c60abc..98bf756 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -197,7 +197,13 @@ function createPageRoutes(ctx) {
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
}, status);
}
-- r.get('/make', async (req, res) => renderMake(req, res, { values: makeValues(req.query) }));
++ r.get('/make', async (req, res) => {
++ if (!req.query.picture) return renderMake(req, res, { values: makeValues(req.query) });
++ const slug = String(req.query.picture);
++ if (!/^[2-9a-km-zA-HJ-NP-Z]{8}$/.test(slug)) return page(req, res, { title:'Not found', body:html`<h1>No such picture</h1>` },404);
++ if (!signedIn(req)) return page(req,res,{title:'Make a meme',body:signInPrompt('Sign in to open your Pics picture.', `/make?picture=${slug}`)},401);
++ return page(req,res,{title:'Make a meme from Pics',cache:'private, no-store',body:html`<section class="mm-wrap mm-narrow"><h1>Make a meme from your picture</h1><img src="/api/v1/pics/${slug}/preview" alt="Your selected picture" width="600" class="mm-pics-source"><form class="mm-form" method="post" action="/api/v1/pics/${slug}/render"><label>Top caption<input name="top" maxlength="120"></label><label>Bottom caption<input name="bottom" maxlength="120"></label><button class="mm-btn" type="submit">Download meme PNG</button></form><p>Your original stays unchanged. <a href="https://openvibe.pics/i/${slug}">Back to picture</a></p></section>`});
++ });
r.post('/make', async (req, res) => {
if (!signedIn(req)) return page(req, res, { title: 'Make a caption', body: raw(views.signIn({ title: 'Make a caption', text: 'A saved caption belongs to your OpenVibe account, so you can find it later.', next: '/make' })) }, 401);
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
ddiff --git a/server/pics-maker.js b/server/pics-maker.js
nnew file mode 100644
iindex 0000000..2f2c12e
---- /dev/null
++++ b/server/pics-maker.js
@@@ -0,0 +1,37 @@
++'use strict';
++const { renderMeme } = require('./render-png');
++const SLUG = /^[2-9a-km-zA-HJ-NP-Z]{8}$/;
++const MAX_BYTES = 20 * 1024 * 1024;
++/** Fixed Pics origin; credentials never follow a redirect or a user-supplied URL. */
++async function loadPicture(ctx, req, slug) {
++ if (!SLUG.test(String(slug || ''))) throw Object.assign(new Error('No such picture'), { status: 404 });
++ const token = (req.viewer && req.viewer.token) || req.token || (ctx.sso && ctx.sso.client && ctx.sso.client.extractToken(req));
++ if (!token) throw Object.assign(new Error('Sign in to use this picture'), { status: 401 });
++ const fetchImpl = ctx.fetchImpl || globalThis.fetch;
++ // The Pics address is configuration, and it is a fixed origin: no redirect is followed, no credential is sent
++ // anywhere else.
++ const base = String((ctx.config && ctx.config.pics && ctx.config.pics.url) || 'https://openvibe.pics').replace(/\/+$/, '');
++ const get = (suffix) => fetchImpl(`${base}/api/v1/images/${slug}${suffix}`, { redirect: 'error', headers: { authorization: 'Bearer ' + token }, signal: AbortSignal.timeout(10000) });
++ const metadata = await get('');
++ if (!metadata.ok) throw Object.assign(new Error('Picture unavailable'), { status: metadata.status === 404 ? 404 : 502 });
++ const picture = await metadata.json();
++ if (!picture || !picture.width || !picture.height || !/^image\//.test(String(picture.mime_type || ''))) throw Object.assign(new Error('Not a picture'), { status: 415 });
++ if (Number(picture.bytes) > MAX_BYTES) throw Object.assign(new Error('Picture too large'), { status: 413 });
++ const res = await get('/bytes');
++ if (!res.ok) throw Object.assign(new Error('Picture unavailable'), { status: res.status === 404 ? 404 : 502 });
++ const chunks = [];
++ let size = 0;
++ for await (const chunk of res.body) {
++ size += chunk.length;
++ if (size > MAX_BYTES) throw Object.assign(new Error('Picture too large'), { status: 413 });
++ chunks.push(chunk);
++ }
++ return { ...picture, buffer: Buffer.concat(chunks) };
++}
++async function renderPicture(picture, lines) {
++ const w = 1000;
++ const h = Math.max(300, Math.min(2000, Math.round(w * picture.height / picture.width)));
++ const box = (y) => ({ x: 30, y, w: 940, h: Math.floor(h * .23), size: 70, color: '#ffffff', outline: '#000000', outline_width: 4, align: 'center', font: 'DejaVuSans-Bold' });
++ return renderMeme({ template: { image_buffer: picture.buffer, layout: { width: w, height: h, boxes: [box(20), box(Math.floor(h * .74))] } }, lines });
++}
++module.exports = { loadPicture, renderPicture, SLUG };
ddiff --git a/server/render-png.js b/server/render-png.js
iindex 1583bbe..e2aa1b0 100644
---- a/server/render-png.js
++++ b/server/render-png.js
@@@ -108,6 +108,7 @@ function textSvg(layout, lines) {
}
async function backgroundOf(template, { fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
++ if (Buffer.isBuffer(template.image_buffer)) return template.image_buffer;
const url = String(template.image_url || '');
if (url.startsWith('/img/')) return fs.readFileSync(path.join(PUBLIC_DIR, url.replace(/^\/+/, '')));
if (/^https?:\/\//i.test(url)) {
ddiff --git a/test/pics-maker.test.js b/test/pics-maker.test.js
nnew file mode 100644
iindex 0000000..61c24fc
---- /dev/null
++++ b/test/pics-maker.test.js
@@@ -0,0 +1,11 @@
++'use strict';
++const assert = require('assert');
++const sharp = require('sharp');
++const { loadPicture, renderPicture } = require('../server/pics-maker');
++(async()=>{
++ const bytes=await sharp({create:{width:200,height:100,channels:3,background:'#357'}}).png().toBuffer();
++ const calls=[];const ctx={sso:{client:{extractToken:()=> 'test-person'}},fetchImpl:async(url,o)=>{calls.push([url,o]);return url.endsWith('/bytes')?new Response(bytes):new Response(JSON.stringify({bytes:bytes.length,width:200,height:100,mime_type:'image/png'}));}};
++ const picture=await loadPicture(ctx,{},'Abcdef23');assert.equal(calls.length,2);assert.equal(calls[0][1].redirect,'error');
++ const rendered=await renderPicture(picture,['Hello','World']);assert.equal((await sharp(rendered.png).metadata()).format,'png');
++ await assert.rejects(loadPicture(ctx,{},'https://evil.test'));process.stdout.write('Pics maker validates source and renders a PNG\n');
++})().catch(e=>{process.stderr.write(e.stack+'\n');process.exit(1);});
ddiff --git a/test/pics-page.test.js b/test/pics-page.test.js
nnew file mode 100644
iindex 0000000..dfb0186
---- /dev/null
++++ b/test/pics-page.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * A Pics picture opened in the Meme maker: the page, the preview and the rendered PNG. Meme reads the picture
++ * from the configured Pics origin with the visitor's own token, so a stand-in server on loopback plays Pics.
++ */
++const assert = require('assert');
++const http = require('http');
++const sharp = require('sharp');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const png = await sharp({ create: { width: 200, height: 100, channels: 3, background: '#357' } }).png().toBuffer();
++ const calls = [];
++ const pics = await new Promise((resolve) => {
++ const s = http.createServer((req, res) => {
++ calls.push({ url: req.url, authorization: req.headers.authorization || '' });
++ if (req.url === '/api/v1/images/Abcdef23') {
++ res.setHeader('content-type', 'application/json');
++ return res.end(JSON.stringify({ id: 'pic_01ARZ3NDEKTSV4RRFFQ69G5FAV', slug: 'Abcdef23', width: 200, height: 100, bytes: png.length, mime_type: 'image/png', listed: true, state: 'active' }));
++ }
++ if (req.url === '/api/v1/images/Abcdef23/bytes') {
++ res.setHeader('content-type', 'image/png');
++ return res.end(png);
++ }
++ res.statusCode = 404;
++ return res.end('{}');
++ });
++ s.listen(0, '127.0.0.1', () => resolve(s));
++ });
++ const t = await boot({ env: { OV_PICS_URL: `http://127.0.0.1:${pics.address().port}` } });
++ const alice = t.network.addUser('alice');
++
++ try {
++ await check('the maker opens only for a signed-in person', async () => {
++ const anon = await t.get('/make?picture=Abcdef23');
++ assert.strictEqual(anon.status, 401);
++ assert.ok(anon.text.includes('/auth/login?next='), 'the page offers the sign-in');
++ const page = await t.get('/make?picture=Abcdef23', { as: alice });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
++ assert.ok(page.text.includes('src="/api/v1/pics/Abcdef23/preview"'), 'the picture is shown');
++ assert.ok(page.text.includes('action="/api/v1/pics/Abcdef23/render"'), 'the form posts to the renderer');
++ });
++
++ await check('an id that is not a picture is a plain 404', async () => {
++ assert.strictEqual((await t.get('/make?picture=https://evil.test', { as: alice })).status, 404);
++ assert.strictEqual((await t.get('/api/v1/pics/not-a-picture/preview', { as: alice })).status, 404);
++ });
++
++ await check('the preview is the picture, read with the visitor\'s token', async () => {
++ const r = await t.get('/api/v1/pics/Abcdef23/preview', { as: alice });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.headers.get('content-type'), 'image/png');
++ assert.deepStrictEqual([...r.buffer.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
++ const metadata = calls.find((c) => c.url === '/api/v1/images/Abcdef23');
++ assert.ok(metadata.authorization.startsWith('Bearer '), 'Pics is asked with a person token');
++ });
++
++ await check('a write from another site is refused', async () => {
++ const cross = await t.get('/api/v1/pics/Abcdef23/render', { as: alice, origin: 'https://evil.test', form: { top: 'Hello' } });
++ assert.strictEqual(cross.status, 403);
++ assert.match(cross.text, /meme\.cross_site/);
++ });
++
++ await check('the renderer answers a PNG download and refuses an empty caption', async () => {
++ const made = await t.get('/api/v1/pics/Abcdef23/render', { as: alice, form: { top: 'Hello', bottom: 'World' } });
++ assert.strictEqual(made.status, 200, made.text.slice(0, 300));
++ assert.strictEqual(made.headers.get('content-type'), 'image/png');
++ assert.match(made.headers.get('content-disposition') || '', /attachment; filename="openvibe-meme.png"/);
++ assert.deepStrictEqual([...made.buffer.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
++ const empty = await t.get('/api/v1/pics/Abcdef23/render', { as: alice, form: { top: '', bottom: '' } });
++ assert.strictEqual(empty.status, 400);
++ });
++ } finally {
++ await t.close();
++ await new Promise((resolve) => pics.close(resolve));
++ }
++ done();
++})();