openvibers/openvibe.community Commits

Feed and wall: every OpenVibe site reads them (public-read CORS) (#313)

OP OpenVibers committed 4 days ago · e5c25a26e7 · 3 files, +35 −4

The menu's Feed and wall on every site call /api/v1/feed and /api/v1/pulse from the browser, but CORS answered only the
five origins in API_CORS_ORIGINS, so on openvibe.video and the rest the browser blocked the feed and the menu fell back
to its old sections. Their reads (GET, HEAD and their preflight) now answer any origin with no credentials mode (a
Bearer token at most, never cookies): the same public data as by URL. Writes keep the listed origins.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 1339806..5c4cbba 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -308,9 +308,11 @@ async function createApp(opts = {}) {
});
const cors = v1.cors(config.apiCorsOrigins);
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
-- app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers, limits }));
++ // The menu's wall and Feed on every OpenVibe site read these: public reads from any origin, writes from the listed ones.
++ const readCors = v1.publicReadCors(config.apiCorsOrigins);
++ app.use('/api/v1/pulse', readCors, createPulseApi({ pulse, viewers, limits }));
// The menu's Feed on every site: one stream from Pulse with the network's trending pictures and videos (pulse/feed.js).
-- app.use('/api/v1/feed', cors, require('./pulse/feed').createFeedApi({ pulse, viewers, limits, networkUrl: config.networkUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));
++ app.use('/api/v1/feed', readCors, require('./pulse/feed').createFeedApi({ pulse, viewers, limits, networkUrl: config.networkUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));
app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
ddiff --git a/server/http/v1.js b/server/http/v1.js
iindex 1982c53..a97182c 100644
---- a/server/http/v1.js
++++ b/server/http/v1.js
@@@ -81,6 +81,26 @@ function decodeCursor(cursor, arity) {
* CORS for the embeddable APIs: other OpenVibe sites call them from the browser with the
* visitor's Network JWT as a Bearer token (never with cookies — no credentials mode).
*/
++/**
++ * CORS for public reads every OpenVibe site makes from the menu (the Feed, the wall): any origin may GET them, with no
++ * credentials mode (a Bearer token at most, never cookies), so the answer is the same public data it would be by URL.
++ * The ~60 OpenVibe domains are not a list to keep in step here; writes keep cors(origins).
++ */
++function publicReadCors(origins) {
++ const strict = cors(origins);
++ return (req, res, next) => {
++ const read = req.method === 'GET' || req.method === 'HEAD' || (req.method === 'OPTIONS' && /^(GET|HEAD)$/i.test(String(req.get('access-control-request-method') || '')));
++ if (!read) return strict(req, res, next);
++ res.set('Access-Control-Allow-Origin', '*');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
++ res.set('Access-Control-Allow-Methods', 'GET, HEAD');
++ res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');
++ res.set('Access-Control-Max-Age', '600');
++ if (req.method === 'OPTIONS') return res.status(204).end();
++ return next();
++ };
++}
++
function cors(origins) {
const allowed = new Set(origins);
return (req, res, next) => {
@@@ -108,4 +128,4 @@ function isoTime(v) {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
--module.exports = { ApiError, fail, run, serviceCap, serviceAnyCap, jsonBody, encodeCursor, decodeCursor, cors, intIn, sqlTime, isoTime };
++module.exports = { ApiError, fail, run, serviceCap, serviceAnyCap, jsonBody, encodeCursor, decodeCursor, cors, publicReadCors, intIn, sqlTime, isoTime };
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex 866d1e3..f05f8d6 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -72,9 +72,18 @@ const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url
assert.deepStrictEqual(none.items, []);
});
-- await check('cross-site: answers any OpenVibe site (CORS)', async () => {
++ await check('cross-site: any OpenVibe site reads it from the browser (CORS, no credentials)', async () => {
const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });
assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'a site off the write list still reads it');
++ assert.ok(!r.headers.get('access-control-allow-credentials'), 'never with cookies');
++ const pre = await t.get('/api/v1/feed', { method: 'OPTIONS', headers: { origin: 'https://open.shoes', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
++ assert.strictEqual(pre.status, 204);
++ assert.match(pre.headers.get('access-control-allow-headers') || '', /Authorization/);
++ const wall = await t.get('/api/v1/pulse', { headers: { origin: 'https://openvibe.pics' } });
++ assert.strictEqual(wall.headers.get('access-control-allow-origin'), '*', 'the wall too');
++ const write = await t.get('/api/v1/pulse/items', { method: 'OPTIONS', headers: { origin: 'https://evil.example', 'access-control-request-method': 'POST' } });
++ assert.ok(!write.headers.get('access-control-allow-origin'), 'a write from elsewhere gets no CORS');
});
await done(t);