openvibers/openvibe.community Commits
Orient Community images before stripping metadata (#349)
Changes
@@@ -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@@@ -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) {@@@ -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, viconst 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) {@@@ -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 };@@@ -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 = {}, limconst 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 = {}, limtry {// 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 = {}, limtype: '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 = {}, limif (!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');@@@ -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')));});@@@ -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' });@@@ -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'],
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
## Unreleased
## 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.
- 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
- **The wide Feed** (owner, 11-09: "one huge feed of everything, all the data, SDK and API enabled"). Pulse now carries
@@ -201,7 +201,7 @@ async function createApp(opts = {}) {
@@ -201,7 +201,7 @@ async function createApp(opts = {}) {
async upload({ buffer, filename, mime, owner = null, visibility = 'private' }) {
async upload({ buffer, filename, mime, owner = null, visibility = 'private' }) {
if (!mediaObjects.configured) return await mediaFiles.upload({ buffer, filename, mime });
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 });
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). */
/** A paste's screenshot bytes, for the proxy (a private object through a signed download). */
async bytes(row, variant = null) {
async bytes(row, variant = null) {
@@ -49,7 +49,6 @@ const { createPersonLimiter } = require('../limits');
@@ -49,7 +49,6 @@ const { createPersonLimiter } = require('../limits');
const { discussionModerator } = require('../identity/capabilities');
const { discussionModerator } = require('../identity/capabilities');
const { renderMarkdown, markdownToText } = require('../render/markdown');
const { renderMarkdown, markdownToText } = require('../render/markdown');
const { isUserSubject } = require('../vip');
const { isUserSubject } = require('../vip');
-const { stripImageMetadata } = require('../media/strip-metadata');
const { sniffImage } = require('../media/sniff-image');
const { sniffImage } = require('../media/sniff-image');
const reactions = require('./reactions');
const reactions = require('./reactions');
const moderation = require('./moderation');
const moderation = require('./moderation');
@@ -779,7 +778,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -779,7 +778,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mime = sniffImage(file.buffer);
const mime = sniffImage(file.buffer);
if (!mime) fail(415, 'attachments.unsupported', 'Attach a PNG, JPEG, GIF or WebP image');
if (!mime) fail(415, 'attachments.unsupported', 'Attach a PNG, JPEG, GIF or WebP image');
uploadLimiter.check(`s:${v.subject}`);
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';
const filename = String(file.originalname || 'image').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'image';
let stored;
let stored;
try { stored = await media.uploadImage({ buffer, mime, filename, owner: v.subject }); } catch (err) {
try { stored = await media.uploadImage({ buffer, mime, filename, owner: v.subject }); } catch (err) {
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
* the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
* namespace community):
* 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 }
* 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
* PUT /api/v2/community/objects/:id/content the bytes
* POST /api/v2/community/objects/:id/complete Media checks size, hash and type → ready
* POST /api/v2/community/objects/:id/complete Media checks size, hash and type → ready
@@ -48,14 +49,34 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -48,14 +49,34 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return await request(method, `${base}${path}`, opts);
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
* 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
* '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.
* 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; }
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 sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
const init = await call('POST', '', {
const init = await call('POST', '', {
json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source } },
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 } = {}) {
@@ -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 });
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('PUT', `/${id}/content`, { body: buffer, headers: { 'Content-Type': mime } });
await call('POST', `/${id}/complete`, { json: { content_hash: sha256 } });
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. */
/** 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 } = {}) {
@@ -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 { 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 };
module.exports = { createMediaObjects };
@@ -22,7 +22,6 @@ const { canonicalSlug, publicIdOfRef } = require('./url');
@@ -22,7 +22,6 @@ const { canonicalSlug, publicIdOfRef } = require('./url');
const spam = require('./spam');
const spam = require('./spam');
const commentStore = require('../comments/store');
const commentStore = require('../comments/store');
const { discussionModerator } = require('../identity/capabilities');
const { discussionModerator } = require('../identity/capabilities');
-const { stripImageMetadata } = require('../media/strip-metadata');
const { sniffImage } = require('../media/sniff-image');
const { sniffImage } = require('../media/sniff-image');
const { capabilities } = require('openvibe-contracts');
const { capabilities } = require('openvibe-contracts');
const { inspectEnvelope } = require('openvibe-sdk/locker/server');
const { inspectEnvelope } = require('openvibe-sdk/locker/server');
@@ -676,8 +675,6 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -676,8 +675,6 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const visibility = visibilityFor(audience);
const visibility = visibilityFor(audience);
const slug = await slugFor(v, body, visibility, truthy(body.burn_after_read));
const slug = await slugFor(v, body, visibility, truthy(body.burn_after_read));
const extra = serviceMetadata(v, body);
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');
const sourceHash = crypto.createHash('sha256').update(file.buffer).digest('hex');
if (v.subject && !truthy(body.keep_both)) {
if (v.subject && !truthy(body.keep_both)) {
const same = await store.findOwnedHash(db, v.subject, sourceHash);
const same = await store.findOwnedHash(db, v.subject, sourceHash);
@@ -687,17 +684,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -687,17 +684,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
try {
try {
// Screenshot bytes are private Media objects owned by the person: only Community's proxy
// Screenshot bytes are private Media objects owned by the person: only Community's proxy
// (which calls canView) serves them, never a bare Media URL.
// (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) {
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
}
}
+ const storedBytes = Number(stored.size_bytes ?? stored.size) || file.buffer.length;
const metadata = {
const metadata = {
...(extra || {}),
...(extra || {}),
page_url: body.page_url || (extra && extra.page_url) || null,
page_url: body.page_url || (extra && extra.page_url) || null,
user_agent: body.user_agent || ctx.userAgent || null,
user_agent: body.user_agent || ctx.userAgent || null,
original_name: file.originalname || null,
original_name: file.originalname || null,
- size_bytes: bytes.length,
+ size_bytes: storedBytes,
mime_type: mime,
mime_type: mime,
media_visibility: 'private',
media_visibility: 'private',
};
};
@@ -709,7 +707,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -709,7 +707,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
type: 'screenshot',
type: 'screenshot',
title: store.sanitizeTitle(body.title || 'Screenshot'),
title: store.sanitizeTitle(body.title || 'Screenshot'),
content: String(body.description || body.content || ''),
content: String(body.description || body.content || ''),
- content_hash: contentHash,
+ content_hash: stored.content_hash || sourceHash,
source_hash: sourceHash,
source_hash: sourceHash,
language: 'text',
language: 'text',
visibility,
visibility,
@@ -993,7 +991,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -993,7 +991,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!media) fail(503, 'Media service unavailable');
if (!media) fail(503, 'Media service unavailable');
let stored;
let stored;
try {
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) {
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
/**
/**
* Images on forum posts (WS-J task 2): an upload goes to OpenVibe.Media's Object API v2 as a med_ object
* 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
* 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
* 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.
* non-image, an anonymous upload and Media being unconfigured are refused.
@@ -12,6 +13,7 @@ const crypto = require('crypto');
@@ -12,6 +13,7 @@ const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { createMediaObjects } = require('../server/media/objects');
const { createMediaObjects } = require('../server/media/objects');
+const { stripImageMetadata } = require('../server/media/strip-metadata');
const { sniffImage } = require('../server/forum/service');
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.
// 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',
@@ -29,6 +31,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
(async () => {
(async () => {
// ── A stub Media (Object API v2 for tenant community) ──
// ── A stub Media (Object API v2 for tenant community) ──
const objects = new Map();
const objects = new Map();
+ let normalizedImages = 0;
const media = http.createServer((req, res) => {
const media = http.createServer((req, res) => {
const chunks = [];
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('data', (c) => chunks.push(c));
@@ -37,6 +40,11 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -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)); };
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.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.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') {
if (req.method === 'POST' && req.url === '/api/v2/community/objects') {
const b = JSON.parse(body);
const b = JSON.parse(body);
const id = ids.newId('media');
const id = ids.newId('media');
@@ -80,7 +88,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -80,7 +88,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
});
});
let first;
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);
const r = await upload(alexJwt);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
first = r.json().attachment;
first = r.json().attachment;
@@ -89,6 +97,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
@@ -89,6 +97,7 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
const o = objects.get(first.media_id);
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.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.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')));
assert.ok(PNG.includes(Buffer.from('secret')));
});
});
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'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
* 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.
* bytes (and, for v2, its kind, visibility and owner) so tests can check what left Community.
*/
*/
@@ -59,12 +59,18 @@ function start({ publicPem, issuer }) {
@@ -59,12 +59,18 @@ function start({ publicPem, issuer }) {
metadata: { filename: o.name } });
metadata: { filename: o.name } });
}
}
const v2 = req.url.match(/^\/api\/v2\/community\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete|download))?(?:\?.*)?$/);
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' });
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 });
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';
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' });
if (!(v.claims.cap || []).includes(verb)) return json(403, { code: 'capability.denied', error: 'not granted' });
const body = Buffer.concat(chunks);
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') {
if (v2 && req.method === 'GET' && v2[2] === 'download') {
const o = uploads.find((u) => u.key === v2[1]);
const o = uploads.find((u) => u.key === v2[1]);
if (!o) return json(404, { code: 'media.object.not_found' });
if (!o) return json(404, { code: 'media.object.not_found' });
@@ -32,7 +32,7 @@ const INVENTORY = {
@@ -32,7 +32,7 @@ const INVENTORY = {
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'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/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/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/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/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'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],