openvibers/openvibe.studio Commits

Studio: open a hand-off media object in the editor (#28)

OP OpenVibers committed 3 days ago · 42ba3a6304 · 12 files, +432 −8

Adds GET /edit?media=<OpenVibe.Video id> and /edit?pics=<Pics slug>, both signed in. Studio asks the source with the person's Network token; a private object of someone else answers 404 like a missing id. Pictures open in Photo (or Light) with their source named and their bytes auto-opened; videos open in a plain editor shell with a Range preview and Publish on Video, which makes a new private copy on Video and leaves the original unchanged.

Changes

ddiff --git a/.env.example b/.env.example
iindex db021fe..a7d56bd 100644
---- a/.env.example
++++ b/.env.example
@@@ -12,4 +12,6 @@ STUDIO_BUNDLE_ROOT=/var/lib/openvibe-studio/apps
# Optional: links to the network's sister sites, and the cookie rule outside production.
# OV_DOWNLOAD_URL=https://openvibe.download
# OV_PICS_URL=https://openvibe.pics
++# Where "Edit in Studio" opens videos from (the hand-off source).
++# OV_VIDEO_URL=https://openvibe.video
# COOKIE_SECURE=true
ddiff --git a/README.md b/README.md
iindex 0f818fd..f8b4159 100644
---- a/README.md
++++ b/README.md
@@@ -57,6 +57,19 @@ per app keeps the same contract, so a base can be swapped:
- **PhotoCraft** has no scriptable API in the browser: a file is dropped on the app, and there is
no export the page can ask for. The app page says so.
++## Hand-off from Video and Cam
++
++`GET /edit?media=<OpenVibe.Video id>&from=video` and `GET /edit?pics=<Pics slug>&from=cam` are the signed-in
++hand-off into Studio. Studio asks the source service with the person's own Network token whether that person may
++read the object; a private object of someone else answers 404 exactly as a missing id does. A picture opens in
++Photo (or `app=light`) with its source named (`From OpenVibe.Cam`); the browser layer fetches the bytes through
++`/api/v1/studio/files/pics/:slug/bytes` and puts them into the running app. A video opens in a plain editor shell
++with its preview at `/api/v1/studio/media/:id/bytes`; when it came from OpenVibe.Video the page offers **Publish on
++Video**, which tells Video's `/api/v1/video/studio/publish` to make a new private copy (the original is unchanged).
++
++Set `OV_VIDEO_URL` when Video is not at `https://openvibe.video` (the drill). The Studio routes are documented in
++`server/pages.js` (`/edit`) and `server/api.js` (`/media/:id/bytes`, `/publish`).
++
## Honest limits
- Heavy first load: both apps are native editors compiled to WebAssembly.
ddiff --git a/public/js/edit-video.js b/public/js/edit-video.js
nnew file mode 100644
iindex 0000000..0d531f3
---- /dev/null
++++ b/public/js/edit-video.js
@@@ -0,0 +1,42 @@
++/* The Studio side of "Publish on Video": the person's edit goes to OpenVibe.Video, which makes
++ * the copy (the original is unchanged). The frame is plain: status beside the button. */
++(function () {
++ 'use strict';
++ var form = document.querySelector('form[data-studio-publish]');
++ if (!form) return;
++ var status = form.querySelector('[data-role="status"]');
++ var button = form.querySelector('button[type="submit"]');
++ function say(text) { if (status) status.textContent = text; }
++ form.addEventListener('submit', function (event) {
++ event.preventDefault();
++ if (!button || button.disabled) return;
++ button.disabled = true;
++ say('Publishing to OpenVibe.Video…');
++ fetch('/api/v1/studio/publish', {
++ method: 'POST',
++ credentials: 'same-origin',
++ headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({
++ media: form.getAttribute('data-media'),
++ title: form.elements.title ? form.elements.title.value : '',
++ visibility: form.elements.visibility ? form.elements.visibility.value : 'private'
++ })
++ }).then(function (res) {
++ return res.json().catch(function () { return {}; }).then(function (data) {
++ if (!res.ok) throw new Error(data.detail || data.title || 'OpenVibe.Video did not take the copy.');
++ say('Published. Your copy is processing on OpenVibe.Video.');
++ if (data.video && data.video.watch_url && status) {
++ var link = document.createElement('a');
++ link.href = data.video.watch_url;
++ link.rel = 'noopener';
++ link.textContent = 'Open your copy on Video';
++ status.appendChild(document.createTextNode(' '));
++ status.appendChild(link);
++ }
++ });
++ }).catch(function (err) {
++ say(err && err.message ? err.message : 'The copy was not published.');
++ button.disabled = false;
++ });
++ });
++})();
ddiff --git a/public/js/studio.js b/public/js/studio.js
iindex d0899ef..f1a03b3 100644
---- a/public/js/studio.js
++++ b/public/js/studio.js
@@@ -228,5 +228,17 @@
}
});
++ // A hand-off from Cam/Pics/Video: the page says which object to open; the bytes come through
++ // Studio's own API, which asks the source service whether this person may read it.
++ const openSource = root.getAttribute('data-studio-open-source');
++ const openId = root.getAttribute('data-studio-open-id');
++ const openName = root.getAttribute('data-studio-open-name');
++ if (openSource && openId) {
++ const start = () => openBytes(openSource, openId, openName || 'the file');
++ if (!iframe) start();
++ else if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') window.setTimeout(start, 1000);
++ else iframe.addEventListener('load', () => window.setTimeout(start, 1000), { once: true });
++ }
++
say(appId + ' is ready.');
})();
ddiff --git a/public/openapi.json b/public/openapi.json
iindex ffb54dc..b361dc3 100644
---- a/public/openapi.json
++++ b/public/openapi.json
@@@ -72,6 +72,67 @@
}
}
},
++ "/api/v1/studio/media/{id}/bytes": {
++ "get": {
++ "summary": "A video's bytes for the Studio editor preview. Video decides whether the signed-in person may read it; Range reads pass through.",
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "The video."
++ },
++ "206": {
++ "description": "The requested range."
++ },
++ "401": {
++ "description": "Sign in required."
++ },
++ "404": {
++ "description": "No such video, or it is not this person's to open."
++ }
++ }
++ }
++ },
++ "/api/v1/studio/publish": {
++ "post": {
++ "summary": "Publish an edit back on OpenVibe.Video as a new private copy (the original is unchanged).",
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "required": ["media"],
++ "properties": {
++ "media": { "type": "string", "description": "The OpenVibe.Video id (vup_, /watch?v= id or Media VOD id)." },
++ "title": { "type": "string", "maxLength": 120 },
++ "visibility": { "type": "string", "enum": ["private", "unlisted", "public"] }
++ }
++ }
++ }
++ }
++ },
++ "responses": {
++ "202": {
++ "description": "The copy is being published on Video."
++ },
++ "401": {
++ "description": "Sign in required."
++ },
++ "404": {
++ "description": "No such video, or it is not this person's to publish."
++ }
++ }
++ }
++ },
"/api/v1/studio/save": {
"post": {
"summary": "Save a file to the person's OpenVibe.Download drive.",
ddiff --git a/server/api.js b/server/api.js
iindex a6712bb..bc00bd1 100644
---- a/server/api.js
++++ b/server/api.js
@@@ -1,9 +1,10 @@
'use strict';
const express = require('express');
++const { Readable } = require('node:stream');
const { ADAPTERS } = require('./adapters');
/** The Studio API: what the apps are, what a person owns, and the way in and out. */
--function createApi({ sso, media, pics }) {
++function createApi({ config, sso, media, pics, video }) {
const r = express.Router();
const token = (req, res) => {
@@@ -65,6 +66,38 @@ function createApi({ sso, media, pics }) {
res.send(file.bytes);
}));
++ /** The video bytes a Studio editor previews: the person's token goes to Video, which decides access. */
++ r.get('/media/:id/bytes', wrap(async (req, res) => {
++ const value = token(req, res);
++ if (!value) return;
++ const out = await video.bytes(value, String(req.params.id || ''), req.get('range'));
++ res.status(out.status);
++ res.set('Cache-Control', 'private, no-store');
++ for (const name of ['content-type', 'content-range', 'content-length', 'accept-ranges']) {
++ const v = out.headers.get(name);
++ if (v) res.set(name, v);
++ }
++ Readable.fromWeb(out.body).pipe(res);
++ }));
++
++ /** The reverse link: publish an edit back on OpenVibe.Video (Video makes the copy). */
++ r.post('/publish', express.json({ limit: '16kb' }), wrap(async (req, res) => {
++ const value = token(req, res);
++ if (!value) return;
++ const bearer = /^Bearer /i.test(req.get('authorization') || '');
++ if (!bearer && req.get('origin') !== config.baseUrl && req.get('sec-fetch-site') !== 'same-origin') {
++ throw new StudioError(403, 'request.cross_site', 'Publish from Studio itself.');
++ }
++ const b = req.body || {};
++ if (typeof b.media !== 'string' || !/^[A-Za-z0-9_.:-]{1,80}$/.test(b.media)) {
++ throw new StudioError(422, 'studio.publish.invalid', 'Open a video with /edit?media=<id> first.');
++ }
++ if (b.title !== undefined && typeof b.title !== 'string') throw new StudioError(422, 'studio.publish.invalid', 'The title must be text.');
++ if (b.visibility !== undefined && !['private', 'unlisted', 'public'].includes(b.visibility)) throw new StudioError(422, 'studio.publish.invalid', 'Choose private, unlisted or public.');
++ const out = await video.publish(value, { media: b.media, title: b.title, visibility: b.visibility });
++ res.status(202).json(out);
++ }));
++
r.post('/save', express.raw({ type: '*/*', limit: '256mb' }), wrap(async (req, res) => {
const value = token(req, res);
if (!value) return;
ddiff --git a/server/app.js b/server/app.js
iindex 6f1230a..cbc709e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -8,6 +8,7 @@ const { createPages } = require('./pages');
const { createBundleFiles } = require('./bundle-files');
const { createMediaClient } = require('./openvibe/media');
const { createPicsClient } = require('./openvibe/pics');
++const { createVideoClient } = require('./openvibe/video');
const { APPS } = require('./product');
const seo = require('openvibe-shared/seo');
const agentDescriptor = require('./agent-descriptor');
@@@ -35,6 +36,7 @@ function createApp(opts = {}) {
});
const media = opts.media || createMediaClient({ baseUrl: config.downloadUrl, fetchImpl: opts.fetchImpl });
const pics = opts.pics || createPicsClient({ baseUrl: config.picsUrl, fetchImpl: opts.fetchImpl });
++ const video = opts.video || createVideoClient({ baseUrl: config.videoUrl, fetchImpl: opts.fetchImpl });
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
@@@ -75,14 +77,14 @@ function createApp(opts = {}) {
app.get('/.well-known/agent.json', (_req, res) => res.set('Cache-Control', 'public, max-age=3600').json(agentDescriptor));
app.get('/openapi.json', (_req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'openapi.json')));
app.use('/api/v1/studio', require('./image-tools').createImageTools({ config, sso, pics, fetchImpl: opts.fetchImpl }));
-- app.use('/api/v1/studio', createApi({ sso, media, pics }));
++ app.use('/api/v1/studio', createApi({ config, sso, media, pics, video }));
// GET /release.json (ADR-016): the navbar's release watch polls it so open tabs learn of a new release (the 11-09
// browser sweep found it answering 404 here).
require('openvibe-shared/release').createRelease({ service: 'studio', root: path.join(__dirname, '..') }).mount(app);
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/apps', createBundleFiles({ root: config.bundleRoot }));
app.use(express.static(path.join(__dirname, '..', 'public'), { index: false, maxAge: '1h' }));
-- app.use(createPages({ config, sso, media, pics }));
++ app.use(createPages({ config, sso, media, pics, video }));
app.use((err, _req, res, _next) => {
if (res.headersSent) return;
res.status(err.status || 500).type('text/plain').send('Studio could not complete that request.');
ddiff --git a/server/config.js b/server/config.js
iindex 3711ee8..a55e3df 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -17,6 +17,7 @@ function load(env = process.env) {
downloadUrl: trim(env.OV_DOWNLOAD_URL || 'https://openvibe.download'),
imageWorkerUrl: trim(env.STUDIO_IMAGE_WORKER_URL || ''),
picsUrl: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
++ videoUrl: trim(env.OV_VIDEO_URL || 'https://openvibe.video'),
clientId: env.OV_OAUTH_CLIENT_ID || 'studio',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
secureCookies: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : env.NODE_ENV === 'production',
ddiff --git a/server/openvibe/pics.js b/server/openvibe/pics.js
iindex 9b2bbb1..2983e5b 100644
---- a/server/openvibe/pics.js
++++ b/server/openvibe/pics.js
@@@ -36,6 +36,13 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
const images = Array.isArray(data.images) ? data.images : (Array.isArray(data) ? data : []);
return images.map(wire);
},
++ /** One picture's metadata as the person may read it (404 for someone else's private one). */
++ async getImage(token, slug) {
++ const data = await request('/images/' + encodeURIComponent(slug), token);
++ const image = wire(data.image || data);
++ if (!image.slug) image.slug = String(slug);
++ return image;
++ },
async bytes(slug, token) {
const data = await request('/images/' + encodeURIComponent(slug), token);
const image = wire(data.image || data);
ddiff --git a/server/openvibe/video.js b/server/openvibe/video.js
nnew file mode 100644
iindex 0000000..a1611fb
---- /dev/null
++++ b/server/openvibe/video.js
@@@ -0,0 +1,65 @@
++'use strict';
++const { StudioError } = require('./media');
++
++/**
++ * OpenVibe.Video: the other end of "Edit in Studio" (server/openvibe/video.js).
++ *
++ * A recording on openvibe.video is a Media VOD in Video's tenant, not an OpenVibe.Download
++ * drive file. Studio opens it by the id Video shows for it (the vup_… upload id, /watch?v=
++ * id or Media's VOD id) through Video's Studio hand-off API, with the signed-in person's
++ * own Network token as a Bearer. Video decides whether that person may read it (a private
++ * video of someone else answers 404, like a missing id) and returns the metadata, the bytes
++ * and whether the person may publish a copy back there.
++ *
++ * GET /api/v1/video/studio/media/:id { media, source }
++ * GET /api/v1/video/studio/media/:id/bytes the bytes (Range reads for the preview)
++ * POST /api/v1/video/studio/publish { media, title, visibility } → { video }
++ */
++function createVideoClient({ baseUrl, fetchImpl = globalThis.fetch }) {
++ const root = String(baseUrl || '').replace(/\/+$/, '');
++ const headers = (token, extra = {}) => Object.assign({ authorization: 'Bearer ' + token, accept: 'application/json' }, extra);
++
++ async function request(token, path, options = {}) {
++ const res = await fetchImpl(root + '/api/v1/video' + path, {
++ method: options.method || 'GET',
++ headers: headers(token, options.headers || {}),
++ ...(options.body !== undefined ? { body: options.body } : {}),
++ signal: AbortSignal.timeout(Number(options.timeoutMs) || 60_000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) {
++ throw new StudioError(res.status, data.code || 'video.upstream_error', data.detail || data.title || 'OpenVibe.Video returned ' + res.status);
++ }
++ return data;
++ }
++
++ return {
++ root,
++ async getMedia(token, id) {
++ return await request(token, '/studio/media/' + encodeURIComponent(id));
++ },
++ async bytes(token, id, range) {
++ const res = await fetchImpl(root + '/api/v1/video/studio/media/' + encodeURIComponent(id) + '/bytes', {
++ headers: headers(token, range ? { range: String(range) } : {}),
++ redirect: 'error',
++ signal: AbortSignal.timeout(2 * 3600_000),
++ });
++ if (!res.ok) {
++ const data = await res.json().catch(() => ({}));
++ throw new StudioError(res.status, data.code || 'video.bytes.unavailable', data.detail || data.title || 'That video is unavailable.');
++ }
++ return { status: res.status, headers: res.headers, body: res.body };
++ },
++ async publish(token, input) {
++ const data = await request(token, '/studio/publish', {
++ method: 'POST',
++ headers: { 'content-type': 'application/json' },
++ body: JSON.stringify(input || {}),
++ timeoutMs: 6 * 3600_000,
++ });
++ return data;
++ },
++ };
++}
++
++module.exports = { createVideoClient };
ddiff --git a/server/pages.js b/server/pages.js
iindex ebc6de3..2fb565a 100644
---- a/server/pages.js
++++ b/server/pages.js
@@@ -6,7 +6,7 @@ const { ADAPTERS } = require('./adapters');
const { hasBundle } = require('./bundles');
const { APPS } = require('./product');
--function createPages({ config, sso }) {
++function createPages({ config, sso, pics, video }) {
const r = express.Router();
const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };
@@@ -47,15 +47,24 @@ function createPages({ config, sso }) {
send(res, { title: 'Studio: edit photos in your browser', body: body, viewer: viewer, config: config, path: '/' });
});
-- const appPage = (adapter, viewer) => {
++ /**
++ * The app page, with a hand-off source when it was opened from another site. `open` is
++ * { source: 'pics', id, name, label, href } or null; the browser layer reads the data-*
++ * attributes and puts the bytes into the app after its frame has started.
++ */
++ const appPage = (adapter, viewer, open = null) => {
const installed = hasBundle(config.bundleRoot, adapter.id);
const src = '/apps/' + adapter.bundle.path + '/' + adapter.bundle.entry + (adapter.bundle.query || '');
const commands = adapter.commands.map((command) =>
'<button class="button-quiet" type="button" data-command="' + esc(command.id) + '" title="' + esc(command.description || command.title) + '">' + esc(command.title) + '</button>').join('');
const saveNote = adapter.capabilities.save ? '' :
'Save to OpenVibe is not available for this app: its browser build has no scriptable export, so export from the app and the file downloads to your browser.';
++ const sourceLine = open && open.label
++ ? '<p class="fine-print app-source">From ' + (open.href ? '<a href="' + esc(open.href) + '" rel="noopener">' + esc(open.label) + '</a>' : esc(open.label)) +
++ (open.name ? ' · ' + esc(open.name) : '') + '</p>'
++ : '';
const head = '<section class="app-head"><div>' +
-- '<h1>' + esc(adapter.title) + '</h1>' +
++ '<h1>' + esc(adapter.title) + '</h1>' + sourceLine +
'<p>Build ' + esc(adapter.base.tag) + ' of ' + esc(adapter.base.repository.replace('https://github.com/', '')) +
', built by ' + esc(adapter.base.builtBy) + '. Licence: ' + esc(adapter.base.licence) + '.</p>' +
'</div><div class="app-actions">' +
@@@ -75,10 +84,91 @@ function createPages({ config, sso }) {
: '<p class="app-missing">The ' + esc(adapter.title) + ' bundle is not installed on this server yet. ' +
'The deploy fetches it from the build artifacts by tag and checksum; a bundle is never kept in git.</p>';
const scripts = '<script src="/js/drivers.js"></script><script src="/js/studio.js"></script>';
++ const openAttrs = open && open.source
++ ? ' data-studio-open-source="' + esc(open.source) + '" data-studio-open-id="' + esc(open.id || '') +
++ '" data-studio-open-name="' + esc(open.name || '') + '"'
++ : '';
return '<section class="app-shell" data-studio-app="' + esc(adapter.id) + '" data-studio-driver="' + esc(adapter.driver) +
-- '" data-studio-signed-in="' + (viewer.kind === 'user' ? 'true' : 'false') + '">' + head + files + frame + '</section>' + scripts;
++ '" data-studio-signed-in="' + (viewer.kind === 'user' ? 'true' : 'false') + '"' + openAttrs + '>' + head + files + frame + '</section>' + scripts;
};
++ /** The Studio side of "Edit in Studio": a video from OpenVibe.Video, loaded in a plain editor shell. */
++ const videoEditor = (media, source) => {
++ const duration = Number(media.duration_seconds) > 0 ? Math.round(Number(media.duration_seconds)) + ' s' : 'a video';
++ const preview = '<div class="app-frame-wrap"><video class="app-frame" controls preload="metadata" playsinline src="/api/v1/studio/media/' +
++ encodeURIComponent(media.id) + '/bytes"></video></div>';
++ if (!media.can_edit) {
++ return '<section class="page-intro"><h1>Edit in Studio</h1><p class="fine-print app-source">From <a href="' + esc(source.url) +
++ '" rel="noopener">' + esc(source.name) + '</a> · ' + esc(media.title) + '</p></section>' +
++ '<p class="app-status">You can watch this video here, but only its owner can edit it. <a class="ovk-btn ovk-btn--quiet" href="' +
++ esc(source.url) + '" rel="noopener">Open on Video</a></p>' + preview;
++ }
++ const form = media.status === 'ready' ? '<form data-studio-publish data-media="' + esc(media.id) + '">' +
++ '<label>Title <input name="title" type="text" maxlength="120" required value="' + esc(media.title) + '"></label>' +
++ '<label>Who can see the copy <select name="visibility"><option value="private" selected>Only you</option><option value="unlisted">Only people with the link</option><option value="public">Anyone</option></select></label>' +
++ '<p class="fine-print">Publishing makes a new copy on OpenVibe.Video. The original stays as it is; the copy starts processing there.</p>' +
++ '<p><button class="ovk-btn ovk-btn--primary" type="submit">Publish on Video</button> ' +
++ '<a class="ovk-btn ovk-btn--quiet" href="' + esc(source.url) + '" rel="noopener">Open on Video</a></p>' +
++ '<p class="app-status" data-role="status" role="status" aria-live="polite"></p></form>'
++ : '<p class="app-status">The video is still processing on OpenVibe.Video. Reload this page when it is ready. <a href="' + esc(source.url) + '">Open on Video</a></p>';
++ return '<section class="page-intro"><h1>Edit in Studio</h1><p class="fine-print app-source">From <a href="' + esc(source.url) +
++ '" rel="noopener">' + esc(source.name) + '</a> · ' + esc(media.title) + ' · ' + esc(duration) + '</p></section>' +
++ '<section class="app-shell" data-studio-edit-video data-studio-media="' + esc(media.id) + '">' + preview + form +
++ '<script src="/js/edit-video.js" defer></script></section>';
++ };
++
++ const editFail = (res, viewer, status, title, detail) => send(res, {
++ title, viewer, config, path: '/edit',
++ body: '<section class="page-intro"><h1>' + esc(title) + '</h1><p>' + esc(detail) + '</p><p><a class="button-primary" href="/">Back to Studio</a></p></section>',
++ }, status);
++ const editSignIn = (res, req) => { res.redirect(302, '/auth/login?next=' + encodeURIComponent(req.originalUrl || '/edit')); };
++
++ /**
++ * GET /edit?media=<OpenVibe.Video id>&from=video a recording opened from Video
++ * GET /edit?pics=<Pics slug>&from=cam&app=photo|light a picture opened from Cam or Pics
++ *
++ * Both are signed-in hand-offs: Studio asks the source service, with the person's own
++ * Network token, whether that person may read the object. A private object of someone
++ * else answers 404 exactly as a missing id does. The route then loads it into the
++ * matching editor and says where it came from.
++ */
++ r.get('/edit', async (req, res) => {
++ const viewer = viewerOf(req);
++ const token = sso.extractToken(req);
++ if (!viewer || viewer.kind !== 'user' || !token) { editSignIn(res, req); return; }
++ const picsSlug = String(req.query.pics || '');
++ const mediaId = String(req.query.media || '');
++ const from = String(req.query.from || '').slice(0, 20);
++ if (picsSlug) {
++ if (!/^[2-9a-km-zA-HJ-NP-Z]{8}$/.test(picsSlug)) { editFail(res, viewer, 400, 'No picture to open', 'That picture address is not one Studio can open.'); return; }
++ try {
++ const image = await pics.getImage(token, picsSlug);
++ const appId = req.query.app === 'light' ? 'light' : 'photo';
++ const adapter = ADAPTERS.find((one) => one.id === appId) || ADAPTERS[0];
++ const label = from === 'cam' ? 'OpenVibe.Cam' : 'OpenVibe.Pics';
++ const href = from === 'cam' ? 'https://openvibe.cam' : (image.page || 'https://openvibe.pics');
++ send(res, { title: 'Edit ' + (image.name || 'a picture') + ' in Studio', viewer, config, path: '/edit',
++ body: appPage(adapter, viewer, { source: 'pics', id: picsSlug, name: image.name || picsSlug, label, href }) });
++ } catch (err) {
++ if (err && err.status === 404) editFail(res, viewer, 404, 'That picture is not yours to edit', 'It does not exist, or its owner has not shared it with you.');
++ else if (err && err.status === 401) editSignIn(res, req);
++ else editFail(res, viewer, 502, 'The picture could not be opened', 'OpenVibe.Pics did not answer just now. Try again in a minute.');
++ }
++ return;
++ }
++ if (!mediaId) { editFail(res, viewer, 400, 'Nothing to open', 'Open a video with /edit?media=<id> or a picture with /edit?pics=<slug>.'); return; }
++ if (!/^[A-Za-z0-9_.:-]{1,80}$/.test(mediaId)) { editFail(res, viewer, 400, 'No video to open', 'That video address is not one Studio can open.'); return; }
++ try {
++ const data = await video.getMedia(token, mediaId);
++ send(res, { title: 'Edit ' + (data.media.title || 'a video') + ' in Studio', viewer, config, path: '/edit',
++ body: videoEditor(data.media, data.source) });
++ } catch (err) {
++ if (err && err.status === 404) editFail(res, viewer, 404, 'That video is not yours to edit', 'It does not exist, or its owner has not shared it with you.');
++ else if (err && err.status === 401) editSignIn(res, req);
++ else editFail(res, viewer, 502, 'The video could not be opened', 'OpenVibe.Video did not answer just now. Try again in a minute.');
++ }
++ });
++
r.get('/:app(photo|light)', (req, res) => {
const adapter = ADAPTERS.find((one) => one.id === req.params.app);
if (!adapter) {
ddiff --git a/test/studio.test.js b/test/studio.test.js
iindex b4659e9..435d4ec 100644
---- a/test/studio.test.js
++++ b/test/studio.test.js
@@@ -10,7 +10,7 @@ const { createApp } = require('../server/app');
const config = {
port: 0, host: '127.0.0.1', baseUrl: 'http://studio.test', networkUrl: 'https://openvibe.network',
-- networkInternalUrl: 'http://127.0.0.1:4000', downloadUrl: 'https://openvibe.download', picsUrl: 'https://openvibe.pics',
++ networkInternalUrl: 'http://127.0.0.1:4000', downloadUrl: 'https://openvibe.download', picsUrl: 'https://openvibe.pics', videoUrl: 'https://openvibe.video',
clientId: 'studio', clientSecret: '', secureCookies: false, trustProxy: 2, drillPort: 15155, bundleRoot: '',
};
@@@ -39,9 +39,40 @@ const fakeMedia = {
const fakePics = {
listImages: async () => [{ id: 'img_a', slug: 'img_a', name: 'mountains.jpg' }],
++ getImage: async (_token, slug) => {
++ if (slug !== 'Abc23456') throw Object.assign(new Error('not found'), { status: 404, code: 'pics.not_found', detail: 'no such picture' });
++ return { id: 'Abc23456', slug: 'Abc23456', name: 'mountains.jpg', page: 'https://openvibe.pics/i/Abc23456' };
++ },
bytes: async () => ({ name: 'mountains.jpg', contentType: 'image/jpeg', bytes: Buffer.from([1, 2, 3]) }),
};
++const fakeVideo = {
++ calls: [],
++ getMedia: async (token, id) => {
++ fakeVideo.calls.push(['get', token, id]);
++ if (id !== 'vup_ok') throw Object.assign(new Error('not found'), { status: 404, code: 'studio.media_not_found', detail: 'no such video' });
++ return {
++ media: { id: 'vup_ok', kind: 'video', title: 'A recording', status: 'ready', can_edit: true, duration_seconds: 12,
++ watch_url: 'https://openvibe.video/watch?v=abcdefghijk' },
++ source: { id: 'video', name: 'OpenVibe.Video', url: 'https://openvibe.video/watch?v=abcdefghijk' },
++ };
++ },
++ bytes: async (token, id, range) => {
++ fakeVideo.calls.push(['bytes', token, id, range || null]);
++ if (id !== 'vup_ok') throw Object.assign(new Error('not found'), { status: 404, code: 'studio.media_not_found', detail: 'no such video' });
++ return {
++ status: 206,
++ headers: new Headers({ 'content-type': 'video/webm', 'content-range': 'bytes 0-3/4', 'content-length': '4', 'accept-ranges': 'bytes' }),
++ body: new Response('abcd').body,
++ };
++ },
++ publish: async (token, input) => {
++ fakeVideo.calls.push(['publish', token, input]);
++ if (input.media !== 'vup_ok') throw Object.assign(new Error('not found'), { status: 404, code: 'studio.media_not_found', detail: 'no such video' });
++ return { video: { id: 'vup_copy', status: 'processing', url: '/watch?v=copycopycop', watch_url: 'https://openvibe.video/watch?v=copycopycop' } };
++ },
++};
++
function bundleFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-bundles-'));
fs.mkdirSync(path.join(root, 'light'), { recursive: true });
@@@ -61,6 +92,7 @@ async function serve(opts = {}) {
sso: fakeSso(),
media: opts.media || fakeMedia,
pics: opts.pics || fakePics,
++ video: opts.video || fakeVideo,
});
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
@@@ -208,3 +240,67 @@ test('the app catalogue, the attribution page and the small endpoints answer', a
assert.match((await fetch(s.base + '/')).headers.get('content-security-policy') || '', /connect-src[^;]*https:\/\/openvibe\.events/);
assert.equal((await fetch(s.base + '/nope')).status, 404);
});
++
++
++test('Edit in Studio is a signed-in hand-off: the source decides who may read the object', async (t) => {
++ fakeVideo.calls.length = 0;
++ const s = await serve(); t.after(s.close);
++ const guest = await fetch(s.base + '/edit?media=vup_ok', { redirect: 'manual' });
++ assert.equal(guest.status, 302);
++ assert.match(guest.headers.get('location'), /^\/auth\/login\?next=/);
++ assert.equal((await fetch(s.base + '/edit?pics=img_a', { redirect: 'manual' })).status, 302);
++
++ const video = await fetch(s.base + '/edit?media=vup_ok&from=video', { headers: { cookie: 'ov_token=person-a' } });
++ assert.equal(video.status, 200);
++ const videoHtml = await video.text();
++ assert.match(videoHtml, /data-studio-edit-video/);
++ assert.match(videoHtml, /From <a href="https:\/\/openvibe\.video\/watch\?v=abcdefghijk"/);
++ assert.match(videoHtml, /OpenVibe\.Video/);
++ assert.match(videoHtml, /Publish on Video/);
++ assert.match(videoHtml, /\/api\/v1\/studio\/media\/vup_ok\/bytes/);
++ assert.deepEqual(fakeVideo.calls[0], ['get', 'person-a', 'vup_ok']);
++
++ // A guessed id answers 404 and never shows another person's title.
++ const hidden = await fetch(s.base + '/edit?media=vup_private', { headers: { cookie: 'ov_token=person-a' } });
++ assert.equal(hidden.status, 404);
++ assert.doesNotMatch(await hidden.text(), /A recording/);
++ assert.equal((await fetch(s.base + '/edit?media=vup_private', { headers: { cookie: 'ov_token=person-a' } })).status, 404);
++
++ const picture = await fetch(s.base + '/edit?pics=Abc23456&from=cam', { headers: { cookie: 'ov_token=person-a' } });
++ assert.equal(picture.status, 200);
++ const pictureHtml = await picture.text();
++ assert.match(pictureHtml, /data-studio-open-source="pics"/);
++ assert.match(pictureHtml, /data-studio-open-id="Abc23456"/);
++ assert.match(pictureHtml, /From <a href="https:\/\/openvibe\.cam"/);
++ assert.match(pictureHtml, /OpenVibe\.Cam/);
++ assert.equal((await fetch(s.base + '/edit?pics=Zyx98765&from=cam', { headers: { cookie: 'ov_token=person-a' } })).status, 404);
++});
++
++test('the video preview and Publish on Video use the person token, and refuse a guessed id', async (t) => {
++ fakeVideo.calls.length = 0;
++ const s = await serve(); t.after(s.close);
++ assert.equal((await fetch(s.base + '/api/v1/studio/media/vup_ok/bytes')).status, 401);
++ const bytes = await fetch(s.base + '/api/v1/studio/media/vup_ok/bytes', { headers: { cookie: 'ov_token=person-a', range: 'bytes=0-3' } });
++ assert.equal(bytes.status, 206);
++ assert.equal(bytes.headers.get('content-range'), 'bytes 0-3/4');
++ assert.equal(await bytes.text(), 'abcd');
++
++ assert.equal((await fetch(s.base + '/api/v1/studio/publish', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 401);
++ const publish = await fetch(s.base + '/api/v1/studio/publish', {
++ method: 'POST',
++ headers: { cookie: 'ov_token=person-a', 'content-type': 'application/json', origin: 'http://studio.test' },
++ body: JSON.stringify({ media: 'vup_ok', title: 'A copy', visibility: 'private' }),
++ });
++ const publishText = await publish.text();
++ assert.equal(publish.status, 202, publishText);
++ const out = JSON.parse(publishText);
++ assert.equal(out.video.watch_url, 'https://openvibe.video/watch?v=copycopycop');
++ assert.deepEqual(fakeVideo.calls.find((call) => call[0] === 'publish').slice(1), ['person-a', { media: 'vup_ok', title: 'A copy', visibility: 'private' }]);
++
++ const hidden = await fetch(s.base + '/api/v1/studio/publish', {
++ method: 'POST',
++ headers: { cookie: 'ov_token=person-a', 'content-type': 'application/json', origin: 'http://studio.test' },
++ body: JSON.stringify({ media: 'vup_private' }),
++ });
++ assert.equal(hidden.status, 404);
++});