openvibers/openvibe.community Commits

Orient Community images before stripping metadata (#349)

OP OpenVibers committed 3 days ago · e82eeb899d · 8 files, +58 −20

* Orient Community images before stripping metadata

* Classify Media image normalization request

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2a7d11e..fe4f4c9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,11 @@
## Unreleased
++- Community paste screenshots and forum attachments now send source image bytes to Media's shared EXIF normalizer
++ before upload. Media applies orientation to pixels and removes camera/location metadata; Community stores the
++ normalized image hash and byte size. The screenshot upload, censor replacement, and forum attachment paths had
++ stripped EXIF before orientation was applied. Tests: `test/attachments.test.js`, `test/paste-sharing.test.js`.
++
- Keep every Pulse row when paging the Feed, include trending cards within `limit`, and apply live/stream filters after resolving current live status.
- **The wide Feed** (owner, 11-09: "one huge feed of everything, all the data, SDK and API enabled"). Pulse now carries
ddiff --git a/server/app.js b/server/app.js
iindex 4acd9e4..0d649e2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -201,7 +201,7 @@ async function createApp(opts = {}) {
async upload({ buffer, filename, mime, owner = null, visibility = 'private' }) {
if (!mediaObjects.configured) return await mediaFiles.upload({ buffer, filename, mime });
const o = await mediaObjects.uploadImage({ buffer, mime, filename, owner, kind: 'screenshot', source: 'community.paste', visibility });
-- return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id, private: visibility === 'private' };
++ return { key: o.id, url: o.url, size: o.size_bytes, mime, content_hash: o.content_hash, media_ref: o.id, private: visibility === 'private' };
},
/** A paste's screenshot bytes, for the proxy (a private object through a signed download). */
async bytes(row, variant = null) {
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex a53eab1..fc67d60 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -49,7 +49,6 @@ const { createPersonLimiter } = require('../limits');
const { discussionModerator } = require('../identity/capabilities');
const { renderMarkdown, markdownToText } = require('../render/markdown');
const { isUserSubject } = require('../vip');
--const { stripImageMetadata } = require('../media/strip-metadata');
const { sniffImage } = require('../media/sniff-image');
const reactions = require('./reactions');
const moderation = require('./moderation');
@@@ -779,7 +778,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mime = sniffImage(file.buffer);
if (!mime) fail(415, 'attachments.unsupported', 'Attach a PNG, JPEG, GIF or WebP image');
uploadLimiter.check(`s:${v.subject}`);
-- const buffer = stripImageMetadata(file.buffer, mime);
++ const buffer = file.buffer;
const filename = String(file.originalname || 'image').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'image';
let stored;
try { stored = await media.uploadImage({ buffer, mime, filename, owner: v.subject }); } catch (err) {
ddiff --git a/server/media/objects.js b/server/media/objects.js
iindex 5b9eb27..4896233 100644
---- a/server/media/objects.js
++++ b/server/media/objects.js
@@@ -6,6 +6,7 @@
* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
* namespace community):
*
++ * POST /api/v2/community/objects/normalize-image raw image → oriented pixels with image metadata removed
* POST /api/v2/community/objects init { kind, visibility: 'unlisted', size_bytes, mime_type, filename, content_hash }
* PUT /api/v2/community/objects/:id/content the bytes
* POST /api/v2/community/objects/:id/complete Media checks size, hash and type → ready
@@@ -48,14 +49,34 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return await request(method, `${base}${path}`, opts);
}
++ /** Apply EXIF orientation in Media before its encoder removes camera and location metadata. */
++ async function normalizeImage(buffer, mime, retried = false) {
++ if (!configured) { const e = new Error('Image uploads need Community\'s service principal'); e.status = 503; throw e; }
++ const headers = { Accept: mime, 'Content-Type': mime, ...(await tokens.authHeaders()) };
++ const res = await fetchImpl(`${base}/normalize-image`, { method: 'POST', headers, body: buffer, signal: AbortSignal.timeout(60_000) });
++ if (res.status === 401 && !retried) { tokens.invalidate(); return await normalizeImage(buffer, mime, true); }
++ if (!res.ok) {
++ const data = await res.json().catch(() => null);
++ const err = new Error(`Media image normalization ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);
++ err.status = res.status; err.code = data && data.code; throw err;
++ }
++ if (String(res.headers.get('content-type') || '').split(';')[0].toLowerCase() !== String(mime).toLowerCase()) {
++ throw Object.assign(new Error('Media returned an unexpected image type'), { status: 502 });
++ }
++ return Buffer.from(await res.arrayBuffer());
++ }
++
/**
-- * Store one image. → { id (med_…), url, size_bytes, mime }
++ * Store one image. Media normalizes orientation and removes image metadata before its hash and size are recorded.
++ * → { id (med_…), url, size_bytes, mime, content_hash }
* owner: the person (usr_…) the object belongs to, when there is one; kind: 'file' (post images) or
* 'screenshot' (screenshot pastes); visibility 'unlisted': served to anyone with the link (the object id is
* unguessable), never listed on openvibe.media — the post or paste decides who sees the link.
*/
-- async function uploadImage({ buffer, mime, filename, owner = null, kind = 'file', visibility = 'unlisted', source = 'community.attachment' }) {
++ async function uploadImage({ buffer, mime, filename, owner = null, kind = 'file', visibility = 'unlisted', source = 'community.attachment', normalized = false }) {
if (!configured) { const e = new Error('Attachments need Community\'s service principal (OV_OAUTH_CLIENT_SECRET)'); e.status = 503; throw e; }
++ // GIF has no EXIF orientation and Media's GIF variant path preserves animation, so do not re-encode it.
++ if (!normalized && mime !== 'image/gif') buffer = await normalizeImage(buffer, mime);
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
const init = await call('POST', '', {
json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source } },
@@@ -65,7 +86,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });
await call('PUT', `/${id}/content`, { body: buffer, headers: { 'Content-Type': mime } });
await call('POST', `/${id}/complete`, { json: { content_hash: sha256 } });
-- return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };
++ return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime, content_hash: sha256 };
}
/** One object's metadata (GET /api/v2/community/objects/:id): owner, kind, mime and public URL. */
@@@ -113,7 +134,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return { retired: true, changed: !!(out && out.changed), ...(out && out.retention_until ? { retention_until: out.retention_until } : {}) };
}
-- return { configured, uploadImage, object, downloadUrl, deleteObject, retireLegacyPaste };
++ return { configured, uploadImage, normalizeImage, object, downloadUrl, deleteObject, retireLegacyPaste };
}
module.exports = { createMediaObjects };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 7117651..5fe5d7f 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -22,7 +22,6 @@ const { canonicalSlug, publicIdOfRef } = require('./url');
const spam = require('./spam');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
--const { stripImageMetadata } = require('../media/strip-metadata');
const { sniffImage } = require('../media/sniff-image');
const { capabilities } = require('openvibe-contracts');
const { inspectEnvelope } = require('openvibe-sdk/locker/server');
@@@ -676,8 +675,6 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const visibility = visibilityFor(audience);
const slug = await slugFor(v, body, visibility, truthy(body.burn_after_read));
const extra = serviceMetadata(v, body);
-- const bytes = stripImageMetadata(file.buffer, mime);
-- const contentHash = crypto.createHash('sha256').update(bytes).digest('hex');
const sourceHash = crypto.createHash('sha256').update(file.buffer).digest('hex');
if (v.subject && !truthy(body.keep_both)) {
const same = await store.findOwnedHash(db, v.subject, sourceHash);
@@@ -687,17 +684,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
try {
// Screenshot bytes are private Media objects owned by the person: only Community's proxy
// (which calls canView) serves them, never a bare Media URL.
-- stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime, owner: v.subject || null, visibility: 'private' });
++ stored = await media.upload({ buffer: file.buffer, filename: file.originalname || 'screenshot.png', mime, owner: v.subject || null, visibility: 'private' });
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
}
++ const storedBytes = Number(stored.size_bytes ?? stored.size) || file.buffer.length;
const metadata = {
...(extra || {}),
page_url: body.page_url || (extra && extra.page_url) || null,
user_agent: body.user_agent || ctx.userAgent || null,
original_name: file.originalname || null,
-- size_bytes: bytes.length,
++ size_bytes: storedBytes,
mime_type: mime,
media_visibility: 'private',
};
@@@ -709,7 +707,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
type: 'screenshot',
title: store.sanitizeTitle(body.title || 'Screenshot'),
content: String(body.description || body.content || ''),
-- content_hash: contentHash,
++ content_hash: stored.content_hash || sourceHash,
source_hash: sourceHash,
language: 'text',
visibility,
@@@ -993,7 +991,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!media) fail(503, 'Media service unavailable');
let stored;
try {
-- stored = await media.upload({ buffer: stripImageMetadata(file.buffer, mime), filename: file.originalname || 'censored.png', mime, owner: p.owner_subject || null, visibility: 'private' });
++ stored = await media.upload({ buffer: file.buffer, filename: file.originalname || 'censored.png', mime, owner: p.owner_subject || null, visibility: 'private' });
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
iindex 7d8a8ea..cbe1676 100644
---- a/test/attachments.test.js
++++ b/test/attachments.test.js
@@@ -1,7 +1,8 @@
'use strict';
/**
* Images on forum posts (WS-J task 2): an upload goes to OpenVibe.Media's Object API v2 as a med_ object
-- * the person owns (init → content → complete, Community's service token, X-OV-Subject), metadata stripped
++ * the person owns (Media normalizes EXIF orientation and strips metadata before init → content → complete;
++ * Community supplies its service token and X-OV-Subject)
* and the type taken from the bytes; a thread or reply names at most 4 of the person's own unattached
* uploads; posts show them; the no-JS forms upload and attach in one step; someone else's upload, a
* non-image, an anonymous upload and Media being unconfigured are refused.
@@@ -12,6 +13,7 @@ const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { createMediaObjects } = require('../server/media/objects');
++const { stripImageMetadata } = require('../server/media/strip-metadata');
const { sniffImage } = require('../server/forum/service');
// A 1×1 PNG with a tEXt chunk (metadata that must not reach Media), with real lengths and CRCs.
@@@ -29,6 +31,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
(async () => {
// ── A stub Media (Object API v2 for tenant community) ──
const objects = new Map();
++ let normalizedImages = 0;
const media = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
@@@ -37,6 +40,11 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
const send = (status, obj) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(obj)); };
if (req.url === '/oauth/token') return send(200, { access_token: 'svc-community', token_type: 'Bearer', expires_in: 300 });
if (req.headers.authorization !== 'Bearer svc-community') return send(401, { code: 'auth.required' });
++ if (req.method === 'POST' && req.url === '/api/v2/community/objects/normalize-image') {
++ normalizedImages++;
++ res.statusCode = 200; res.setHeader('content-type', req.headers['content-type']);
++ return res.end(stripImageMetadata(body, req.headers['content-type']));
++ }
if (req.method === 'POST' && req.url === '/api/v2/community/objects') {
const b = JSON.parse(body);
const id = ids.newId('media');
@@@ -80,7 +88,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
});
let first;
-- await check('upload: a med_ object owned by the person, metadata stripped, the public URL', async () => {
++ await check('upload: Media orients pixels before stripping metadata and stores a med_ object owned by the person', async () => {
const r = await upload(alexJwt);
assert.strictEqual(r.status, 201, r.text);
first = r.json().attachment;
@@@ -89,6 +97,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
const o = objects.get(first.media_id);
assert.strictEqual(o.owner, alex.subject_id); assert.strictEqual(o.visibility, 'unlisted', 'reachable by link, never listed on openvibe.media'); assert.strictEqual(o.status, 'ready');
assert.ok(!o.bytes.includes(Buffer.from('secret')), 'the tEXt chunk never reached Media');
++ assert.strictEqual(normalizedImages, 1, 'Media normalization ran before object creation');
assert.ok(PNG.includes(Buffer.from('secret')));
});
ddiff --git a/test/helpers/mock-media.js b/test/helpers/mock-media.js
iindex 0706dda..9ff838c 100644
---- a/test/helpers/mock-media.js
++++ b/test/helpers/mock-media.js
@@@ -1,7 +1,7 @@
'use strict';
/**
-- * A stand-in for OpenVibe.Media: the v1 file store (POST /api/v1/community/files, multipart `file`) and the
-- * Object API v2 upload (POST /api/v2/community/objects → PUT /:id/content → POST /:id/complete), with a
++ * A stand-in for OpenVibe.Media: the v1 file store (POST /api/v1/community/files, multipart `file`), image
++ * normalization, and Object API v2 upload (POST /api/v2/community/objects → PUT /:id/content → POST /:id/complete), with a
* Network service token for openvibe.media holding media.object.upload. Keeps each finished upload's raw
* bytes (and, for v2, its kind, visibility and owner) so tests can check what left Community.
*/
@@@ -59,12 +59,18 @@ function start({ publicPem, issuer }) {
metadata: { filename: o.name } });
}
const v2 = req.url.match(/^\/api\/v2\/community\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete|download))?(?:\?.*)?$/);
-- if (!v2 && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });
++ const normalize = req.method === 'POST' && req.url === '/api/v2/community/objects/normalize-image';
++ if (!v2 && !normalize && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
const verb = v2 && req.method === 'GET' && v2[2] === 'download' ? 'media.object.read' : 'media.object.upload';
if (!(v.claims.cap || []).includes(verb)) return json(403, { code: 'capability.denied', error: 'not granted' });
const body = Buffer.concat(chunks);
++ if (normalize) {
++ const { stripImageMetadata } = require('../../server/media/strip-metadata');
++ res.writeHead(200, { 'Content-Type': req.headers['content-type'] || 'application/octet-stream' });
++ return res.end(stripImageMetadata(body, req.headers['content-type']));
++ }
if (v2 && req.method === 'GET' && v2[2] === 'download') {
const o = uploads.find((u) => u.key === v2[1]);
if (!o) return json(404, { code: 'media.object.not_found' });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5af736c..00bd294 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -32,7 +32,7 @@ const INVENTORY = {
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'server/comments/service.js': [1, 'OpenVibe.Pics GET /api/v1/pictures/<id> at the fixed https://openvibe.pics origin, no redirect, to ask whether a picture is public; the id must match the picture-ID shape and is encoded'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
-- 'server/media/objects.js': [2, 'OV_MEDIA_INTERNAL_URL Object API v2, service token'],
++ 'server/media/objects.js': [3, 'OV_MEDIA_INTERNAL_URL Object API v2 and normalize-image paths, service token; fixed Media base, never a user URL'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],