openvibers/openvibe.meme Commits
Make a meme from a Pics picture (#135)
Changes
@@@ -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; }@@@ -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,@@@ -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: {@@@ -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);@@@ -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);nnew file mode 100644@@@ -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 };@@@ -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)) {nnew file mode 100644@@@ -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);});nnew file mode 100644@@@ -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();++})();
@@ -210,3 +210,5 @@ html[data-ov-motion=reduced] .mm-tree-lines path, html[data-ov-motion=reduced] .
@@ -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[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; }
.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; }
@@ -119,6 +119,7 @@ async function createApp(opts = {}) {
@@ -119,6 +119,7 @@ async function createApp(opts = {}) {
const ctx = {
const ctx = {
config, s, keys, sso, principal, search, searchRelay, entries, upstreams, importer, reverifier, accountData, accountSend, log,
config, s, keys, sso, principal, search, searchRelay, entries, upstreams, importer, reverifier, accountData, accountSend, log,
+ fetchImpl,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
media, trends, reddit, sourceRegistration,
media, trends, reddit, sourceRegistration,
redditReader, analyzer,
redditReader, analyzer,
@@ -106,6 +106,12 @@ function load(env = process.env) {
@@ -106,6 +106,12 @@ function load(env = process.env) {
contextLines: Math.max(1, int(env.MEME_JUDGE_CONTEXT_LINES, 6)),
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
// 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.
// the internal URL and Network client secret; tests inject a stand-in.
media: {
media: {
@@ -223,6 +223,24 @@ function createApi(ctx) {
@@ -223,6 +223,24 @@ function createApi(ctx) {
// Render user-supplied lines as PNG with the same licensed template and bundled fonts as the maker.
// 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.
// 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) => {
r.post('/render', requirePerson, async (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const body = req.body && typeof req.body === 'object' ? req.body : {};
const checked = captionLines(body.lines);
const checked = captionLines(body.lines);
@@ -197,7 +197,13 @@ function createPageRoutes(ctx) {
@@ -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 })),
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
}, status);
}, 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) => {
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 (!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);
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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 };
@@ -108,6 +108,7 @@ function textSvg(layout, lines) {
@@ -108,6 +108,7 @@ function textSvg(layout, lines) {
}
}
async function backgroundOf(template, { fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
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 || '');
const url = String(template.image_url || '');
if (url.startsWith('/img/')) return fs.readFileSync(path.join(PUBLIC_DIR, url.replace(/^\/+/, '')));
if (url.startsWith('/img/')) return fs.readFileSync(path.join(PUBLIC_DIR, url.replace(/^\/+/, '')));
if (/^https?:\/\//i.test(url)) {
if (/^https?:\/\//i.test(url)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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);});
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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();
+})();