openvibers/openvibe.community Commits
Deduplicate Community pastes by owner and content (#345)
Changes
nnew file mode 100644@@@ -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';nnew file mode 100644@@@ -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;@@@ -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 {@@@ -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({@@@ -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' },} } },} },@@@ -115,6 +115,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = nconst 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)));@@@ -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 queryconst 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. */nnew file mode 100644@@@ -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 };@@@ -298,7 +298,11 @@ function createPasteService({ db, network = null, media = null, config = {}, limconst 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 = {}, limPasteError,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 = {}, limconst 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 = {}, limconst 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 = {}, limtype: '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 = {}, limconst 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)) {@@@ -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,@@@ -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,});@@@ -67,5 +67,16 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; consoassert.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); });nnew file mode 100644@@@ -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); });@@@ -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 fixturefor (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}`] });};@@@ -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);@@@ -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);@@@ -75,6 +75,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVRconst 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 bytesfor (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}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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';
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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;
@@ -175,16 +175,30 @@
@@ -175,16 +175,30 @@
var shotStatus = $('[data-screenshot-status]', form);
var shotStatus = $('[data-screenshot-status]', form);
var shotProgress = $('[data-screenshot-progress]', form);
var shotProgress = $('[data-screenshot-progress]', form);
var shotFile = null;
var shotFile = null;
+ var shotDuplicate = null;
+ var shotKeepBoth = false;
+ var shotChecking = false;
var shotMeter = shotProgress && window.OVUpload ? window.OVUpload.progress(shotProgress) : null;
var shotMeter = shotProgress && window.OVUpload ? window.OVUpload.progress(shotProgress) : null;
var shotPicker = shotRoot && window.OVUpload ? window.OVUpload.picker(shotRoot, {
var shotPicker = shotRoot && window.OVUpload ? window.OVUpload.picker(shotRoot, {
label: 'Choose an image', accept: 'image/png,image/jpeg,image/webp,image/gif',
label: 'Choose an image', accept: 'image/png,image/jpeg,image/webp,image/gif',
- onFile: function (f) {
+ onFile: function (f, row) {
shotFile = f;
shotFile = f;
+ shotDuplicate = null; shotKeepBoth = false; shotChecking = false;
if (shotProgress) shotProgress.hidden = true;
if (shotProgress) shotProgress.hidden = true;
if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, '');
if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, '');
setMode('image');
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;
}) : null;
function sayShot(text, bad) { if (!shotStatus) return; shotStatus.textContent = text || ''; shotStatus.className = 'form-status' + (bad ? ' error' : ''); }
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' };
var LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };
@@ -407,12 +421,15 @@
@@ -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 };
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 (mode === 'image') {
if (!shotFile) { btn.disabled = false; sayShot('Choose an image first.', true); return; }
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…');
sayShot('Uploading your image…');
if (shotProgress) shotProgress.hidden = false;
if (shotProgress) shotProgress.hidden = false;
if (shotMeter) shotMeter.update({ state: 'sending', done: 0, total: shotFile.size });
if (shotMeter) shotMeter.update({ state: 'sending', done: 0, total: shotFile.size });
var fd = new FormData();
var fd = new FormData();
fd.append('screenshot', shotFile);
fd.append('screenshot', shotFile);
fd.append('title', common.title || 'Screenshot'); fd.append('description', el.description.value); fd.append('visibility', common.visibility);
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');
if (common.is_nsfw) fd.append('is_nsfw', '1');
p = uploadScreenshot(fd);
p = uploadScreenshot(fd);
} else {
} else {
@@ -276,6 +276,7 @@ async function createApp(opts = {}) {
@@ -276,6 +276,7 @@ async function createApp(opts = {}) {
app.locals.pastes = service;
app.locals.pastes = service;
app.locals.pasteMedia = pasteMedia;
app.locals.pasteMedia = pasteMedia;
app.locals.pasteReview = review;
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).
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
const anonWriteLimiter = rateLimit({
@@ -80,6 +80,13 @@ route('get', '/api/pastes', {
@@ -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' })],
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]) },
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', {
route('post', '/api/pastes', {
operationId: 'createPaste', summary: 'Create a paste', tags: ['pastes'], security: PERSON,
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).',
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', {
@@ -89,6 +96,7 @@ route('post', '/api/pastes', {
members: { type: 'array', items: shareMember, maxItems: 100, description: 'audience people only: who may open it' },
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" }),
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_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}$' }),
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' },
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' }),
ai_summary: str({ maxLength: 2000, description: 'A service with community.paste.moderate' }),
@@ -99,7 +107,7 @@ route('post', '/api/pastes', {
@@ -99,7 +107,7 @@ route('post', '/api/pastes', {
} } },
} } },
'multipart/form-data': { schema: { type: 'object', required: ['file'], properties: {
'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,
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' },
burn_after_read: { type: 'boolean' }, is_nsfw: { type: 'boolean' },
} } },
} } },
} },
} },
@@ -115,6 +115,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
@@ -115,6 +115,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
const slug = (req) => req.params.slug;
const slug = (req) => req.params.slug;
// ── collection + literal paths (before /: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'
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 })
? 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)));
: next()), run(async (req) => await service.list(req.viewer, req.query)));
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
* text paste at all ("0 pastes in the latest 200" beside 59 on the newest page).
* text paste at all ("0 pastes in the latest 200" beside 59 on the newest page).
*/
*/
const source = require('./source');
const source = require('./source');
+const crypto = require('crypto');
const WINDOW = 200; // page cap for one window query
const WINDOW = 200; // page cap for one window query
const LOCAL_TTL_MS = 2000;
const LOCAL_TTL_MS = 2000;
@@ -66,9 +67,30 @@ const recentOf = (type) => windowOf({ type });
@@ -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 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 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');
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); }
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. */
/** Pastes in the same language first, then the newest, never the paste itself. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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 };
@@ -298,7 +298,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -298,7 +298,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const projections = await projectionsFor(rows.map((r) => r.owner_subject));
const projections = await projectionsFor(rows.map((r) => r.owner_subject));
return rows.map((r) => {
return rows.map((r) => {
const out = shape(r, v, projections);
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;
return out;
});
});
}
}
@@ -486,6 +490,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -486,6 +490,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
PasteError,
PasteError,
limits: L,
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 */
/** GET /api/pastes */
async list(v, q = {}) {
async list(v, q = {}) {
const needsAi = truthy(q.needs_ai);
const needsAi = truthy(q.needs_ai);
@@ -599,6 +613,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -599,6 +613,11 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const content = body.content;
const content = body.content;
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
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)`);
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);
const metadata = serviceMetadata(v, body);
let audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
let audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
audience = audienceFor(v, audience);
audience = audienceFor(v, audience);
@@ -617,6 +636,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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.
// An AI paste's title and body are AI text too; a person's are theirs, as written.
title: store.sanitizeTitle(body.title),
title: store.sanitizeTitle(body.title),
content: content.trim(),
content: content.trim(),
+ content_hash: contentHash,
language: store.detectLanguage(content, body.language),
language: store.detectLanguage(content, body.language),
visibility,
visibility,
stream_ref: streamRefFor(v, body),
stream_ref: streamRefFor(v, body),
@@ -657,6 +677,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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 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 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;
let stored;
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
@@ -683,6 +709,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -683,6 +709,8 @@ 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,
+ source_hash: sourceHash,
language: 'text',
language: 'text',
visibility,
visibility,
screenshot_url: stored.url,
screenshot_url: stored.url,
@@ -726,6 +754,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -726,6 +754,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const content = String(body.content);
const content = String(body.content);
if (content.length > L.maxSizeKb * 1024) fail(400, 'Too large');
if (content.length > L.maxSizeKb * 1024) fail(400, 'Too large');
patch.content = content;
patch.content = content;
+ patch.content_hash = crypto.createHash('sha256').update(content.trim(), 'utf8').digest('hex');
patch.language = store.detectLanguage(content, body.language);
patch.language = store.detectLanguage(content, body.language);
}
}
if (reviewHidden(p)) {
if (reviewHidden(p)) {
@@ -169,7 +169,7 @@ async function canonicalForRef(db, ref) {
@@ -169,7 +169,7 @@ async function canonicalForRef(db, ref) {
}
}
const INSERT_COLUMNS = ['slug', 'public_id', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',
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'];
'ai_summary', 'ai_tags', 'ai_analyzed_at', 'locked', 'lock_kind', 'envelope', 'title_shown'];
/** Insert a new paste (fields already validated); returns the stored row. */
/** Insert a new paste (fields already validated); returns the stored row. */
@@ -187,6 +187,9 @@ async function insertPaste(db, fields) {
@@ -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
* 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
* 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) {
@@ -199,7 +202,7 @@ async function updatePaste(db, id, patch, editedBy = null) {
if (!cur) return null;
if (!cur) return null;
const sets = [];
const sets = [];
const params = [];
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;
if (patch[k] === undefined) continue;
sets.push(`${k} = ?`);
sets.push(`${k} = ?`);
params.push(patch[k]);
params.push(patch[k]);
@@ -477,7 +480,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
@@ -477,7 +480,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
module.exports = { ensurePublicId, getByPublicId, canonicalForRef, canonicalSlug, publicIdOfRef,
module.exports = { ensurePublicId, getByPublicId, canonicalForRef, canonicalSlug, publicIdOfRef,
generateSlug, slugTaken, retireSlug, sanitizeTitle, detectLanguage,
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,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
getProjections, upsertProjection, subjectsByUsername,
getProjections, upsertProjection, subjectsByUsername,
@@ -195,7 +195,7 @@ function pasteKitCard(p, { eager = false } = {}) {
@@ -195,7 +195,7 @@ function pasteKitCard(p, { eager = false } = {}) {
href: `/p/${pastePathSlug(p)}`, title: p.title || 'Untitled', type: 'post', ratio: '16/9', image: image || '',
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' : '',
badge: p.is_nsfw ? 'NSFW' : p.origin === 'ai' ? 'AI' : p.pinned ? 'Pinned' : '',
corner: isShot ? '' : languageLabel(p.language || 'text'),
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) || '',
who: authorName(p), avatar: avatarUrl(p, 68) || '',
count: [Number(p.views) || 0, Number(p.views) === 1 ? 'view' : 'views'], when: p.created_at || null,
count: [Number(p.views) || 0, Number(p.views) === 1 ? 'view' : 'views'], when: p.created_at || null,
});
});
@@ -67,5 +67,16 @@ const check = async (name, fn) => { catalog.reset(); await fn(); passed++; conso
@@ -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');
assert.strictEqual((await catalog.browse({ type: 'images', sort: 'views' })).pastes[1].slug, 'shot-198', 'the image list keeps it');
delete shots[198].title;
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`);
console.log(`paste catalog: ${passed} checks passed`);
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -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); });
@@ -62,6 +62,7 @@ const PNG = (() => {
@@ -62,6 +62,7 @@ const PNG = (() => {
const upload = async (cookie, fields = {}) => {
const upload = async (cookie, fields = {}) => {
const fd = new FormData();
const fd = new FormData();
fd.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');
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));
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}`] });
return await t.get('/api/pastes/screenshot', { method: 'POST', body: fd, cookies: [`ov_token=${cookie}`] });
};
};
@@ -347,6 +347,7 @@ const pastesApiModule = require('../server/pastes/api');
@@ -347,6 +347,7 @@ const pastesApiModule = require('../server/pastes/api');
const fd = new FormData();
const fd = new FormData();
fd.append('title', 'Shot for everyone');
fd.append('title', 'Shot for everyone');
fd.append('audience', 'public');
fd.append('audience', 'public');
+ fd.append('keep_both', '1');
fd.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'p.jpg');
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 });
const made = await t.get('/api/pastes/screenshot', { method: 'POST', cookies: [`ov_token=${alexJwt}`], body: fd });
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
@@ -284,8 +284,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -284,8 +284,22 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(meta.original_name, 'desk.jpg');
assert.strictEqual(meta.original_name, 'desk.jpg');
const row = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
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);
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;
shotSlug = p.slug; shotUrl = p.canonical_slug;
const viaCreate = new FormData();
const viaCreate = new FormData();
+ viaCreate.append('keep_both', '1');
viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
const r2 = await call('/api/pastes', { method: 'POST', cookie: alexJwt, body: viaCreate });
const r2 = await call('/api/pastes', { method: 'POST', cookie: alexJwt, body: viaCreate });
assert.strictEqual(r2.status, 201, 'multipart POST / creates a screenshot paste too');
assert.strictEqual(r2.status, 201, 'multipart POST / creates a screenshot paste too');
@@ -295,6 +309,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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);
assert.strictEqual((await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: bad })).status, 400);
t.media.failNext();
t.media.failNext();
const again = new FormData();
const again = new FormData();
+ again.append('keep_both', '1');
again.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'c.jpg');
again.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'c.jpg');
const failed = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: again });
const failed = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: again });
assert.strictEqual(failed.status, 502);
assert.strictEqual(failed.status, 502);
@@ -75,6 +75,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
@@ -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 paste = async (who, fields) => ok(await send('POST', '/api/pastes', { who, json: fields }), `paste ${fields.title}`);
const shotPaste = async (who, fields) => {
const shotPaste = async (who, fields) => {
const f = new FormData();
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));
for (const [k, v] of Object.entries(fields)) f.append(k, String(v));
f.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');
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}`);
return ok(await send('POST', '/api/pastes/screenshot', { who, body: f }), `screenshot ${fields.title}`);