openvibers/openvibe.community Commits

Deduplicate Community pastes by owner and content (#345)

OP OpenVibers committed 3 days ago · e66b3df0cf · 17 files, +212 −9

* Deduplicate Community pastes by owner and content

* Document duplicate paste lookup

Changes

ddiff --git a/migrations/0027_unlist_avatar_pastes.sql b/migrations/0027_unlist_avatar_pastes.sql
nnew file mode 100644
iindex 0000000..4573541
---- /dev/null
++++ b/migrations/0027_unlist_avatar_pastes.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- Profile pictures belong to their owner's avatar store. Keep the old rows and their owners, but remove them from
++-- public paste lists and preserve their direct links for the owner and existing references.
++UPDATE pastes
++ SET visibility = 'unlisted', updated_at = ov_now()
++ WHERE type = 'screenshot'
++ AND title = 'Avatar upload'
++ AND visibility = 'public'
++ AND metadata IS NOT NULL
++ AND (metadata::jsonb ->> 'kind') = 'avatar';
ddiff --git a/migrations/0028_owner_content_hash.sql b/migrations/0028_owner_content_hash.sql
nnew file mode 100644
iindex 0000000..3969198
---- /dev/null
++++ b/migrations/0028_owner_content_hash.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Exact content hash for owner-scoped paste deduplication and stable recent-list grouping.
++ALTER TABLE pastes ADD COLUMN content_hash text COLLATE "C";
++ALTER TABLE pastes ADD COLUMN source_hash text COLLATE "C";
++CREATE INDEX idx_pastes_owner_content_hash ON pastes (owner_subject, content_hash, created_at DESC)
++ WHERE owner_subject IS NOT NULL AND content_hash IS NOT NULL AND deleted_at IS NULL;
++CREATE INDEX idx_pastes_owner_source_hash ON pastes (owner_subject, source_hash, created_at DESC)
++ WHERE owner_subject IS NOT NULL AND source_hash IS NOT NULL AND deleted_at IS NULL;
ddiff --git a/public/js/community.js b/public/js/community.js
iindex 1bf5cd3..1124568 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -175,16 +175,30 @@
var shotStatus = $('[data-screenshot-status]', form);
var shotProgress = $('[data-screenshot-progress]', form);
var shotFile = null;
++ var shotDuplicate = null;
++ var shotKeepBoth = false;
++ var shotChecking = false;
var shotMeter = shotProgress && window.OVUpload ? window.OVUpload.progress(shotProgress) : null;
var shotPicker = shotRoot && window.OVUpload ? window.OVUpload.picker(shotRoot, {
label: 'Choose an image', accept: 'image/png,image/jpeg,image/webp,image/gif',
-- onFile: function (f) {
++ onFile: function (f, row) {
shotFile = f;
++ shotDuplicate = null; shotKeepBoth = false; shotChecking = false;
if (shotProgress) shotProgress.hidden = true;
if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, '');
setMode('image');
++ if (window.OVUpload && form.getAttribute('data-subject')) {
++ shotChecking = true;
++ window.OVUpload.preflight(f, function (_file, hash) {
++ return api('/api/pastes/duplicates?sha256=' + encodeURIComponent(hash)).then(function (out) { return out && out.duplicate ? out : null; });
++ }, row, {
++ onDuplicate: function (out) { shotDuplicate = out; sayShot('You already uploaded this image. Choose an option in its row.'); },
++ onUse: function (existing) { location.href = existing.address || existing.url; },
++ onAgain: function () { shotKeepBoth = true; shotDuplicate = null; sayShot('Uploading another copy.'); }
++ }).catch(function () { /* The server still checks uploads even if this read is unavailable. */ }).finally(function () { shotChecking = false; });
++ }
},
-- onRemove: function () { shotFile = null; }
++ onRemove: function () { shotFile = null; shotDuplicate = null; shotKeepBoth = false; shotChecking = false; }
}) : null;
function sayShot(text, bad) { if (!shotStatus) return; shotStatus.textContent = text || ''; shotStatus.className = 'form-status' + (bad ? ' error' : ''); }
var LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };
@@@ -407,12 +421,15 @@
var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };
if (mode === 'image') {
if (!shotFile) { btn.disabled = false; sayShot('Choose an image first.', true); return; }
++ if (shotChecking) { btn.disabled = false; sayShot('Checking whether you already uploaded this image.'); return; }
++ if (shotDuplicate && !shotKeepBoth) { btn.disabled = false; sayShot('Choose Open it, Use that one, or Upload again in the file row.', true); return; }
sayShot('Uploading your image…');
if (shotProgress) shotProgress.hidden = false;
if (shotMeter) shotMeter.update({ state: 'sending', done: 0, total: shotFile.size });
var fd = new FormData();
fd.append('screenshot', shotFile);
fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);
++ if (shotKeepBoth) fd.append('keep_both', '1');
if (common.is_nsfw) fd.append('is_nsfw', '1');
p = uploadScreenshot(fd);
} else {
ddiff --git a/server/app.js b/server/app.js
iindex ef1a3d8..4acd9e4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -276,6 +276,7 @@ async function createApp(opts = {}) {
app.locals.pastes = service;
app.locals.pasteMedia = pasteMedia;
app.locals.pasteReview = review;
++ if (opts.startPasteHashWorker !== false) app.locals.pasteHashTimer = require('./pastes/content-hash').start({ db, media: mediaObjects, screenshots: pasteMedia });
}
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
ddiff --git a/server/openapi.js b/server/openapi.js
iindex c62e064..e7b93ec 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -80,6 +80,13 @@ route('get', '/api/pastes', {
query('needs_ai', { type: 'string', description: 'Services holding community.paste.moderate only: pastes with no AI pass yet' })],
responses: { 200: json('A page of pastes', { type: 'object', properties: { pastes: arrayOf(ref('Paste')), total: { type: 'integer' } } }), ...errors([400, 403, 429]) },
});
++route('get', '/api/pastes/duplicates', {
++ operationId: 'findPasteDuplicate', summary: 'Check for your own matching paste', tags: ['pastes'], security: PERSON,
++ parameters: [query('sha256', { type: 'string', pattern: '^[a-fA-F0-9]{64}$', required: true, description: 'SHA-256 of the uploaded bytes' })],
++ responses: { 200: json('Whether this person already owns a matching paste', { type: 'object', properties: {
++ duplicate: { type: 'boolean' }, object: { type: 'object', properties: { id: str(), name: str(), date: str(), address: str(), url: str() } }, paste: ref('Paste'),
++ } }), ...errors([400, 401, 429]) },
++});
route('post', '/api/pastes', {
operationId: 'createPaste', summary: 'Create a paste', tags: ['pastes'], security: PERSON,
description: 'community.paste.create. The body is JSON, or multipart with a file (then it is a screenshot paste).',
@@@ -89,6 +96,7 @@ route('post', '/api/pastes', {
members: { type: 'array', items: shareMember, maxItems: 100, description: 'audience people only: who may open it' },
transfer_to: str({ description: "An exact @name: offer this paste's ownership in the same call" }),
keep_access: { type: 'boolean', default: false, description: 'With transfer_to: the current owner keeps viewing' },
++ keep_both: { type: 'boolean', default: false, description: 'Create another paste even when these exact bytes already belong to you' },
burn_after_read: { type: 'boolean' }, is_nsfw: { type: 'boolean' }, stream_id: str({ pattern: '^\\d{1,18}$' }),
metadata: { type: ['object', 'string'], description: 'Services only: a JSON object kept with the paste' },
ai_summary: str({ maxLength: 2000, description: 'A service with community.paste.moderate' }),
@@@ -99,7 +107,7 @@ route('post', '/api/pastes', {
} } },
'multipart/form-data': { schema: { type: 'object', required: ['file'], properties: {
file: { type: 'string', format: 'binary' }, title: str(), description: str(), page_url: str(), visibility: pasteVisibility, audience: pasteAudience,
-- members: str({ description: 'audience people only: @names, comma or space separated' }), transfer_to: str(), keep_access: { type: 'boolean' },
++ members: str({ description: 'audience people only: @names, comma or space separated' }), transfer_to: str(), keep_access: { type: 'boolean' }, keep_both: { type: 'boolean', default: false },
burn_after_read: { type: 'boolean' }, is_nsfw: { type: 'boolean' },
} } },
} },
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex 6903854..a6458a3 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -115,6 +115,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
const slug = (req) => req.params.slug;
// ── collection + literal paths (before /:slug) ───────────
++ router.get('/duplicates', anyCap, run(async (req) => await service.duplicate(req.viewer, req.query.sha256)));
router.get('/', anyCap, (req, res, next) => (req.query.needs_ai && req.viewer.kind !== 'service'
? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })
: next()), run(async (req) => await service.list(req.viewer, req.query)));
ddiff --git a/server/pastes/catalog.js b/server/pastes/catalog.js
iindex 050034f..063f679 100644
---- a/server/pastes/catalog.js
++++ b/server/pastes/catalog.js
@@@ -16,6 +16,7 @@
* text paste at all ("0 pastes in the latest 200" beside 59 on the newest page).
*/
const source = require('./source');
++const crypto = require('crypto');
const WINDOW = 200; // page cap for one window query
const LOCAL_TTL_MS = 2000;
@@@ -66,9 +67,30 @@ const recentOf = (type) => windowOf({ type });
function byViews(a, b) { return (b.views || 0) - (a.views || 0) || (b.likes || 0) - (a.likes || 0) || String(b.created_at).localeCompare(String(a.created_at)); }
function byNewest(a, b) { return String(b.created_at || '').localeCompare(String(a.created_at || '')); }
++function contentKey(p) {
++ if (p.content_hash && /^[a-f0-9]{64}$/i.test(p.content_hash)) return `content:${p.content_hash.toLowerCase()}`;
++ if (p.type === 'screenshot') {
++ const ref = String(p.media_ref || p.screenshot_url || '');
++ const id = /med_[0-9A-HJKMNP-TV-Z]{26}/.exec(ref);
++ return id ? `image:${id[0]}` : null;
++ }
++ return `text:${crypto.createHash('sha256').update(String(p.content || ''), 'utf8').digest('hex')}`;
++}
++function consecutiveUnique(rows) {
++ const out = [];
++ for (const p of rows) {
++ const owner = p.owner_subject || p.user_id || p.username;
++ const hash = contentKey(p);
++ const key = owner && hash ? `${owner}:${hash}` : null;
++ const previous = out[out.length - 1];
++ if (key && previous && previous._dedupeKey === key) { previous.more_like_it = (previous.more_like_it || 0) + 1; continue; }
++ out.push({ ...p, ...(key ? { _dedupeKey: key } : {}) });
++ }
++ return out.map(({ _dedupeKey, ...p }) => p);
++}
const fromOf = (v) => (Object.prototype.hasOwnProperty.call(FROM, v) ? v : 'all');
--async function latest(n = 12, { from = 'all' } = {}) { return _front(await windowOf({ from: fromOf(from) })).sort(byNewest).slice(0, n); }
++async function latest(n = 12, { from = 'all' } = {}) { return consecutiveUnique(_front(await windowOf({ from: fromOf(from) })).sort(byNewest)).slice(0, n); }
async function trending(n = 8, { from = 'all' } = {}) { return _front(await windowOf({ from: fromOf(from) })).sort(byViews).slice(0, n); }
/** Pastes in the same language first, then the newest, never the paste itself. */
ddiff --git a/server/pastes/content-hash.js b/server/pastes/content-hash.js
nnew file mode 100644
iindex 0000000..8ef99c2
---- /dev/null
++++ b/server/pastes/content-hash.js
@@@ -0,0 +1,42 @@
++'use strict';
++
++const crypto = require('crypto');
++
++/** Fill at most one small batch of legacy paste hashes. Media owns screenshot bytes and supplies their stored hash. */
++async function fillBatch({ db, media, screenshots, limit = 50 } = {}) {
++ const batch = Math.min(200, Math.max(1, Number(limit) || 50));
++ const rows = await db.prepare(`SELECT id, type, content, media_ref, screenshot_url FROM pastes
++ WHERE deleted_at IS NULL AND content_hash IS NULL ORDER BY id LIMIT ?`).all(batch);
++ let filled = 0;
++ for (const row of rows) {
++ let contentHash = null;
++ if (row.type === 'paste' && row.content != null) {
++ contentHash = crypto.createHash('sha256').update(String(row.content), 'utf8').digest('hex');
++ } else if (row.type === 'screenshot' && /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(row.media_ref || '')) && media?.object) {
++ try {
++ const object = await media.object(row.media_ref);
++ if (/^[a-f0-9]{64}$/i.test(String(object && object.content_hash || ''))) contentHash = String(object.content_hash).toLowerCase();
++ } catch { /* retry this object on the next bounded run */ }
++ } else if (row.type === 'screenshot' && screenshots?.bytes) {
++ try {
++ const bytes = await screenshots.bytes(row);
++ if (bytes && bytes.body) contentHash = crypto.createHash('sha256').update(bytes.body).digest('hex');
++ } catch { /* retry this object on the next bounded run */ }
++ }
++ if (!contentHash) continue;
++ const result = await db.prepare(`UPDATE pastes SET content_hash = ?, source_hash = COALESCE(source_hash, ?)
++ WHERE id = ? AND content_hash IS NULL`).run(contentHash, contentHash, row.id);
++ if (result.changes) filled++;
++ }
++ return { scanned: rows.length, filled };
++}
++
++function start({ db, media, screenshots, intervalMs = 60_000, log = console } = {}) {
++ const run = () => fillBatch({ db, media, screenshots }).catch((err) => log.warn('[Paste hashes] batch failed:', err.message));
++ run();
++ const timer = setInterval(run, Math.max(15_000, Number(intervalMs) || 60_000));
++ if (timer.unref) timer.unref();
++ return timer;
++}
++
++module.exports = { fillBatch, start };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 1782636..7117651 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -298,7 +298,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const projections = await projectionsFor(rows.map((r) => r.owner_subject));
return rows.map((r) => {
const out = shape(r, v, projections);
-- if (preview && !r.locked) out.content = r.type === 'paste' ? String(r.content || '').slice(0, 300) : null; // preview only in lists
++ if (preview && !r.locked) {
++ out.content = r.type === 'paste' ? String(r.content || '').slice(0, 300) : null; // preview only in lists
++ if (r.visibility === 'public') out.content_hash = r.content_hash || (r.type === 'paste' && !r.locked
++ ? crypto.createHash('sha256').update(String(r.content || ''), 'utf8').digest('hex') : null);
++ }
return out;
});
}
@@@ -486,6 +490,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
PasteError,
limits: L,
++ async duplicate(v, hash) {
++ if (!v || !v.subject) fail(401, 'Sign in to check your own uploads');
++ if (!/^[a-f0-9]{64}$/.test(String(hash || '').toLowerCase())) fail(400, 'sha256 must be a SHA-256 digest');
++ const row = await store.findOwnedHash(db, v.subject, String(hash).toLowerCase());
++ if (!row) return { duplicate: false };
++ const paste = await shapeOne(row, v);
++ const address = `/p/${canonicalSlug(row)}`;
++ return { duplicate: true, object: { id: row.slug, name: row.title || 'Untitled', date: row.created_at, address, url: address }, paste };
++ },
++
/** GET /api/pastes */
async list(v, q = {}) {
const needsAi = truthy(q.needs_ai);
@@@ -599,6 +613,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const content = body.content;
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
++ const contentHash = crypto.createHash('sha256').update(content.trim(), 'utf8').digest('hex');
++ if (v.subject && !truthy(body.keep_both)) {
++ const same = await store.findOwnedHash(db, v.subject, contentHash);
++ if (same) return { id: same.id, slug: same.slug, url: `/p/${canonicalSlug(same)}`, duplicate: true, paste: await shapeOne(same, v) };
++ }
const metadata = serviceMetadata(v, body);
let audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
audience = audienceFor(v, audience);
@@@ -617,6 +636,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
// An AI paste's title and body are AI text too; a person's are theirs, as written.
title: store.sanitizeTitle(body.title),
content: content.trim(),
++ content_hash: contentHash,
language: store.detectLanguage(content, body.language),
visibility,
stream_ref: streamRefFor(v, body),
@@@ -657,6 +677,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
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);
++ if (same) return { id: same.id, slug: same.slug, url: `/p/${canonicalSlug(same)}`, duplicate: true, paste: await shapeOne(same, v) };
++ }
let stored;
try {
// Screenshot bytes are private Media objects owned by the person: only Community's proxy
@@@ -683,6 +709,8 @@ 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,
++ source_hash: sourceHash,
language: 'text',
visibility,
screenshot_url: stored.url,
@@@ -726,6 +754,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const content = String(body.content);
if (content.length > L.maxSizeKb * 1024) fail(400, 'Too large');
patch.content = content;
++ patch.content_hash = crypto.createHash('sha256').update(content.trim(), 'utf8').digest('hex');
patch.language = store.detectLanguage(content, body.language);
}
if (reviewHidden(p)) {
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 2d24413..0b0f57c 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -169,7 +169,7 @@ async function canonicalForRef(db, ref) {
}
const INSERT_COLUMNS = ['slug', 'public_id', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',
-- 'screenshot_url', 'media_ref', 'stream_ref', 'metadata', 'burn_after_read', 'forked_from', 'pinned', 'is_nsfw',
++ 'screenshot_url', 'media_ref', 'stream_ref', 'metadata', 'content_hash', 'source_hash', 'burn_after_read', 'forked_from', 'pinned', 'is_nsfw',
'ai_summary', 'ai_tags', 'ai_analyzed_at', 'locked', 'lock_kind', 'envelope', 'title_shown'];
/** Insert a new paste (fields already validated); returns the stored row. */
@@@ -187,6 +187,9 @@ async function insertPaste(db, fields) {
});
}
++const findOwnedHash = (db, owner, hash) => db.prepare(`SELECT * FROM pastes WHERE owner_subject = ? AND (content_hash = ? OR source_hash = ?)
++ AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1`).get(owner, hash, hash);
++
/**
* Apply an edit. `patch` may hold title/content/language/visibility/is_nsfw/pinned. A change to
* title, content or language bumps the revision and appends a paste_versions row; the first
@@@ -199,7 +202,7 @@ async function updatePaste(db, id, patch, editedBy = null) {
if (!cur) return null;
const sets = [];
const params = [];
-- for (const k of ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned']) {
++ for (const k of ['title', 'content', 'content_hash', 'language', 'visibility', 'is_nsfw', 'pinned']) {
if (patch[k] === undefined) continue;
sets.push(`${k} = ?`);
params.push(patch[k]);
@@@ -477,7 +480,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
module.exports = { ensurePublicId, getByPublicId, canonicalForRef, canonicalSlug, publicIdOfRef,
generateSlug, slugTaken, retireSlug, sanitizeTitle, detectLanguage,
-- getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
++ getBySlug, getById, insertPaste, findOwnedHash, updatePaste, listVersions, softDelete, listPastes,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
getProjections, upsertProjection, subjectsByUsername,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 53622ac..118d6b0 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -195,7 +195,7 @@ function pasteKitCard(p, { eager = false } = {}) {
href: `/p/${pastePathSlug(p)}`, title: p.title || 'Untitled', type: 'post', ratio: '16/9', image: image || '',
badge: p.is_nsfw ? 'NSFW' : p.origin === 'ai' ? 'AI' : p.pinned ? 'Pinned' : '',
corner: isShot ? '' : languageLabel(p.language || 'text'),
-- preview: !isShot && !p.is_nsfw ? String(p.content || '') : '',
++ preview: !isShot && !p.is_nsfw ? `${String(p.content || '')}${p.more_like_it ? `\n and ${p.more_like_it} more like it` : ''}` : '',
who: authorName(p), avatar: avatarUrl(p, 68) || '',
count: [Number(p.views) || 0, Number(p.views) === 1 ? 'view' : 'views'], when: p.created_at || null,
});
ddiff --git a/test/paste-catalog.test.js b/test/paste-catalog.test.js
iindex bc79281..faa1fe5 100644
---- a/test/paste-catalog.test.js
++++ b/test/paste-catalog.test.js
@@@ -67,5 +67,16 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; conso
assert.strictEqual((await catalog.browse({ type: 'images', sort: 'views' })).pastes[1].slug, 'shot-198', 'the image list keeps it');
delete shots[198].title;
});
++ await check('latest collapses consecutive same-owner exact hashes and counts older copies', async () => {
++ const hash = 'a'.repeat(64);
++ const first = { slug: 'same-newest', type: 'paste', visibility: 'public', owner_subject: 'usr_fixture', content_hash: hash, content: 'same', created_at: at(2000) };
++ const previous = { ...first, slug: 'same-previous', created_at: at(1999) };
++ const otherOwner = { ...first, slug: 'other-owner', owner_subject: 'usr_other', created_at: at(1998) };
++ all.unshift(first, previous, otherOwner);
++ const latest = await catalog.latest(4);
++ assert.deepStrictEqual(latest.slice(0, 3).map((p) => p.slug), ['same-newest', 'other-owner', 'shot-198']);
++ assert.strictEqual(latest[0].more_like_it, 1);
++ all.splice(0, 3);
++ });
console.log(`paste catalog: ${passed} checks passed`);
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/paste-content-hash.test.js b/test/paste-content-hash.test.js
nnew file mode 100644
iindex 0000000..bcf36ae
---- /dev/null
++++ b/test/paste-content-hash.test.js
@@@ -0,0 +1,33 @@
++'use strict';
++const assert = require('assert');
++const crypto = require('crypto');
++const { fillBatch } = require('../server/pastes/content-hash');
++
++const rows = [
++ { id: 1, type: 'paste', content: 'hello', content_hash: null },
++ { id: 2, type: 'screenshot', media_ref: 'med_01JAB2C3D4E5F6G7H8J9K0MNPA', content_hash: null },
++ { id: 3, type: 'paste', content: 'later', content_hash: null },
++];
++const db = {
++ prepare(sql) {
++ return {
++ all: async (limit) => rows.filter((r) => !r.content_hash).slice(0, limit),
++ run: async (hash, sourceHash, id) => {
++ const row = rows.find((r) => r.id === id);
++ if (!row || row.content_hash) return { changes: 0 };
++ row.content_hash = hash; row.source_hash = row.source_hash || sourceHash; return { changes: 1 };
++ },
++ };
++ },
++};
++
++(async () => {
++ const imageHash = 'a'.repeat(64);
++ const result = await fillBatch({ db, limit: 2, media: { object: async () => ({ content_hash: imageHash }) } });
++ assert.deepStrictEqual(result, { scanned: 2, filled: 2 });
++ assert.strictEqual(rows[0].content_hash, crypto.createHash('sha256').update('hello').digest('hex'));
++ assert.deepStrictEqual([rows[1].content_hash, rows[1].source_hash], [imageHash, imageHash]);
++ const retry = await fillBatch({ db, limit: 2 });
++ assert.deepStrictEqual(retry, { scanned: 1, filled: 1 });
++ console.log('paste content hash: batches are bounded and retryable');
++})().catch((error) => { console.error(error); process.exit(1); });
ddiff --git a/test/paste-review.test.js b/test/paste-review.test.js
iindex 1f8dd0a..839f545 100644
---- a/test/paste-review.test.js
++++ b/test/paste-review.test.js
@@@ -62,6 +62,7 @@ const PNG = (() => {
const upload = async (cookie, fields = {}) => {
const fd = new FormData();
fd.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');
++ fd.append('keep_both', '1'); // each moderation scenario needs its own independent paste fixture
for (const [k, v] of Object.entries(fields)) fd.append(k, String(v));
return await t.get('/api/pastes/screenshot', { method: 'POST', body: fd, cookies: [`ov_token=${cookie}`] });
};
ddiff --git a/test/paste-sharing.test.js b/test/paste-sharing.test.js
iindex 632c9af..832dfe8 100644
---- a/test/paste-sharing.test.js
++++ b/test/paste-sharing.test.js
@@@ -347,6 +347,7 @@ const pastesApiModule = require('../server/pastes/api');
const fd = new FormData();
fd.append('title', 'Shot for everyone');
fd.append('audience', 'public');
++ fd.append('keep_both', '1');
fd.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'p.jpg');
const made = await t.get('/api/pastes/screenshot', { method: 'POST', cookies: [`ov_token=${alexJwt}`], body: fd });
assert.strictEqual(made.status, 201, made.text);
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex cfc55d9..d31532b 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -284,8 +284,22 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(meta.original_name, 'desk.jpg');
const row = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
assert.match(row.media_ref, /^med_[0-9A-HJKMNP-TV-Z]{26}$/); assert.strictEqual(row.media_ref, up.key);
++ const digest = require('crypto').createHash('sha256').update(jpeg).digest('hex');
++ const duplicate = await call('/api/pastes/duplicates?sha256=' + digest, { cookie: alexJwt });
++ assert.strictEqual(duplicate.status, 200);
++ assert.strictEqual(duplicate.json().duplicate, true);
++ assert.strictEqual(duplicate.json().object.address, `/p/${p.canonical_slug}`);
++ const uploadsBeforeDuplicate = t.media.uploads.length;
++ const already = new FormData();
++ already.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'again.jpg');
++ const rDup = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: already });
++ assert.strictEqual(rDup.status, 201);
++ assert.strictEqual(rDup.json().duplicate, true);
++ assert.strictEqual(rDup.json().paste.slug, p.slug);
++ assert.strictEqual(t.media.uploads.length, uploadsBeforeDuplicate, 'duplicate upload does not create another Media object');
shotSlug = p.slug; shotUrl = p.canonical_slug;
const viaCreate = new FormData();
++ viaCreate.append('keep_both', '1');
viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
const r2 = await call('/api/pastes', { method: 'POST', cookie: alexJwt, body: viaCreate });
assert.strictEqual(r2.status, 201, 'multipart POST / creates a screenshot paste too');
@@@ -295,6 +309,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: bad })).status, 400);
t.media.failNext();
const again = new FormData();
++ again.append('keep_both', '1');
again.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'c.jpg');
const failed = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: again });
assert.strictEqual(failed.status, 502);
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex e8f6764..0b59dbc 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -75,6 +75,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
const paste = async (who, fields) => ok(await send('POST', '/api/pastes', { who, json: fields }), `paste ${fields.title}`);
const shotPaste = async (who, fields) => {
const f = new FormData();
++ f.append('keep_both', '1'); // distinct privacy fixtures deliberately use identical screenshot bytes
for (const [k, v] of Object.entries(fields)) f.append(k, String(v));
f.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');
return ok(await send('POST', '/api/pastes/screenshot', { who, body: f }), `screenshot ${fields.title}`);