openvibers/openvibe.community Commits
Feed and wall: every OpenVibe site reads them (public-read CORS) (#313)
Changes
@@@ -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.@@@ -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 };@@@ -72,9 +72,18 @@ const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(urlassert.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);
@@ -308,9 +308,11 @@ async function createApp(opts = {}) {
@@ -308,9 +308,11 @@ async function createApp(opts = {}) {
});
});
const cors = v1.cors(config.apiCorsOrigins);
const cors = v1.cors(config.apiCorsOrigins);
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
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).
// 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/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 }));
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.
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
@@ -81,6 +81,26 @@ function decodeCursor(cursor, arity) {
@@ -81,6 +81,26 @@ function decodeCursor(cursor, arity) {
* CORS for the embeddable APIs: other OpenVibe sites call them from the browser with the
* 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).
* 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) {
function cors(origins) {
const allowed = new Set(origins);
const allowed = new Set(origins);
return (req, res, next) => {
return (req, res, next) => {
@@ -108,4 +128,4 @@ function isoTime(v) {
@@ -108,4 +128,4 @@ function isoTime(v) {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
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 };
@@ -72,9 +72,18 @@ const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url
@@ -72,9 +72,18 @@ const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url
assert.deepStrictEqual(none.items, []);
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' } });
const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });
assert.strictEqual(r.status, 200);
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);
await done(t);