openvibers/openvibe.community Commits
Comments on a Pics picture, and pictures embedded in a paste (#316)
Changes
@@@ -536,3 +536,5 @@ button.rx:hover { border-color: var(--accent); }.paste-plain:focus-visible { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 2px; }.locked-file { margin: 0 0 10px; }.locked-file a { white-space: nowrap; }++++.md .pics-embed { display: block; max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }@@@ -58,6 +58,7 @@ const BROWSER_REF_TYPES = {wiki: ['page'],blog: ['post'],reviews: ['entity'],++ pics: ['picture'],};/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */const SIGNED_IN_ONLY = {@@@ -69,7 +70,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;const PAGE = 30;const REPLY_PAGE = 20;--function createCommentService({ db, network = null, limits = {}, canViewPaste = null } = {}) {++function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch } = {}) {const authors = createAuthors({ db, network });const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });@@@ -155,10 +156,21 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =return !(p.visibility === 'private' && !(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));}/** Whether the viewer may see the entity a thread belongs to (only Community's pastes can be checked here). */-- const entityVisible = async (v, t) => !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);++ async function publicPicture(id) {++ if (!/^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) return false;++ try {++ const response = await fetchImpl(`https://openvibe.pics/api/v1/pictures/${encodeURIComponent(id)}`, { redirect: 'error', signal: AbortSignal.timeout(3000), headers: { Accept: 'application/json' } });++ if (!response.ok) return false;++ const picture = await response.json();++ return picture.id === id && picture.listed === true && picture.state === 'active' && typeof picture.views === 'number';++ } catch { return false; }++ }++ const entityVisible = async (v, t) => t.ref_service === 'pics' && t.ref_type === 'picture' ? publicPicture(t.ref_id)++ : !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);/** Community's own entities must exist (and be visible to the viewer) before anyone opens a thread on them. */async function checkCommunityRef(ref, v) {++ if (ref.service === 'pics' && ref.type === 'picture' && !await publicPicture(ref.id)) fail(404, 'ref.not_found', 'No such public picture');if (ref.service !== 'community') return;if (ref.type === 'paste') {if (!await pasteVisibleTo(v, ref.id)) fail(404, 'ref.not_found', 'No such paste');@@@ -73,7 +73,7 @@ module.exports = {// Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)// with a Bearer Network JWT. No cookies cross origins.-- apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games')++ apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games,https://openvibe.pics').split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),// OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from@@@ -123,6 +123,9 @@ function inline(raw) {let s = codeSpans(String(raw), (code) => hold(`<code>${esc(code)}</code>`));// 2. everything else is escaped before any tag is writtens = esc(s);++ // Opt-in Pics embeds only: a fixed HTTPS origin and raster paths, no SVG or arbitrary trackers.++ s = s.replace(/!\[([^\]\n]{0,300})\]\((https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.(?:jpg|png|webp|gif|avif))\)/g,++ (_m, alt, url) => hold(`<img src="${url}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="pics-embed">`));// 3. [text](url) — only safe schemes; the rest stays as texts = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {if (!SAFE_URL.test(unescapeEntities(url))) return m;@@@ -8,7 +8,12 @@ const { renderMarkdown: md, markdownToText } = require('../server/render/markdowconst { check, done } = require('./helpers/app');(async () => {-- await check('the supported subset renders', () => {++ await check('Pics embeds accept only fixed raster URLs and escape alt text', () => {++ assert.ok(md('').includes('<img src="https://openvibe.pics/i/Abcdef23.png"'));++ for (const url of ['https://evil.test/i/Abcdef23.png','https://openvibe.pics/i/Abcdef23.svg','https://openvibe.pics.evil.test/i/Abcdef23.png']) assert.ok(!md(``).includes('<img'));++ assert.ok(!md('').includes('alt="" onerror'));++ });++ await check('the supported subset renders' , () => {assert.strictEqual(md('Hello **bold**, *em*, _em_, ~~gone~~ and `x < y`'), '<p>Hello <strong>bold</strong>, <em>em</em>, <em>em</em>, <del>gone</del> and <code>x < y</code></p>');assert.strictEqual(md('line one\nline two'), '<p>line one<br>\nline two</p>');assert.strictEqual(md('# Title\n## Sub'), '<h3>Title</h3>\n<h4>Sub</h4>', 'headings demoted so the page keeps one h1');nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/**++ * Comments on an OpenVibe.Pics picture, and a picture embedded in a paste.++ *++ * A picture's thread is opened by the picture's own page, so Community asks Pics whether the picture is public++ * right now: listed, active, and with public counts. Anything else fails closed (404, never a hint that the++ * picture exists). A paste may embed a picture from the fixed Pics origin, raster formats only.++ */++const assert = require('assert');++const { createCommentService } = require('../server/comments/service');++const { renderMarkdown } = require('../server/render/markdown');++++const PICTURE = 'pic_01ARZ3NDEKTSV4RRFFQ69G5FAV';++const listed = (over = {}) => ({ id: PICTURE, listed: true, state: 'active', views: 3, ...over });++++(async () => {++ const answers = [];++ let requests = 0;++ const service = createCommentService({ db: {}, fetchImpl: async () => { requests++; const body = answers.shift() || { ok: false }; return { ok: body.ok !== false, json: async () => body.body || body }; } });++++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: 'not-a-picture' } }),++ (e) => e.code === 'ref.not_found', 'an id that is not a picture is refused');++ assert.equal(requests, 0, 'and nothing was asked of Pics');++++ answers.push({ body: listed({ listed: false }) });++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),++ (e) => e.code === 'ref.not_found', 'an unlisted picture has no thread');++++ answers.push({ body: listed({ state: 'hidden' }) });++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),++ (e) => e.code === 'ref.not_found', 'a hidden picture has no thread');++++ answers.push({ body: listed({ views: undefined }) });++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),++ (e) => e.code === 'ref.not_found', 'a picture without public counts is not a public picture');++++ answers.push({ ok: false });++ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),++ (e) => e.code === 'ref.not_found', 'and Pics saying nothing fails closed');++++ // A paste may embed a picture: the fixed origin, raster formats only.++ const embed = renderMarkdown('');++ assert.ok(embed.includes('<img'), 'a Pics picture embeds as an image');++ assert.ok(embed.includes('https://openvibe.pics/i/Abcdef23.png'), 'and keeps its address');++ assert.ok(!renderMarkdown('').includes('<img'), 'no SVG');++ assert.ok(!renderMarkdown('').includes('<img'), 'no other origin');++ assert.ok(!renderMarkdown('').includes('<img'), 'no query strings');++ assert.ok(!renderMarkdown('').includes('<img'), 'no album paths');++++ process.stdout.write('Pics comments fail closed; fixed-origin raster embeds pass\n');++})().catch((e) => { process.stderr.write(e.stack + '\n'); process.exit(1); });@@@ -30,6 +30,7 @@ const INVENTORY = {'server/render/pages.js': [1, 'not a call: the home page shows a fetch() sample as text (never executed)'],'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],++ 'server/comments/service.js': [1, 'OpenVibe.Pics GET /api/v1/pictures/<id> at the fixed https://openvibe.pics origin, no redirect, to ask whether a picture is public; the id must match the picture-ID shape and is encoded'],'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
@@ -536,3 +536,5 @@ button.rx:hover { border-color: var(--accent); }
@@ -536,3 +536,5 @@ button.rx:hover { border-color: var(--accent); }
.paste-plain:focus-visible { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 2px; }
.paste-plain:focus-visible { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 2px; }
.locked-file { margin: 0 0 10px; }
.locked-file { margin: 0 0 10px; }
.locked-file a { white-space: nowrap; }
.locked-file a { white-space: nowrap; }
+
+.md .pics-embed { display: block; max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }
@@ -58,6 +58,7 @@ const BROWSER_REF_TYPES = {
@@ -58,6 +58,7 @@ const BROWSER_REF_TYPES = {
wiki: ['page'],
wiki: ['page'],
blog: ['post'],
blog: ['post'],
reviews: ['entity'],
reviews: ['entity'],
+ pics: ['picture'],
};
};
/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */
/** Threads that take comments from people only — no anonymous comments (Live's rule for VODs and clips). */
const SIGNED_IN_ONLY = {
const SIGNED_IN_ONLY = {
@@ -69,7 +70,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
@@ -69,7 +70,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const PAGE = 30;
const REPLY_PAGE = 20;
const REPLY_PAGE = 20;
-function createCommentService({ db, network = null, limits = {}, canViewPaste = null } = {}) {
+function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
@@ -155,10 +156,21 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -155,10 +156,21 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
return !(p.visibility === 'private' && !(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));
return !(p.visibility === 'private' && !(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));
}
}
/** Whether the viewer may see the entity a thread belongs to (only Community's pastes can be checked here). */
/** Whether the viewer may see the entity a thread belongs to (only Community's pastes can be checked here). */
- const entityVisible = async (v, t) => !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);
+ async function publicPicture(id) {
+ if (!/^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) return false;
+ try {
+ const response = await fetchImpl(`https://openvibe.pics/api/v1/pictures/${encodeURIComponent(id)}`, { redirect: 'error', signal: AbortSignal.timeout(3000), headers: { Accept: 'application/json' } });
+ if (!response.ok) return false;
+ const picture = await response.json();
+ return picture.id === id && picture.listed === true && picture.state === 'active' && typeof picture.views === 'number';
+ } catch { return false; }
+ }
+ const entityVisible = async (v, t) => t.ref_service === 'pics' && t.ref_type === 'picture' ? publicPicture(t.ref_id)
+ : !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);
/** Community's own entities must exist (and be visible to the viewer) before anyone opens a thread on them. */
/** Community's own entities must exist (and be visible to the viewer) before anyone opens a thread on them. */
async function checkCommunityRef(ref, v) {
async function checkCommunityRef(ref, v) {
+ if (ref.service === 'pics' && ref.type === 'picture' && !await publicPicture(ref.id)) fail(404, 'ref.not_found', 'No such public picture');
if (ref.service !== 'community') return;
if (ref.service !== 'community') return;
if (ref.type === 'paste') {
if (ref.type === 'paste') {
if (!await pasteVisibleTo(v, ref.id)) fail(404, 'ref.not_found', 'No such paste');
if (!await pasteVisibleTo(v, ref.id)) fail(404, 'ref.not_found', 'No such paste');
@@ -73,7 +73,7 @@ module.exports = {
@@ -73,7 +73,7 @@ module.exports = {
// Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)
// Browser origins allowed to call the embeddable APIs (/api/v1/comments, /api/v1/pulse)
// with a Bearer Network JWT. No cookies cross origins.
// with a Bearer Network JWT. No cookies cross origins.
- apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games')
+ apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games,https://openvibe.pics')
.split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),
.split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),
// OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from
// OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from
@@ -123,6 +123,9 @@ function inline(raw) {
@@ -123,6 +123,9 @@ function inline(raw) {
let s = codeSpans(String(raw), (code) => hold(`<code>${esc(code)}</code>`));
let s = codeSpans(String(raw), (code) => hold(`<code>${esc(code)}</code>`));
// 2. everything else is escaped before any tag is written
// 2. everything else is escaped before any tag is written
s = esc(s);
s = esc(s);
+ // Opt-in Pics embeds only: a fixed HTTPS origin and raster paths, no SVG or arbitrary trackers.
+ s = s.replace(/!\[([^\]\n]{0,300})\]\((https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.(?:jpg|png|webp|gif|avif))\)/g,
+ (_m, alt, url) => hold(`<img src="${url}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" class="pics-embed">`));
// 3. [text](url) — only safe schemes; the rest stays as text
// 3. [text](url) — only safe schemes; the rest stays as text
s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
if (!SAFE_URL.test(unescapeEntities(url))) return m;
if (!SAFE_URL.test(unescapeEntities(url))) return m;
@@ -8,7 +8,12 @@ const { renderMarkdown: md, markdownToText } = require('../server/render/markdow
@@ -8,7 +8,12 @@ const { renderMarkdown: md, markdownToText } = require('../server/render/markdow
const { check, done } = require('./helpers/app');
const { check, done } = require('./helpers/app');
(async () => {
(async () => {
- await check('the supported subset renders', () => {
+ await check('Pics embeds accept only fixed raster URLs and escape alt text', () => {
+ assert.ok(md('').includes('<img src="https://openvibe.pics/i/Abcdef23.png"'));
+ for (const url of ['https://evil.test/i/Abcdef23.png','https://openvibe.pics/i/Abcdef23.svg','https://openvibe.pics.evil.test/i/Abcdef23.png']) assert.ok(!md(``).includes('<img'));
+ assert.ok(!md('').includes('alt="" onerror'));
+ });
+ await check('the supported subset renders' , () => {
assert.strictEqual(md('Hello **bold**, *em*, _em_, ~~gone~~ and `x < y`'), '<p>Hello <strong>bold</strong>, <em>em</em>, <em>em</em>, <del>gone</del> and <code>x < y</code></p>');
assert.strictEqual(md('Hello **bold**, *em*, _em_, ~~gone~~ and `x < y`'), '<p>Hello <strong>bold</strong>, <em>em</em>, <em>em</em>, <del>gone</del> and <code>x < y</code></p>');
assert.strictEqual(md('line one\nline two'), '<p>line one<br>\nline two</p>');
assert.strictEqual(md('line one\nline two'), '<p>line one<br>\nline two</p>');
assert.strictEqual(md('# Title\n## Sub'), '<h3>Title</h3>\n<h4>Sub</h4>', 'headings demoted so the page keeps one h1');
assert.strictEqual(md('# Title\n## Sub'), '<h3>Title</h3>\n<h4>Sub</h4>', 'headings demoted so the page keeps one h1');
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/**
+ * Comments on an OpenVibe.Pics picture, and a picture embedded in a paste.
+ *
+ * A picture's thread is opened by the picture's own page, so Community asks Pics whether the picture is public
+ * right now: listed, active, and with public counts. Anything else fails closed (404, never a hint that the
+ * picture exists). A paste may embed a picture from the fixed Pics origin, raster formats only.
+ */
+const assert = require('assert');
+const { createCommentService } = require('../server/comments/service');
+const { renderMarkdown } = require('../server/render/markdown');
+
+const PICTURE = 'pic_01ARZ3NDEKTSV4RRFFQ69G5FAV';
+const listed = (over = {}) => ({ id: PICTURE, listed: true, state: 'active', views: 3, ...over });
+
+(async () => {
+ const answers = [];
+ let requests = 0;
+ const service = createCommentService({ db: {}, fetchImpl: async () => { requests++; const body = answers.shift() || { ok: false }; return { ok: body.ok !== false, json: async () => body.body || body }; } });
+
+ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: 'not-a-picture' } }),
+ (e) => e.code === 'ref.not_found', 'an id that is not a picture is refused');
+ assert.equal(requests, 0, 'and nothing was asked of Pics');
+
+ answers.push({ body: listed({ listed: false }) });
+ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
+ (e) => e.code === 'ref.not_found', 'an unlisted picture has no thread');
+
+ answers.push({ body: listed({ state: 'hidden' }) });
+ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
+ (e) => e.code === 'ref.not_found', 'a hidden picture has no thread');
+
+ answers.push({ body: listed({ views: undefined }) });
+ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
+ (e) => e.code === 'ref.not_found', 'a picture without public counts is not a public picture');
+
+ answers.push({ ok: false });
+ await assert.rejects(service.resolve({ kind: 'anonymous' }, { ref: { service: 'pics', type: 'picture', id: PICTURE } }),
+ (e) => e.code === 'ref.not_found', 'and Pics saying nothing fails closed');
+
+ // A paste may embed a picture: the fixed origin, raster formats only.
+ const embed = renderMarkdown('');
+ assert.ok(embed.includes('<img'), 'a Pics picture embeds as an image');
+ assert.ok(embed.includes('https://openvibe.pics/i/Abcdef23.png'), 'and keeps its address');
+ assert.ok(!renderMarkdown('').includes('<img'), 'no SVG');
+ assert.ok(!renderMarkdown('').includes('<img'), 'no other origin');
+ assert.ok(!renderMarkdown('').includes('<img'), 'no query strings');
+ assert.ok(!renderMarkdown('').includes('<img'), 'no album paths');
+
+ process.stdout.write('Pics comments fail closed; fixed-origin raster embeds pass\n');
+})().catch((e) => { process.stderr.write(e.stack + '\n'); process.exit(1); });
@@ -30,6 +30,7 @@ const INVENTORY = {
@@ -30,6 +30,7 @@ const INVENTORY = {
'server/render/pages.js': [1, 'not a call: the home page shows a fetch() sample as text (never executed)'],
'server/render/pages.js': [1, 'not a call: the home page shows a fetch() sample as text (never executed)'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
+ 'server/comments/service.js': [1, 'OpenVibe.Pics GET /api/v1/pictures/<id> at the fixed https://openvibe.pics origin, no redirect, to ask whether a picture is public; the id must match the picture-ID shape and is encoded'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],