openvibers/openvibe.studio Commits
Studio: open a hand-off media object in the editor (#28)
Changes
@@@ -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@@@ -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 isno 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.nnew file mode 100644@@@ -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;++ });++ });++})();@@@ -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.');})();@@@ -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.",@@@ -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;@@@ -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.');@@@ -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',@@@ -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);nnew file mode 100644@@@ -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 };@@@ -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) {@@@ -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', aassert.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);++});
@@ -12,4 +12,6 @@ STUDIO_BUNDLE_ROOT=/var/lib/openvibe-studio/apps
@@ -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.
# Optional: links to the network's sister sites, and the cookie rule outside production.
# OV_DOWNLOAD_URL=https://openvibe.download
# OV_DOWNLOAD_URL=https://openvibe.download
# OV_PICS_URL=https://openvibe.pics
# 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
# COOKIE_SECURE=true
@@ -57,6 +57,19 @@ per app keeps the same contract, so a base can be swapped:
@@ -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
- **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.
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
## Honest limits
- Heavy first load: both apps are native editors compiled to WebAssembly.
- Heavy first load: both apps are native editors compiled to WebAssembly.
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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;
+ });
+ });
+})();
@@ -228,5 +228,17 @@
@@ -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.');
say(appId + ' is ready.');
})();
})();
@@ -72,6 +72,67 @@
@@ -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": {
"/api/v1/studio/save": {
"post": {
"post": {
"summary": "Save a file to the person's OpenVibe.Download drive.",
"summary": "Save a file to the person's OpenVibe.Download drive.",
@@ -1,9 +1,10 @@
@@ -1,9 +1,10 @@
'use strict';
'use strict';
const express = require('express');
const express = require('express');
+const { Readable } = require('node:stream');
const { ADAPTERS } = require('./adapters');
const { ADAPTERS } = require('./adapters');
/** The Studio API: what the apps are, what a person owns, and the way in and out. */
/** 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 r = express.Router();
const token = (req, res) => {
const token = (req, res) => {
@@ -65,6 +66,38 @@ function createApi({ sso, media, pics }) {
@@ -65,6 +66,38 @@ function createApi({ sso, media, pics }) {
res.send(file.bytes);
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) => {
r.post('/save', express.raw({ type: '*/*', limit: '256mb' }), wrap(async (req, res) => {
const value = token(req, res);
const value = token(req, res);
if (!value) return;
if (!value) return;
@@ -8,6 +8,7 @@ const { createPages } = require('./pages');
@@ -8,6 +8,7 @@ const { createPages } = require('./pages');
const { createBundleFiles } = require('./bundle-files');
const { createBundleFiles } = require('./bundle-files');
const { createMediaClient } = require('./openvibe/media');
const { createMediaClient } = require('./openvibe/media');
const { createPicsClient } = require('./openvibe/pics');
const { createPicsClient } = require('./openvibe/pics');
+const { createVideoClient } = require('./openvibe/video');
const { APPS } = require('./product');
const { APPS } = require('./product');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const agentDescriptor = require('./agent-descriptor');
const agentDescriptor = require('./agent-descriptor');
@@ -35,6 +36,7 @@ function createApp(opts = {}) {
@@ -35,6 +36,7 @@ function createApp(opts = {}) {
});
});
const media = opts.media || createMediaClient({ baseUrl: config.downloadUrl, fetchImpl: opts.fetchImpl });
const media = opts.media || createMediaClient({ baseUrl: config.downloadUrl, fetchImpl: opts.fetchImpl });
const pics = opts.pics || createPicsClient({ baseUrl: config.picsUrl, 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -75,14 +77,14 @@ function createApp(opts = {}) {
@@ -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('/.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.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', 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
// 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).
// browser sweep found it answering 404 here).
require('openvibe-shared/release').createRelease({ service: 'studio', root: path.join(__dirname, '..') }).mount(app);
require('openvibe-shared/release').createRelease({ service: 'studio', root: path.join(__dirname, '..') }).mount(app);
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/apps', createBundleFiles({ root: config.bundleRoot }));
app.use('/apps', createBundleFiles({ root: config.bundleRoot }));
app.use(express.static(path.join(__dirname, '..', 'public'), { index: false, maxAge: '1h' }));
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) => {
app.use((err, _req, res, _next) => {
if (res.headersSent) return;
if (res.headersSent) return;
res.status(err.status || 500).type('text/plain').send('Studio could not complete that request.');
res.status(err.status || 500).type('text/plain').send('Studio could not complete that request.');
@@ -17,6 +17,7 @@ function load(env = process.env) {
@@ -17,6 +17,7 @@ function load(env = process.env) {
downloadUrl: trim(env.OV_DOWNLOAD_URL || 'https://openvibe.download'),
downloadUrl: trim(env.OV_DOWNLOAD_URL || 'https://openvibe.download'),
imageWorkerUrl: trim(env.STUDIO_IMAGE_WORKER_URL || ''),
imageWorkerUrl: trim(env.STUDIO_IMAGE_WORKER_URL || ''),
picsUrl: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
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',
clientId: env.OV_OAUTH_CLIENT_ID || 'studio',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
secureCookies: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : env.NODE_ENV === 'production',
secureCookies: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : env.NODE_ENV === 'production',
@@ -36,6 +36,13 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
@@ -36,6 +36,13 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
const images = Array.isArray(data.images) ? data.images : (Array.isArray(data) ? data : []);
const images = Array.isArray(data.images) ? data.images : (Array.isArray(data) ? data : []);
return images.map(wire);
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) {
async bytes(slug, token) {
const data = await request('/images/' + encodeURIComponent(slug), token);
const data = await request('/images/' + encodeURIComponent(slug), token);
const image = wire(data.image || data);
const image = wire(data.image || data);
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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 };
@@ -6,7 +6,7 @@ const { ADAPTERS } = require('./adapters');
@@ -6,7 +6,7 @@ const { ADAPTERS } = require('./adapters');
const { hasBundle } = require('./bundles');
const { hasBundle } = require('./bundles');
const { APPS } = require('./product');
const { APPS } = require('./product');
-function createPages({ config, sso }) {
+function createPages({ config, sso, pics, video }) {
const r = express.Router();
const r = express.Router();
const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };
const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };
@@ -47,15 +47,24 @@ function createPages({ config, sso }) {
@@ -47,15 +47,24 @@ function createPages({ config, sso }) {
send(res, { title: 'Studio: edit photos in your browser', body: body, viewer: viewer, config: config, path: '/' });
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 installed = hasBundle(config.bundleRoot, adapter.id);
const src = '/apps/' + adapter.bundle.path + '/' + adapter.bundle.entry + (adapter.bundle.query || '');
const src = '/apps/' + adapter.bundle.path + '/' + adapter.bundle.entry + (adapter.bundle.query || '');
const commands = adapter.commands.map((command) =>
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('');
'<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 ? '' :
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.';
'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>' +
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/', '')) +
'<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>' +
', built by ' + esc(adapter.base.builtBy) + '. Licence: ' + esc(adapter.base.licence) + '.</p>' +
'</div><div class="app-actions">' +
'</div><div class="app-actions">' +
@@ -75,10 +84,91 @@ function createPages({ config, sso }) {
@@ -75,10 +84,91 @@ function createPages({ config, sso }) {
: '<p class="app-missing">The ' + esc(adapter.title) + ' bundle is not installed on this server yet. ' +
: '<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>';
'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 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) +
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) => {
r.get('/:app(photo|light)', (req, res) => {
const adapter = ADAPTERS.find((one) => one.id === req.params.app);
const adapter = ADAPTERS.find((one) => one.id === req.params.app);
if (!adapter) {
if (!adapter) {
@@ -10,7 +10,7 @@ const { createApp } = require('../server/app');
@@ -10,7 +10,7 @@ const { createApp } = require('../server/app');
const config = {
const config = {
port: 0, host: '127.0.0.1', baseUrl: 'http://studio.test', networkUrl: 'https://openvibe.network',
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: '',
clientId: 'studio', clientSecret: '', secureCookies: false, trustProxy: 2, drillPort: 15155, bundleRoot: '',
};
};
@@ -39,9 +39,40 @@ const fakeMedia = {
@@ -39,9 +39,40 @@ const fakeMedia = {
const fakePics = {
const fakePics = {
listImages: async () => [{ id: 'img_a', slug: 'img_a', name: 'mountains.jpg' }],
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]) }),
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() {
function bundleFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-bundles-'));
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-bundles-'));
fs.mkdirSync(path.join(root, 'light'), { recursive: true });
fs.mkdirSync(path.join(root, 'light'), { recursive: true });
@@ -61,6 +92,7 @@ async function serve(opts = {}) {
@@ -61,6 +92,7 @@ async function serve(opts = {}) {
sso: fakeSso(),
sso: fakeSso(),
media: opts.media || fakeMedia,
media: opts.media || fakeMedia,
pics: opts.pics || fakePics,
pics: opts.pics || fakePics,
+ video: opts.video || fakeVideo,
});
});
const server = http.createServer(app);
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
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
@@ -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.match((await fetch(s.base + '/')).headers.get('content-security-policy') || '', /connect-src[^;]*https:\/\/openvibe\.events/);
assert.equal((await fetch(s.base + '/nope')).status, 404);
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);
+});