openvibers/openvibe.mediahub Commits
A picture's owner can turn it a quarter from its page (#413)
Changes
@@@ -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 = consoleget: (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' }),/**@@@ -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" namereturn 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);@@@ -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,};}@@@ -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_albconst 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,};@@@ -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/:idif (!sub.length && req.method === 'DELETE') {obj.lifecycle_status = 'deleted';nnew file mode 100644@@@ -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();++})();
@@ -133,7 +133,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -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. */
/** 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
// 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.
// service's own object, with no subject on it.
const subject = subjectHeader(ownerSubject);
const subject = subjectHeader(ownerSubject);
@@ -146,6 +146,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -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
// 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.
// header takes a filename as is, so it gets the printable-ASCII rendering.
mime_type: contentType || null, filename: headerSafeName(name), multipart: multipart === true,
mime_type: contentType || null, filename: headerSafeName(name), multipart: multipart === true,
+ ...(metadata && typeof metadata === 'object' ? { metadata } : {}),
...(multipart && partSize ? { part_size: partSize } : {}),
...(multipart && partSize ? { part_size: partSize } : {}),
},
},
});
});
@@ -177,6 +178,13 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -177,6 +178,13 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
get: (objectId) => call(`/${encodeURIComponent(objectId)}`),
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' }),
remove: (objectId) => call(`/${encodeURIComponent(objectId)}`, { method: 'DELETE' }),
/**
/**
@@ -433,7 +433,7 @@ ${raw(showcase.network({
@@ -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'],
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'] : [])],
localStyles: ['css/comments.css', ...(owner ? ['css/labels.css'] : [])],
private: deleteKey ? true : undefined,
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>` : ''}
${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') : ''}
${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">
<article class="ovp-picture">
@@ -471,6 +471,7 @@ ${!hidden && variants.widthsFor(row).length ? html`<section class="ovp-section o
@@ -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>`)}
${[['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>` : ''}
</tbody></table></section>` : ''}
${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>
${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}/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">
<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}">
<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
@@ -607,6 +608,19 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
return res.redirect(303, `/a/${album.slug}`);
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) => {
r.post('/i/:slug/delete', budgeted('pics.image.write'), async (req, res) => {
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
const { row } = await picture(req, res);
const { row } = await picture(req, res);
@@ -66,7 +66,10 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -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);
const isOwner = (row, viewer) => Boolean(row && row.owner && viewer && viewer.requester && row.owner === viewer.requester);
async function putObject(buf, { mime, name, owner, anonymous }) {
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;
if (!init.ok) return init;
const id = init.data && (init.data.id || (init.data.object && init.data.object.id));
const id = init.data && (init.data.id || (init.data.object && init.data.object.id));
const put = await media.putContent(id, buf);
const put = await media.putContent(id, buf);
@@ -259,6 +262,32 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -259,6 +262,32 @@ function createPics({ s, media, config, log = console, events = null, sharing =
return { ok: true };
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. */
/** The public links of a picture: its page, the direct file, the thumbnail and the embed snippets. */
function links(row) {
function links(row) {
const base = cfg.url;
const base = cfg.url;
@@ -287,7 +316,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -287,7 +316,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
return {
return {
cfg, REASONS, REASON_TEXT, LIMITS, SLUG_RE, isSlug: (v) => SLUG_RE.test(String(v || '')),
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,
createAlbum, addToAlbum, links, toWire, isStaff, altOf, tagsOf,
};
};
}
}
@@ -30,6 +30,12 @@ async function updateImage(s, id, patch) {
@@ -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]);
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. */
/** A person's pictures, newest first, a page at a time by id cursor. */
async function imagesOf(s, owner, { limit = 48, before = null } = {}) {
async function imagesOf(s, owner, { limit = 48, before = null } = {}) {
const rows = await s.db.many(
const rows = await s.db.many(
@@ -128,7 +134,7 @@ const removeFromAlbum = (s, albumId, imageId) => s.db.exec('DELETE FROM pics_alb
@@ -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]);
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 = {
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,
blockedSha, blockedDhashes, block, addReport, reportsFor, staffQueue, staffCounts, idleAnonymous,
albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,
albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,
};
};
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* POST /api/v2/:app/objects/:id/multipart/:uid/complete assemble into the object
* POST /api/v2/:app/objects/:id/multipart/:uid/complete assemble into the object
* DELETE /api/v2/:app/objects/:id/multipart/:uid abort
* DELETE /api/v2/:app/objects/:id/multipart/:uid abort
* GET /api/v2/:app/objects/:id metadata
* 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)
* GET /api/v2/:app/objects/:id/download a short-lived signed URL (host-independent signature)
* DELETE /api/v2/:app/objects/:id soft delete
* DELETE /api/v2/:app/objects/:id soft delete
* GET /o/:id?exp&sig the bytes, with the signature checked
* GET /o/:id?exp&sig the bytes, with the signature checked
@@ -241,7 +242,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -241,7 +242,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const obj = {
const obj = {
id: ids.newId('media', 1_700_000_000_000 + seq), visibility: body.visibility || 'private',
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,
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(),
created_at: new Date().toISOString(),
};
};
objects.set(obj.id, obj);
objects.set(obj.id, obj);
@@ -326,6 +327,17 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -326,6 +327,17 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const exp = Math.floor(Date.now() / 1000) + 300;
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 });
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
// DELETE /objects/:id
if (!sub.length && req.method === 'DELETE') {
if (!sub.length && req.method === 'DELETE') {
obj.lifecycle_status = 'deleted';
obj.lifecycle_status = 'deleted';
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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();
+})();