openvibers/openvibe.community Commits

A picture paste deleted and posted again works; a deleted paste's picture leaves Media (#415)

OP OpenVibers committed 17 h ago · 88d7f6e967 · 5 files, +47 −2

11-12: someone posted a picture paste, deleted it from its page 9 s later, and every repost of the same picture failed with 502 "Media service unavailable" (Media PUT .../content 409: Object is ready, three times). Media answers an owner's second upload of the same bytes with the existing object ({ duplicate: true }), and uploadImage then tried to upload into that ready object. Every Community upload now asks for its own object (keep_both: true): Community finds paste duplicates itself, and a post, a comment and a paste each delete their own object, so sharing one would break the others. Forum and comment pictures had the same failure for a picture used twice.

Deleting a paste ("There is no undo") now deletes its picture in Media too, unless another live paste shows the same object; it used to stay in Media for good.

The test Media answers duplicates and refuses content for a ready object, as Media does. The new check fails with the production 502 without the fix.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 60a7f80..8470825 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -217,6 +217,12 @@ async function createApp(opts = {}) {
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, content_hash: o.content_hash, media_ref: o.id, private: visibility === 'private' };
},
++ /** A deleted paste's picture goes from Media too (best effort: Media keeps a deleted object for its retention). */
++ async remove(ref) {
++ if (!mediaObjects.configured || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(ref || ''))) return false;
++ await mediaObjects.deleteObject(ref);
++ return true;
++ },
/** A paste's screenshot bytes, for the proxy (a private object through a signed download). */
async bytes(row, variant = null) {
// A card's ?w=&fmt= rides on the same URL Media answered: public objects take it openly, a
ddiff --git a/server/media/objects.js b/server/media/objects.js
iindex 0f8000d..feb6862 100644
---- a/server/media/objects.js
++++ b/server/media/objects.js
@@@ -78,8 +78,12 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
// 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');
++ // keep_both: every picture Community stores is its own object. Without it Media answers a picture the person
++ // already stored with that object ({ duplicate: true }), whose content is already there, so the upload below
++ // got 409 "Object is ready": a paste deleted and posted again failed every time (11-12). Community finds its own
++ // duplicates first (a paste's source hash), and a post, a comment and a paste each delete their own object.
const init = await call('POST', '', {
-- json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256,
++ json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, keep_both: true,
metadata: { source, orientation_status: 'orientation applied' } },
headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},
});
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 4816e5b..6c3ba1c 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -795,6 +795,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
});
await tell((pl) => pl.pasteGone(p.slug));
pingIndexNow(p);
++ // "Delete this paste? There is no undo": its picture goes too, unless another paste still shows the same object.
++ if (p.media_ref && media && typeof media.remove === 'function') {
++ try {
++ const other = await db.prepare('SELECT 1 FROM pastes WHERE media_ref = ? AND id <> ? AND deleted_at IS NULL LIMIT 1').get(p.media_ref, p.id);
++ if (!other) await media.remove(p.media_ref);
++ } catch (err) { console.warn('[Pastes] picture cleanup failed:', err.message); }
++ }
return { success: true };
},
ddiff --git a/test/helpers/mock-media.js b/test/helpers/mock-media.js
iindex 97d8126..248b537 100644
---- a/test/helpers/mock-media.js
++++ b/test/helpers/mock-media.js
@@@ -93,18 +93,24 @@ function start({ publicPem, issuer }) {
if (req.method === 'POST' && !v2[1]) {
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
const b = JSON.parse(body.toString() || '{}');
++ // Like Media: the same owner's ready object with the same hash answers as a duplicate unless keep_both.
++ const owner = req.headers['x-ov-subject'] || null;
++ const same = owner && b.content_hash && !['1', 'true'].includes(String(b.keep_both || '').toLowerCase())
++ && uploads.find((u) => u.v2 && u.owner === owner && u.content_hash === String(b.content_hash).toLowerCase());
++ if (same) return json(200, { duplicate: true, id: same.key, object: { id: same.key } });
const id = ids.newId('media');
pending.set(id, { ...b, owner: req.headers['x-ov-subject'] || null });
return json(201, { id, object: { id } });
}
const o = pending.get(v2[1]);
++ if (!o && req.method === 'PUT' && uploads.some((u) => u.key === v2[1])) return json(409, { code: 'media.object.conflict', error: 'Object is ready' });
if (!o) return json(404, { code: 'media.object.not_found' });
if (req.method === 'PUT' && v2[2] === 'content') { o.bytes = body; o.mime = String(req.headers['content-type'] || ''); return json(200, { id: v2[1], size_bytes: body.length }); }
if (req.method === 'POST' && v2[2] === 'complete') {
const hash = crypto.createHash('sha256').update(o.bytes || Buffer.alloc(0)).digest('hex');
if (hash !== o.content_hash) return json(400, { code: 'media.object.hash_mismatch' });
pending.delete(v2[1]);
-- uploads.push({ key: v2[1], name: o.filename || 'upload.bin', mime: o.mime, bytes: o.bytes, claims: v.claims, kind: o.kind, visibility: o.visibility, owner: o.owner, v2: true });
++ uploads.push({ key: v2[1], name: o.filename || 'upload.bin', mime: o.mime, bytes: o.bytes, claims: v.claims, kind: o.kind, visibility: o.visibility, owner: o.owner, v2: true, content_hash: hash });
return json(200, { id: v2[1], lifecycle_status: 'ready' });
}
return json(404, { error: 'not found' });
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex d31532b..2aa65b2 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -316,6 +316,28 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(failed.json(), { error: 'Media service unavailable' });
});
++ await check('a picture paste deleted and posted again is a new paste, and its picture leaves Media (11-12)', async () => {
++ // The same person's same picture: Media answered the old object as a duplicate, and the upload into it got 409
++ // "Object is ready", so every repost failed with 502 after a delete.
++ const bytes = Buffer.concat([jpeg, Buffer.from('repost-1112')]);
++ const post = (extra = {}) => { const fd = new FormData(); for (const [k, v] of Object.entries(extra)) fd.append(k, v); fd.append('screenshot', new Blob([bytes], { type: 'image/jpeg' }), 'n.jpg'); return call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: fd }); };
++ const first = await post({ title: 'Nvidia thing' });
++ assert.strictEqual(first.status, 201, first.text);
++ const one = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(first.json().slug);
++ const gone = await call(`/api/pastes/${first.json().slug}`, { method: 'DELETE', cookie: alexJwt });
++ assert.strictEqual(gone.status, 200, gone.text);
++ assert.ok(t.media.deletes.includes(one.media_ref), 'the deleted paste\'s picture is deleted in Media');
++ const again = await post({ title: 'Nvidia thing' });
++ assert.strictEqual(again.status, 201, `posted again: ${again.status} ${again.text}`);
++ const two = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(again.json().slug);
++ assert.notStrictEqual(two.media_ref, one.media_ref);
++ // Kept both on purpose while the first is still there: its own object too, never Media's duplicate.
++ const both = await post({ keep_both: '1' });
++ assert.strictEqual(both.status, 201, `kept both: ${both.status} ${both.text}`);
++ const three = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(both.json().slug);
++ assert.ok(three.media_ref && three.media_ref !== two.media_ref, 'a second object of its own');
++ });
++
await check('screenshot upload: the type comes from the bytes, not the declared Content-Type', async () => {
// A JPEG whose bytes still carry EXIF, uploaded as image/png. Trusting the declared type let it
// pass the type check and skip stripping (stripPng bails on the signature mismatch), so the GPS