openvibers/openvibe.studio Commits
Image tools: an edited copy of a picture, never the original (#13)
Changes
@@@ -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.nnew file mode 100644@@@ -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; } });++})();@@@ -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 + '"');@@@ -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).@@@ -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 || '',nnew file mode 100644@@@ -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 };@@@ -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();++ },};}@@@ -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');nnew file mode 100644@@@ -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));}++});
@@ -74,3 +74,17 @@ attribution is in [ATTRIBUTION.md](ATTRIBUTION.md).
@@ -74,3 +74,17 @@ attribution is in [ATTRIBUTION.md](ATTRIBUTION.md).
- openvibe-sdk: v0.123.0
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
- openvibe-shared: v4.173.0
<!-- versions:end -->
<!-- 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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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; } });
+})();
@@ -57,7 +57,7 @@ function createApi({ sso, media, pics }) {
@@ -57,7 +57,7 @@ function createApi({ sso, media, pics }) {
if (!value) return;
if (!value) return;
const source = req.params.source;
const source = req.params.source;
const id = req.params.id;
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, '_');
const name = String(file.name || 'file').replace(/[^A-Za-z0-9._-]+/g, '_');
res.set('Content-Type', file.contentType || 'application/octet-stream');
res.set('Content-Type', file.contentType || 'application/octet-stream');
res.set('Content-Disposition', 'attachment; filename="' + name + '"');
res.set('Content-Disposition', 'attachment; filename="' + name + '"');
@@ -74,6 +74,7 @@ function createApp(opts = {}) {
@@ -74,6 +74,7 @@ function createApp(opts = {}) {
// What openvibe.studio tells an agent about itself (SEO-2, openvibe-shared/agent-layer's shape).
// 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('/.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', createApi({ sso, media, pics }));
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
// 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).
@@ -15,6 +15,7 @@ function load(env = process.env) {
@@ -15,6 +15,7 @@ function load(env = process.env) {
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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 || ''),
picsUrl: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
picsUrl: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
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 || '',
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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 };
@@ -23,7 +23,7 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
@@ -23,7 +23,7 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
id: image.slug || image.id || null,
id: image.slug || image.id || null,
slug: image.slug || null,
slug: image.slug || null,
name: image.name || image.title || image.slug || 'image',
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,
direct: (image.links && image.links.direct) || null,
page: (image.links && image.links.page) || null,
page: (image.links && image.links.page) || null,
thumb: (image.links && image.links.thumb) || null,
thumb: (image.links && image.links.thumb) || null,
@@ -36,15 +36,18 @@ function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
@@ -36,15 +36,18 @@ 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);
},
},
- 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 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()) };
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();
+ },
};
};
}
}
@@ -20,6 +20,15 @@ function createPages({ config, sso }) {
@@ -20,6 +20,15 @@ function createPages({ config, sso }) {
'<ul class="app-features">' + app.features.map((f) => '<li>' + esc(f) + '</li>').join('') + '</ul>' +
'<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>';
'<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) => {
r.get('/', (req, res) => {
const viewer = viewerOf(req);
const viewer = viewerOf(req);
const photo = APPS.find((x) => x.id === 'photo'), light = APPS.find((x) => x.id === 'light');
const photo = APPS.find((x) => x.id === 'photo'), light = APPS.find((x) => x.id === 'light');
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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));}
+});