openvibers/openvibe.network Commits

Frame relays for the Feed composer's pictures and posts (#719)

OP OpenVibers committed 4 days ago · b3cd0b3473 · 3 files, +49 −2

* Frame relays for the Feed composer's pictures and posts

* Merge remote-tracking branch 'origin/main' into frame-posts

Changes

ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 0d9270ec..3213659c 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -465,7 +465,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
}
const qCursor = (v) => (/^[A-Za-z0-9._~=-]{1,300}$/.test(String(v || '')) ? String(v) : '');
const qLimit = (v, max) => String(Math.min(Math.max(parseInt(v, 10) || 20, 1), max));
-- router.options(['/feed', '/wall', '/status'], (req, res) => {
++ router.options(['/feed', '/wall', '/status', '/posts', '/attachments'], (req, res) => {
res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Max-Age': '600' });
res.status(204).end();
});
@@@ -488,6 +488,32 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
// signed-in person's follows (following=1 only with their Bearer token: Community answers 401 without one).
const qSite = (v) => (/^[a-z0-9][a-z0-9:-]{0,39}$/.test(String(v || '')) ? String(v) : '');
const qWords = (v) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
++ // POST /api/frame/attachments and POST /api/frame/posts — the Feed's composer with pictures (the owner, 11-09: "you
++ // can make posts and put images"): a picture goes to Community's status space (multipart `file`, one image, at most
++ // 8 MB, Community stores it in Media as the person's), then the post names it with its alt text. Same rules as
++ // /status: only with the person's Bearer token, never a cookie; Community's answer and status passed back as they are.
++ const sendOn = async (req, res, path, body, contentType) => {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', 'Content-Type': 'application/json; charset=utf-8' });
++ const auth = /^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '';
++ if (!auth) return res.status(401).send('{"error":"sign in to post"}');
++ try {
++ const r = await fetch(new URL(path, svcUrl('community', 'http://127.0.0.1:4200')).href, {
++ method: 'POST', headers: { Authorization: auth, 'Content-Type': contentType, Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(20000),
++ });
++ const text = await r.text();
++ return res.status(r.status).send(text.slice(0, 64 * 1024));
++ } catch { return res.status(502).send('{"error":"Community did not answer"}'); }
++ };
++ router.post('/attachments', express.raw({ type: (req) => /^multipart\/form-data; *boundary=/i.test(String(req.headers['content-type'] || '')), limit: '8500kb' }), (req, res) => {
++ if (!Buffer.isBuffer(req.body) || !req.body.length) {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store' });
++ return res.status(400).json({ error: 'Send one picture as multipart form data (the field "file")' });
++ }
++ return sendOn(req, res, '/api/v1/spaces/status/attachments', req.body, String(req.get('content-type')));
++ });
++ router.post('/posts', express.json({ limit: '8kb' }), (req, res) => sendOn(req, res, '/api/v1/posts', JSON.stringify(req.body || {}), 'application/json'));
++
router.get('/feed', (req, res) => relay(req, res, '/api/v1/feed', {
kind: FEED_KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '',
site: qSite(req.query.site), q: qWords(req.query.q),
ddiff --git a/test/frame-feed.test.js b/test/frame-feed.test.js
iindex cb582155..54fa51ba 100644
---- a/test/frame-feed.test.js
++++ b/test/frame-feed.test.js
@@@ -17,6 +17,12 @@ const { createFrameService } = require('../server/frame/service');
const u = new URL(req.url, 'http://x');
if (u.pathname === '/api/v1/feed') return res.end(JSON.stringify({ items: [{ id: 'p:1', kind: u.searchParams.get('kind') || 'post', url: 'https://openvibe.community/p/1', title: 'A post' }], next_cursor: 'c2' }));
if (u.pathname === '/api/v1/me/status' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { asked.push({ url: req.url, auth: req.headers.authorization || '', body }); res.statusCode = req.headers.authorization ? 201 : 401; res.end(JSON.stringify({ ok: true })); }); return undefined; }
++ if ((u.pathname === '/api/v1/spaces/status/attachments' || u.pathname === '/api/v1/posts') && req.method === 'POST') {
++ const chunks = []; req.on('data', (c) => chunks.push(c));
++ req.on('end', () => { const body = Buffer.concat(chunks); asked.push({ url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', type: req.headers['content-type'] || '', size: body.length, text: body.toString('utf8').slice(0, 400) });
++ res.statusCode = 201; res.end(u.pathname === '/api/v1/posts' ? '{"id":7,"post_id":9}' : '{"attachment":{"media_id":"med_01ABCDEFGHJKMNPQRSTVWXYZ01"}}'); });
++ return;
++ }
if (u.pathname === '/api/v1/pulse') return res.end(JSON.stringify({ items: [{ id: 1, title: 'ada went live' }], next_cursor: null, viewer: { can_post: !!req.headers.authorization } }));
res.statusCode = 404; return res.end('{}');
});
@@@ -71,6 +77,21 @@ const { createFrameService } = require('../server/frame/service');
const sent = asked.filter((x) => x.body)[0];
assert.strictEqual(sent.auth, 'Bearer abcdefghijklmnop.qrstu');
assert.deepStrictEqual(JSON.parse(sent.body), { text: 'hello' });
++ // The composer's pictures: one multipart picture to Community's status space, then the post that names it.
++ const form = new FormData(); form.append('file', new Blob([Buffer.from('\x89PNG fake')], { type: 'image/png' }), 'a.png');
++ const up = await fetch(`${base}/attachments`, { method: 'POST', headers: { Authorization: 'Bearer abcdefghijklmnop.qrstu', Cookie: 'ov_session=secret' }, body: form });
++ assert.strictEqual(up.status, 201, 'the picture is stored');
++ assert.strictEqual((await up.json()).attachment.media_id, 'med_01ABCDEFGHJKMNPQRSTVWXYZ01');
++ const upSent = asked.filter((x) => x.url === '/api/v1/spaces/status/attachments' && x.size != null).pop();
++ assert.ok(/^multipart\/form-data; *boundary=/i.test(upSent.type) && upSent.size > 0 && upSent.text.includes('PNG fake'), 'the picture goes on as it came: ' + JSON.stringify({ type: upSent.type, size: upSent.size, text: upSent.text.slice(0, 120) }));
++ assert.deepStrictEqual([upSent.auth, upSent.cookie], ['Bearer abcdefghijklmnop.qrstu', ''], 'the token goes along, the cookie never');
++ const posted = await fetch(`${base}/posts`, { method: 'POST', headers: { Authorization: 'Bearer abcdefghijklmnop.qrstu', 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'look', pictures: [{ media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ01', alt: 'A cat' }] }) });
++ assert.strictEqual(posted.status, 201);
++ assert.deepStrictEqual(JSON.parse(asked.filter((x) => x.url === '/api/v1/posts' && x.text != null).pop().text), { text: 'look', pictures: [{ media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ01', alt: 'A cat' }] });
++ assert.strictEqual((await fetch(`${base}/attachments`, { method: 'POST', body: form })).status, 401, 'no token, no picture');
++ assert.strictEqual((await fetch(`${base}/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).status, 401, 'no token, no post');
++ assert.strictEqual((await fetch(`${base}/attachments`, { method: 'POST', headers: { Authorization: 'Bearer abcdefghijklmnop.qrstu', 'Content-Type': 'image/png' }, body: 'x' })).status, 400, 'only multipart');
++
const anon = await fetch(`${base}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
assert.strictEqual(anon.status, 401, 'no token, no post');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex ace41228..e023fdc1 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -126,7 +126,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
-- 'server/frame/service.js fetch': [4, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only)'],
++ 'server/frame/service.js fetch': [5, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only)'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],