openvibers/openvibe.studio Commits

Image tools: an edited copy of a picture, never the original (#13)

OP OpenVibers committed 4 days ago · b2abac813c · 9 files, +93 −8

* Image tools: an edited copy of a picture, never the original

"Edit with AI" on a picture's page opens Studio's image tools: upscale, remove background
or restyle. The edit is made on a copy and saved back to Pics as a new picture, so the
original is never touched; the new picture starts unlisted, like every upload.

- The API reads the picture's bytes from Pics with the person's own token and saves the
  result back through the ordinary upload endpoint.
- The image worker is configured by STUDIO_IMAGE_WORKER_URL. Without one the API answers
  an explicit "not available" state and the page says so; no worker is called by default
  and no third-party account is needed.
- Writes need a signed-in person and come from Studio itself; the worker never sees a
  person's token, only the selected picture.

* Merge remote-tracking branch 'origin/main' into pics-studio-1109

Changes

ddiff --git a/README.md b/README.md
iindex 8476196..27d6f75 100644
---- a/README.md
++++ b/README.md
@@@ -74,3 +74,17 @@ attribution is in [ATTRIBUTION.md](ATTRIBUTION.md).
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
<!-- versions:end -->
++
++### Pics image tools
++
++`/image-tools?picture=<slug>` opens an edit of a Pics picture. The authenticated
++`POST /api/v1/studio/images/edit` API accepts `{picture, operation, prompt?}`;
++operation is `upscale`, `remove-background`, or `restyle`. It reads authorized Pics
++bytes and saves a separate private picture through Pics' re-encoding pipeline.
++
++Deployment requires `STUDIO_IMAGE_WORKER_URL`, an operator-controlled image worker
++with `POST /edit` accepting `{operation, prompt, image: {mime_type, base64}}` and
++returning JPEG, PNG or WebP bytes. No person token is sent to that worker; it receives
++only the selected picture. The worker is not supplied by this PR. Without it the
++capability endpoint reports unavailable, the UI disables submission and the API
++returns 503. Tests use an in-process fake; no model calls or spending occurred.
ddiff --git a/public/js/image-tools.js b/public/js/image-tools.js
nnew file mode 100644
iindex 0000000..b044776
---- /dev/null
++++ b/public/js/image-tools.js
@@@ -0,0 +1,8 @@
++(function () { 'use strict';
++var form = document.getElementById('image-tools'), status = document.getElementById('image-tools-status');
++fetch('/api/v1/studio/images/tools').then(function (r) { return r.json(); }).then(function (r) { if (!r.available) { status.textContent = 'Image tools are not available on this Studio yet. Your picture is unchanged.'; form.querySelector('button').disabled = true; } });
++form.addEventListener('submit', async function (event) { event.preventDefault(); var button = form.querySelector('button'); button.disabled = true; status.textContent = 'Making your edited copy…';
++try { var data = Object.fromEntries(new FormData(form)); var res = await fetch('/api/v1/studio/images/edit', { method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(data) }); var out = await res.json(); if (!res.ok) throw new Error(out.detail || 'The edit failed.');
++if (!out.slug || !/^[2-9a-km-zA-HJ-NP-Z]{8}$/.test(out.slug)) throw new Error('The result could not be opened.'); location.href = 'https://openvibe.pics/i/' + out.slug;
++} catch (e) { status.textContent = e.message; } finally { button.disabled = false; } });
++})();
ddiff --git a/server/api.js b/server/api.js
iindex 5411285..a6712bb 100644
---- a/server/api.js
++++ b/server/api.js
@@@ -57,7 +57,7 @@ function createApi({ sso, media, pics }) {
if (!value) return;
const source = req.params.source;
const id = req.params.id;
-- const file = source === 'pics' ? await pics.bytes(id) : await media.downloadBytes(value, id);
++ const file = source === 'pics' ? await pics.bytes(id, value) : await media.downloadBytes(value, id);
const name = String(file.name || 'file').replace(/[^A-Za-z0-9._-]+/g, '_');
res.set('Content-Type', file.contentType || 'application/octet-stream');
res.set('Content-Disposition', 'attachment; filename="' + name + '"');
ddiff --git a/server/app.js b/server/app.js
iindex bd8364a..6f1230a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -74,6 +74,7 @@ function createApp(opts = {}) {
// What openvibe.studio tells an agent about itself (SEO-2, openvibe-shared/agent-layer's shape).
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 }));
// 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).
ddiff --git a/server/config.js b/server/config.js
iindex 610443d..3711ee8 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -15,6 +15,7 @@ function load(env = process.env) {
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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'),
clientId: env.OV_OAUTH_CLIENT_ID || 'studio',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
ddiff --git a/server/image-tools.js b/server/image-tools.js
nnew file mode 100644
iindex 0000000..da4b435
---- /dev/null
++++ b/server/image-tools.js
@@@ -0,0 +1,32 @@
++'use strict';
++const express = require('express');
++const { StudioError } = require('./openvibe/media');
++const OPERATIONS = ['upscale', 'remove-background', 'restyle'];
++/** An explicitly configured image worker receives only the selected raster, never a person's token. */
++function createImageTools({ config, sso, pics, fetchImpl = globalThis.fetch }) {
++ const r = express.Router();
++ r.get('/images/tools', (_req, res) => res.json({ operations: OPERATIONS, available: Boolean(config.imageWorkerUrl) }));
++ r.post('/images/edit', express.json({ limit: '8kb' }), async (req, res, next) => {
++ try {
++ const token = sso.extractToken(req);
++ if (!token) throw new StudioError(401, 'studio.auth.required', 'Sign in to edit a picture.');
++ 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', 'Submit edits from Studio.');
++ const b = req.body || {};
++ if (!OPERATIONS.includes(b.operation) || !/^[2-9a-km-zA-HJ-NP-Z]{8}$/.test(String(b.picture || '')) || typeof (b.prompt || '') !== 'string' || String(b.prompt || '').length > 1000) throw new StudioError(422, 'studio.image.invalid', 'Choose a picture and an image operation.');
++ if (!config.imageWorkerUrl) throw new StudioError(503, 'studio.image.unavailable', 'Image tools are not configured on this Studio yet.');
++ const source = await pics.bytes(b.picture, token);
++ if (source.bytes.length > 20 * 1024 * 1024) throw new StudioError(413, 'studio.image.too_large', 'This picture is too large to edit.');
++ const response = await fetchImpl(config.imageWorkerUrl + '/edit', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(90000), headers: { 'content-type':'application/json' }, body: JSON.stringify({ operation:b.operation, prompt:b.prompt || '', image: { mime_type:source.contentType, base64:source.bytes.toString('base64') } }) });
++ if (!response.ok) throw new StudioError(502, 'studio.image.failed', 'The image worker could not make the edit.');
++ const type = response.headers.get('content-type') || '';
++ if (!/^image\/(png|jpeg|webp)$/.test(type)) throw new StudioError(502, 'studio.image.invalid_output', 'The worker did not return a supported picture.');
++ const chunks = []; let size = 0;
++ for await (const chunk of response.body) { size += chunk.length; if (size > 20 * 1024 * 1024) throw new StudioError(502, 'studio.image.too_large', 'The edited picture is too large.'); chunks.push(chunk); }
++ const saved = await pics.save(token, Buffer.concat(chunks), type, source.name + ' — ' + b.operation);
++ res.set('Cache-Control','private, no-store').status(201).json(saved);
++ } catch (error) { next(error); }
++ });
++ return r;
++}
++module.exports = { createImageTools, OPERATIONS };
ddiff --git a/server/openvibe/pics.js b/server/openvibe/pics.js
iindex ce2ecc0..9b2bbb1 100644
---- a/server/openvibe/pics.js
++++ b/server/openvibe/pics.js
@@@ -23,7 +23,7 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
id: image.slug || image.id || null,
slug: image.slug || null,
name: image.name || image.title || image.slug || 'image',
-- contentType: image.content_type || 'image/jpeg',
++ contentType: image.mime_type || image.content_type || 'image/jpeg',
direct: (image.links && image.links.direct) || null,
page: (image.links && image.links.page) || null,
thumb: (image.links && image.links.thumb) || null,
@@@ -36,15 +36,18 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
const images = Array.isArray(data.images) ? data.images : (Array.isArray(data) ? data : []);
return images.map(wire);
},
-- async bytes(slug) {
-- const data = await request('/images/' + encodeURIComponent(slug));
++ async bytes(slug, token) {
++ const data = await request('/images/' + encodeURIComponent(slug), token);
const image = wire(data.image || data);
-- const direct = image.direct || (image.slug ? root + '/i/' + image.slug : null);
-- if (!direct) throw new StudioError(404, 'pics.image_not_found', 'No such picture in OpenVibe.Pics.');
-- const res = await fetchImpl(direct, { headers: { accept: '*/*' } });
-- if (!res.ok) throw new StudioError(res.status, 'pics.bytes.unavailable', 'OpenVibe.Pics returned ' + res.status + ' for the picture.');
++ const res = await fetchImpl(root + '/api/v1/images/' + encodeURIComponent(slug) + '/bytes', { redirect: 'error', headers: { authorization: 'Bearer ' + token } });
++ if (!res.ok) throw new StudioError(res.status, 'pics.bytes.unavailable', 'This picture is unavailable.');
return { name: image.name, contentType: image.contentType, bytes: Buffer.from(await res.arrayBuffer()) };
},
++ async save(token, bytes, contentType, title) {
++ const res = await fetchImpl(root + '/api/v1/images', { method: 'POST', redirect: 'error', headers: { authorization: 'Bearer ' + token, 'content-type': contentType, 'x-title': encodeURIComponent(title) }, body: bytes });
++ if (!res.ok) throw new StudioError(res.status, 'pics.save.failed', 'The edited picture could not be saved.');
++ return res.json();
++ },
};
}
ddiff --git a/server/pages.js b/server/pages.js
iindex a551038..ebc6de3 100644
---- a/server/pages.js
++++ b/server/pages.js
@@@ -20,6 +20,15 @@ function createPages({ config, sso }) {
'<ul class="app-features">' + app.features.map((f) => '<li>' + esc(f) + '</li>').join('') + '</ul>' +
'<p><a class="button-primary" href="/' + esc(app.id) + '">Open ' + esc(app.name) + '</a></p></article>';
++ r.get('/image-tools', (req, res) => {
++ const picture = /^[2-9a-km-zA-HJ-NP-Z]{8}$/.test(String(req.query.picture || '')) ? String(req.query.picture) : '';
++ send(res, { title: 'Image tools', viewer: viewerOf(req), config, path: '/image-tools', body:
++ '<h1>Edit a picture with AI</h1><p>Make an edited copy on Pics. The original stays unchanged and the new picture starts unlisted, like every upload.</p>' +
++ '<form id="image-tools"><label>Picture slug <input name="picture" required value="' + esc(picture) + '"></label>' +
++ '<label>Operation <select name="operation"><option value="upscale">Upscale</option><option value="remove-background">Remove background</option><option value="restyle">Restyle</option></select></label>' +
++ '<label>Style instructions <textarea name="prompt" maxlength="1000"></textarea></label><button type="submit">Make edited copy</button></form>' +
++ '<p id="image-tools-status" role="status"></p><script src="/js/image-tools.js" defer></script>' });
++ });
r.get('/', (req, res) => {
const viewer = viewerOf(req);
const photo = APPS.find((x) => x.id === 'photo'), light = APPS.find((x) => x.id === 'light');
ddiff --git a/test/image-tools.test.js b/test/image-tools.test.js
nnew file mode 100644
iindex 0000000..c9b840c
---- /dev/null
++++ b/test/image-tools.test.js
@@@ -0,0 +1,17 @@
++'use strict';
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const express = require('express');
++const { createImageTools } = require('../server/image-tools');
++test('image editing authenticates, rejects cross-site writes and saves a separate copy', async () => {
++ const calls=[]; const app=express();
++ app.use(createImageTools({config:{baseUrl:'https://openvibe.studio',imageWorkerUrl:'http://worker.test'},sso:{extractToken:r=>r.get('authorization') ? 'person' : r.get('cookie') ? 'session' : null},pics:{bytes:async(s,t)=>{calls.push([s,t]);return {bytes:Buffer.from([1]),contentType:'image/png',name:'Forest'};},save:async(t,b,m)=>({slug:'Abcdef23',listed:false})},fetchImpl:async(url,o)=>{assert.equal(o.headers.authorization,undefined); assert.equal(JSON.parse(o.body).operation,'restyle');return new Response(Buffer.from([1,2]),{headers:{'content-type':'image/png'}});}}));
++ app.use((e,_q,r,_n)=>r.status(e.status||500).json({code:e.code}));
++ const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); const url=`http://127.0.0.1:${server.address().port}/images/edit`;
++ const body=JSON.stringify({picture:'Abcdef23',operation:'restyle',prompt:'Watercolour'});
++ try {
++ assert.equal((await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body})).status,401);
++ assert.equal((await fetch(url,{method:'POST',headers:{'content-type':'application/json',cookie:'signed-in',origin:'https://evil.test'},body})).status,403);
++ const result=await fetch(url,{method:'POST',headers:{'content-type':'application/json',authorization:'Bearer person'},body});assert.equal(result.status,201);assert.equal((await result.json()).listed,false);assert.deepEqual(calls,[['Abcdef23','person']]);
++ }finally{await new Promise(r=>server.close(r));}
++});