openvibers/openvibe.mediahub Commits

A picture's owner can turn it a quarter from its page (#413)

OP OpenVibers committed 1 days ago · c62acee528 · 6 files, +160 −6

* A picture's owner can turn it a quarter from its page

* Pics marks its already-upright Media objects so the detector never turns them

* Test the orientation marker Pics sends to Media

Changes

ddiff --git a/server/media/client.js b/server/media/client.js
iindex 659c469..d810533 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -133,7 +133,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
},
/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
-- async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {
++ async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false, metadata = null }) {
// A drive file always belongs to a person. A picture uploaded signed out (Pics, `ownerless`) is the
// service's own object, with no subject on it.
const subject = subjectHeader(ownerSubject);
@@@ -146,6 +146,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
// Media only needs a label: the real name (any script) is MediaHub's, and Media's own download
// header takes a filename as is, so it gets the printable-ASCII rendering.
mime_type: contentType || null, filename: headerSafeName(name), multipart: multipart === true,
++ ...(metadata && typeof metadata === 'object' ? { metadata } : {}),
...(multipart && partSize ? { part_size: partSize } : {}),
},
});
@@@ -177,6 +178,13 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
get: (objectId) => call(`/${encodeURIComponent(objectId)}`),
++ /** Turn a stored picture a quarter (or half) clockwise, as the person who owns it. */
++ rotate: (objectId, { subject, degrees = 90 } = {}) => call(`/${encodeURIComponent(objectId)}/rotate`, {
++ method: 'POST',
++ headers: subjectHeader(subject) ? { 'X-OV-Subject': subjectHeader(subject) } : {},
++ json: { degrees },
++ }),
++
remove: (objectId) => call(`/${encodeURIComponent(objectId)}`, { method: 'DELETE' }),
/**
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 05e730f..4d10582 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -433,7 +433,7 @@ ${raw(showcase.network({
scripts: ['js/pics.js', 'js/pics-gallery.js', 'js/comments.js'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js'],
localStyles: ['css/comments.css', ...(owner ? ['css/labels.css'] : [])],
private: deleteKey ? true : undefined,
-- body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
++ body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.turned ? notice('Turned a quarter. Every size of the picture follows.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}
<article class="ovp-picture">
@@@ -471,6 +471,7 @@ ${!hidden && variants.widthsFor(row).length ? html`<section class="ovp-section o
${[['webp', 'WebP'], ['avif', 'AVIF'], ['jpg', 'JPEG'], ['png', 'PNG']].map(([fmt, label]) => html`<tr><th scope="row">${label}</th>${variants.widthsFor(row).map((w) => html`<td><a href="/i/${row.slug}/w/${w}.${fmt}" rel="nofollow">${w}</a></td>`)}</tr>`)}
</tbody></table></section>` : ''}
${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>
++<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/rotate"><span>Which way is up?</span> <button class="ovk-btn ovk-btn--quiet" name="direction" value="left" type="submit">Turn left</button> <button class="ovk-btn ovk-btn--quiet" name="direction" value="right" type="submit">Turn right</button> <span class="muted">A quarter turn at a time; every size follows, and the original is kept.</span></form>
<form class="ovp-form" method="post" action="/i/${row.slug}/publication"><label for="p-tags">Tags (comma separated)</label><input id="p-tags" name="tags" maxlength="330" value="${gallery.tags(row).join(', ')}"><p>Publishing makes this picture visible to everyone, lists it in the gallery, enables public counts, and lets search engines index it.</p><button class="ovp-btn" name="listed" value="${!row.listed}">${row.listed ? 'Remove from gallery' : 'Publish to gallery'}</button><p>Removing from the gallery keeps your sharing setting. Use Share to make it private again.</p></form>
<form class="ovp-form" method="post" action="/i/${row.slug}/edit">
<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
@@@ -607,6 +608,19 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
return res.redirect(303, `/a/${album.slug}`);
});
++ // A quarter turn by the person who uploaded the picture: plain kit buttons in the owner's section, a POST and a
++ // redirect, so it works without JavaScript. The picture and its thumbnail both turn; every size follows.
++ r.post('/i/:slug/rotate', budgeted('pics.image.write'), async (req, res) => {
++ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
++ const { row } = await picture(req, res);
++ if (!row) return notFound(req, res);
++ if (!pics.isOwner(row, whoOf(req))) return picturePage(req, res, row, { message: 'Only the person who uploaded this picture can turn it.', tone: 'warn' }, 403);
++ const degrees = String(req.body.direction || '') === 'left' ? 270 : 90;
++ const out = await pics.rotate(row, degrees, whoOf(req));
++ if (!out.ok) return picturePage(req, res, row, { message: out.detail || 'The picture could not be turned.', tone: 'warn' }, out.status || 422);
++ return res.redirect(303, `/i/${row.slug}?turned=1`);
++ });
++
r.post('/i/:slug/delete', budgeted('pics.image.write'), async (req, res) => {
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
const { row } = await picture(req, res);
ddiff --git a/server/pics/service.js b/server/pics/service.js
iindex 8fd9fc4..3bae600 100644
---- a/server/pics/service.js
++++ b/server/pics/service.js
@@@ -66,7 +66,10 @@ function createPics({ s, media, config, log = console, events = null, sharing =
const isOwner = (row, viewer) => Boolean(row && row.owner && viewer && viewer.requester && row.owner === viewer.requester);
async function putObject(buf, { mime, name, owner, anonymous }) {
-- const init = await media.init({ size: buf.length, contentType: mime, name, ownerSubject: owner, visibility: 'private', ownerless: anonymous });
++ // `images.prepare` already applied the EXIF orientation and dropped the details: Media must not offer this
++ // picture to its model detector again, or an upright picture could be turned by a confident wrong answer.
++ const init = await media.init({ size: buf.length, contentType: mime, name, ownerSubject: owner, visibility: 'private', ownerless: anonymous,
++ metadata: { orientation_status: 'orientation applied', orientation_source: 'exif' } });
if (!init.ok) return init;
const id = init.data && (init.data.id || (init.data.object && init.data.object.id));
const put = await media.putContent(id, buf);
@@@ -259,6 +262,32 @@ function createPics({ s, media, config, log = console, events = null, sharing =
return { ok: true };
}
++ /**
++ * Turn a picture a quarter at a time. Only its owner may; the picture and its stored 640 px thumbnail both turn,
++ * so the page, every size link and the embeds follow. The Media object keeps its original beside the turned copy.
++ */
++ async function rotate(row, degrees, who) {
++ if (!isOwner(row, who)) return { ok: false, status: 403, code: 'pics.forbidden', detail: 'Only the person who uploaded this picture can turn it.' };
++ if (!row || row.state === 'removed') return { ok: false, status: 409, code: 'pics.not_active', detail: 'This picture is not here any more.' };
++ const turn = ((Number(degrees) % 360) + 360) % 360;
++ if (![90, 180, 270].includes(turn)) return { ok: false, status: 422, code: 'pics.rotate', detail: 'A turn is a quarter or half: 90, 180 or 270 degrees.' };
++ const main = await media.rotate(row.object_id, { subject: row.owner, degrees: turn });
++ if (!main.ok) return main;
++ if (row.thumb_object_id) {
++ const thumb = await media.rotate(row.thumb_object_id, { subject: row.owner, degrees: turn });
++ if (!thumb.ok) return thumb;
++ }
++ const turned = main.data || {};
++ const swap = turn % 180 !== 0;
++ const saved = await store.recordRotation(s, row.id, {
++ width: swap ? Number(row.height) : Number(row.width),
++ height: swap ? Number(row.width) : Number(row.height),
++ bytes: Number(turned.size_bytes) || Number(row.bytes),
++ sha256: turned.content_hash || row.sha256,
++ });
++ return { ok: true, row: saved, rotation: turned.rotation == null ? turn : turned.rotation };
++ }
++
/** The public links of a picture: its page, the direct file, the thumbnail and the embed snippets. */
function links(row) {
const base = cfg.url;
@@@ -287,7 +316,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
return {
cfg, REASONS, REASON_TEXT, LIMITS, SLUG_RE, isSlug: (v) => SLUG_RE.test(String(v || '')),
-- addressId, canSee, isOwner, upload, edit, remove, deleteKeyMatches, report, moderate, viewed, sweep,
++ addressId, canSee, isOwner, upload, edit, remove, rotate, deleteKeyMatches, report, moderate, viewed, sweep,
createAlbum, addToAlbum, links, toWire, isStaff, altOf, tagsOf,
};
}
ddiff --git a/server/pics/store.js b/server/pics/store.js
iindex 86f8d67..95a9b21 100644
---- a/server/pics/store.js
++++ b/server/pics/store.js
@@@ -30,6 +30,12 @@ async function updateImage(s, id, patch) {
const deleteImage = (s, id) => s.db.exec('DELETE FROM pics_images WHERE id = $1', [id]);
++/** A turn changes what is served: the file's dimensions, size and hash move with it (the title and audience do not). */
++async function recordRotation(s, id, { width, height, bytes, sha256 }) {
++ await s.db.query('UPDATE pics_images SET width = $3, height = $4, bytes = $5, sha256 = $6, updated_at = $2 WHERE id = $1', [id, s.iso(), width, height, bytes, sha256]);
++ return imageById(s, id);
++}
++
/** A person's pictures, newest first, a page at a time by id cursor. */
async function imagesOf(s, owner, { limit = 48, before = null } = {}) {
const rows = await s.db.many(
@@@ -128,7 +134,7 @@ const removeFromAlbum = (s, albumId, imageId) => s.db.exec('DELETE FROM pics_alb
const albumsHolding = (s, imageId) => s.db.many('SELECT a.* FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id WHERE ai.image_id = $1 ORDER BY a.title', [imageId]);
module.exports = {
-- imageBySlug, imageById, insertImage, updateImage, deleteImage, imagesOf, ownHashes, ownedByHash, usageOf, countUpload, uploadsToday,
++ imageBySlug, imageById, insertImage, updateImage, deleteImage, recordRotation, imagesOf, ownHashes, ownedByHash, usageOf, countUpload, uploadsToday,
blockedSha, blockedDhashes, block, addReport, reportsFor, staffQueue, staffCounts, idleAnonymous,
albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,
};
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
iindex 0d85e9e..08e5e32 100644
---- a/test/helpers/media.js
++++ b/test/helpers/media.js
@@@ -12,6 +12,7 @@
* POST /api/v2/:app/objects/:id/multipart/:uid/complete assemble into the object
* DELETE /api/v2/:app/objects/:id/multipart/:uid abort
* GET /api/v2/:app/objects/:id metadata
++ * POST /api/v2/:app/objects/:id/rotate a quarter turn by the owner (x-ov-subject)
* GET /api/v2/:app/objects/:id/download a short-lived signed URL (host-independent signature)
* DELETE /api/v2/:app/objects/:id soft delete
* GET /o/:id?exp&sig the bytes, with the signature checked
@@@ -241,7 +242,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const obj = {
id: ids.newId('media', 1_700_000_000_000 + seq), visibility: body.visibility || 'private',
lifecycle_status: 'uploading', mime_type: body.mime_type || null, size_bytes: size,
-- content_hash: null, owner_subject: subject, filename: body.filename || null, bytes: null, x: true,
++ content_hash: null, owner_subject: subject, filename: body.filename || null, bytes: null, x: true, init: body,
created_at: new Date().toISOString(),
};
objects.set(obj.id, obj);
@@@ -326,6 +327,17 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const exp = Math.floor(Date.now() / 1000) + 300;
return json(res, 200, { url: `https://media.test/o/${encodeURIComponent(obj.id)}?exp=${exp}&sig=${sign(obj.id, exp)}`, expires_at: new Date(exp * 1000).toISOString(), public: false });
}
++ // POST /objects/:id/rotate
++ if (sub.length === 1 && sub[0] === 'rotate' && req.method === 'POST') {
++ if (!obj.owner_subject || req.headers['x-ov-subject'] !== obj.owner_subject) return problem(res, 403, 'media.rotate.forbidden', "Only the picture's owner can turn it");
++ let body = {};
++ try { body = JSON.parse(raw.toString() || '{}'); } catch { body = {}; }
++ const degrees = Number(body.degrees);
++ if (![90, 180, 270].includes(degrees)) return problem(res, 422, 'media.rotate.invalid', 'a turn is 90, 180 or 270 degrees');
++ obj.rotation = ((Number(obj.rotation) || 0) + degrees) % 360;
++ obj.content_hash = sha256(Buffer.concat([obj.bytes || Buffer.alloc(0), Buffer.from(String(obj.rotation))]));
++ return json(res, 200, { ...publicObject(obj), rotation: obj.rotation, content_hash: obj.content_hash });
++ }
// DELETE /objects/:id
if (!sub.length && req.method === 'DELETE') {
obj.lifecycle_status = 'deleted';
ddiff --git a/test/pics-rotate.test.js b/test/pics-rotate.test.js
nnew file mode 100644
iindex 0000000..bbc38a9
---- /dev/null
++++ b/test/pics-rotate.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++// A picture's owner turns it a quarter from its page: plain kit buttons, a form POST (works without JavaScript), the
++// picture and its stored thumbnail both turned in Media with the owner as X-OV-Subject, and the page's size follows.
++const assert = require('assert');
++const http = require('http');
++const sharp = require('sharp');
++const { boot, check, done } = require('./helpers/boot');
++
++/** t.get with a Host header (Node's fetch drops one): the pics host only answers on openvibe.pics. */
++function hostClient(t) {
++ return (p, o = {}) => new Promise((resolve, reject) => {
++ const headers = { Host: 'openvibe.pics', ...(o.headers || {}) };
++ if (o.as) headers.cookie = t.signIn(o.as);
++ let body = o.body;
++ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));
++ const u = new URL(t.base + p);
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, headers: { get: (k) => res.headers[String(k).toLowerCase()] || null }, text, json() { return JSON.parse(text); } }); });
++ });
++ req.on('error', reject);
++ if (body != null) req.write(body);
++ req.end();
++ });
++}
++
++const PICS = { Host: 'openvibe.pics' };
++const SAME = { ...PICS, 'sec-fetch-site': 'same-origin' };
++const photo = () => sharp({ create: { width: 300, height: 200, channels: 3, background: '#357' } }).jpeg({ quality: 90 }).toBuffer();
++
++(async () => {
++ const booted = await boot();
++ const t = { ...booted, get: hostClient(booted) };
++ try {
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const up = async (as) => t.get('/api/v1/images', { as, method: 'POST', body: await photo(), headers: { ...SAME, 'content-type': 'image/jpeg', 'x-title': 'Sideways' } });
++ let slug;
++ await check('the owner sees the Rotate control, a stranger does not', async () => {
++ const made = await up(kim);
++ assert.strictEqual(made.status, 201, `upload answered ${made.status}: ${made.text.replace(/\s+/g, ' ').slice(0, 200)}`);
++ slug = made.json().slug;
++ assert.deepStrictEqual([made.json().width, made.json().height], [300, 200]);
++ const ownerPage = await t.get(`/i/${slug}`, { headers: PICS, as: kim });
++ assert.ok(ownerPage.text.includes(`action="/i/${slug}/rotate"`), 'the owner has the turn form');
++ assert.ok(ownerPage.text.includes('>Turn left<') && ownerPage.text.includes('>Turn right<'), 'both directions, plain kit buttons');
++ assert.ok(ownerPage.text.includes('class="ovk-btn ovk-btn--quiet"'));
++ assert.ok(!(await t.get(`/i/${slug}`, { headers: PICS, as: lee })).text.includes(`/i/${slug}/rotate`), 'a stranger never sees it');
++ const stored = [...t.media.objects.values()].find((o) => o.init);
++ assert.strictEqual(stored.init.metadata.orientation_status, 'orientation applied', 'Pics tells Media the EXIF was already applied, so the detector never turns an upright picture');
++ });
++
++ await check('a right turn turns the picture and its thumbnail in Media, as the owner', async () => {
++ const before = t.media.requests.length;
++ const r = await t.get(`/i/${slug}/rotate`, { as: kim, method: 'POST', form: { direction: 'right' }, headers: SAME });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.strictEqual(r.headers.get('location'), `/i/${slug}?turned=1`);
++ const calls = t.media.requests.slice(before).filter((x) => /\/rotate$/.test(x.path));
++ assert.strictEqual(calls.length, 2, 'the picture and its stored thumbnail both turn');
++ assert.ok(calls.every((c) => c.subject === kim.subject), 'Media sees the owner, never the service alone');
++ assert.ok(calls.every((c) => c.authorization === 'Bearer svc-media'), 'and the service token');
++ const row = await t.ctx.s.db.maybe('SELECT width, height, sha256 FROM pics_images WHERE slug = $1', [slug]);
++ assert.deepStrictEqual([Number(row.width), Number(row.height)], [200, 300], 'the page size follows the turn');
++ const objects = [...t.media.objects.values()].filter((o) => o.rotation === 90);
++ assert.ok(objects.length >= 2, 'the stand-in Media recorded a 90-degree turn on both objects');
++ const page = await t.get(`/i/${slug}?turned=1`, { headers: PICS, as: kim });
++ assert.ok(page.text.includes('Turned a quarter.'), 'the page says what happened');
++ });
++
++ await check('someone else cannot turn it', async () => {
++ const before = t.media.requests.length;
++ const r = await t.get(`/i/${slug}/rotate`, { as: lee, method: 'POST', form: { direction: 'left' }, headers: SAME });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(t.media.requests.slice(before).filter((x) => /\/rotate$/.test(x.path)).length, 0, 'nothing reached Media');
++ });
++
++ await check('a cross-site form post never turns it', async () => {
++ const r = await t.get(`/i/${slug}/rotate`, { as: kim, method: 'POST', form: { direction: 'left' }, headers: { ...PICS, 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(r.status, 403);
++ });
++ } finally { await t.close(); }
++ done();
++})();