openvibers/openvibe.community Commits
A picture paste deleted and posted again works; a deleted paste's picture leaves Media (#415)
Changes
@@@ -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@@@ -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 } : {},});@@@ -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 };},@@@ -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' });@@@ -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
@@ -217,6 +217,12 @@ async function createApp(opts = {}) {
@@ -217,6 +217,12 @@ async function createApp(opts = {}) {
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, content_hash: o.content_hash, 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 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). */
/** 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) {
// A card's ?w=&fmt= rides on the same URL Media answered: public objects take it openly, a
// A card's ?w=&fmt= rides on the same URL Media answered: public objects take it openly, a
@@ -78,8 +78,12 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -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.
// 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);
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');
+ // 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', '', {
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' } },
metadata: { source, orientation_status: 'orientation applied' } },
headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},
headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},
});
});
@@ -795,6 +795,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -795,6 +795,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
});
});
await tell((pl) => pl.pasteGone(p.slug));
await tell((pl) => pl.pasteGone(p.slug));
pingIndexNow(p);
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 };
return { success: true };
},
},
@@ -93,18 +93,24 @@ function start({ publicPem, issuer }) {
@@ -93,18 +93,24 @@ function start({ publicPem, issuer }) {
if (req.method === 'POST' && !v2[1]) {
if (req.method === 'POST' && !v2[1]) {
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
const b = JSON.parse(body.toString() || '{}');
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');
const id = ids.newId('media');
pending.set(id, { ...b, owner: req.headers['x-ov-subject'] || null });
pending.set(id, { ...b, owner: req.headers['x-ov-subject'] || null });
return json(201, { id, object: { id } });
return json(201, { id, object: { id } });
}
}
const o = pending.get(v2[1]);
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 (!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 === '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') {
if (req.method === 'POST' && v2[2] === 'complete') {
const hash = crypto.createHash('sha256').update(o.bytes || Buffer.alloc(0)).digest('hex');
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' });
if (hash !== o.content_hash) return json(400, { code: 'media.object.hash_mismatch' });
pending.delete(v2[1]);
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(200, { id: v2[1], lifecycle_status: 'ready' });
}
}
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
@@ -316,6 +316,28 @@ const { boot, check, done } = require('./helpers/app');
@@ -316,6 +316,28 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(failed.json(), { error: 'Media service unavailable' });
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 () => {
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
// 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
// pass the type check and skip stripping (stripPng bails on the signature mismatch), so the GPS