openvibers/openvibe.mediahub Commits
Pics: the same picture again is the one you already have; a near-duplicate is named (VIS-6) (#282)
Changes
@@@ -84,7 +84,10 @@ function createPicsApi(ctx) {const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200) });if (!out.ok) return problem(req, res, out.status, out.code, out.detail);res.set('Location', `/api/v1/images/${out.image.slug}`);-- return res.status(201).json({ ...pics.toWire(out.image, { owner: true }), ...(out.deleteKey ? { delete_key: out.deleteKey, delete_url: `${cfg.url}/i/${out.image.slug}/delete?key=${out.deleteKey}` } : {}) });++ // The same picture again: 200 with the one the person already has (duplicate: true), nothing stored twice.++ if (out.duplicate) return res.status(200).json({ ...pics.toWire(out.image, { owner: true }), duplicate: true });++ return res.status(201).json({ ...pics.toWire(out.image, { owner: true }), ...(out.deleteKey ? { delete_key: out.deleteKey, delete_url: `${cfg.url}/i/${out.image.slug}/delete?key=${out.deleteKey}` } : {}),++ ...(out.similar ? { similar_to: `${cfg.url}/i/${out.similar.slug}` } : {}) });});r.use(express.json({ limit: '64kb' }));@@@ -315,7 +315,8 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '' });if (!out.ok) return uploadFailed(req, res, out.detail, out.status);if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });-- return res.redirect(303, `/i/${out.image.slug}?new=1`);++ if (out.duplicate) return res.redirect(303, `/i/${out.image.slug}?again=1`);++ return res.redirect(303, `/i/${out.image.slug}?new=1${out.similar ? `&like=${encodeURIComponent(out.similar.slug)}` : ''}`);} finally {fs.rm(tmp, { force: true }, () => {});}@@@ -337,6 +338,9 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><const holding = owner ? await store.albumsHolding(s, row.id) : [];const title = row.title || 'Untitled picture';const hidden = row.state !== 'active';++ // ?like=<slug> after an upload: a near-duplicate of one of the person's own pictures (VIS-6). Only a slug of theirs is linked.++ const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;++ const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;return page(req, res, {title, noindex: true,description: row.description || `${title}: a picture on ${SITE_NAME}.`,@@@ -344,7 +348,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],...(owner ? { localStyles: ['css/labels.css'] } : {}),private: deleteKey ? true : undefined,-- body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}++ body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}<article class="ovp-picture">@@@ -38,6 +38,8 @@ const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note:// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.const DHASH_DISTANCE = 10;++// A person's own near-duplicate (a recompress, a resize, a screenshot of it): tighter than the takedown match.++const OWN_SIMILAR_DISTANCE = 6;const slug = () => Array.from(crypto.randomBytes(8), (b) => SLUG_ALPHABET[b % SLUG_ALPHABET.length]).join('');const cleanLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);@@@ -108,6 +110,12 @@ function createPics({ s, media, config, log = console, events = null }) {(log.warn || log.log)(`[OpenVibe.Pics] refused an upload matching a takedown (${hit.reason})`);return { ok: false, status: 422, code: 'pics.blocked', detail: 'This picture was taken down from OpenVibe.Pics and cannot be uploaded again.' };}++ // The same picture again, signed in (its exact bytes, or the copy served): the one the person already has, not a++ // second copy of it (VIS-6). A near-duplicate is stored as asked and named after (`similar`), since an edit may be meant.++ const own = anonymous ? [] : await store.ownHashes(s, who.requester);++ const same = own.find((r) => r.source_sha256 === prepared.sourceSha256 || r.sha256 === prepared.sha256);++ if (same) return { ok: true, image: await store.imageById(s, same.id), duplicate: true };++ const similar = distinctive(prepared.dhash) ? own.find((r) => images.distance(r.dhash, prepared.dhash) <= OWN_SIMILAR_DISTANCE) : null;await store.countUpload(s, uploader, day());const id = s.newId('pic');@@@ -128,7 +136,7 @@ function createPics({ s, media, config, log = console, events = null }) {});if (events && !anonymous) await events.pictureUploaded(r);return r; });-- return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };++ return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };}async function edit(row, { title, description, alt, labels: nextLabels }) {@@@ -38,6 +38,11 @@ async function imagesOf(s, owner, { limit = 48, before = null } = {}) {return { rows: rows.slice(0, limit), next: rows.length > limit ? rows[limit - 1].id : null };}++/** A person's pictures as their hashes, newest first: for "you uploaded this before" (VIS-6). */++const ownHashes = (s, owner, limit = 2000) => s.db.many(++ `SELECT id, slug, sha256, source_sha256, dhash, created_at FROM pics_images WHERE owner = $1 AND state = 'active' ORDER BY id DESC LIMIT $2`,++ [owner, limit]);++const usageOf = async (s, owner) => {const r = await s.db.maybe("SELECT count(*)::int AS n, coalesce(sum(bytes), 0)::bigint AS bytes FROM pics_images WHERE owner = $1 AND state <> 'removed'", [owner]);return { images: Number(r ? r.n : 0), bytes: Number(r ? r.bytes : 0) };@@@ -120,7 +125,7 @@ const removeFromAlbum = (s, albumId, imageId) => s.db.exec('DELETE FROM pics_albconst albumsHolding = (s, imageId) => s.db.many('SELECT a.* FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id WHERE ai.image_id = $1 ORDER BY a.title', [imageId]);module.exports = {-- imageBySlug, imageById, insertImage, updateImage, deleteImage, imagesOf, usageOf, countUpload, uploadsToday,++ imageBySlug, imageById, insertImage, updateImage, deleteImage, imagesOf, ownHashes, usageOf, countUpload, uploadsToday,blockedSha, blockedDhashes, block, addReport, reportsFor, staffQueue, staffCounts, idleAnonymous,albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,};@@@ -181,6 +181,25 @@ function multipart(fields, file) {assert.strictEqual(crossApi.json().code, 'request.cross_site');});++ await check('the same picture again is the one already there; a near-duplicate is stored and named (VIS-6)', async () => {++ const objects = t.media.objects.size;++ const again = await up(kim, jpegWithSecrets, { title: 'Again' });++ assert.strictEqual(again.status, 200, again.text);++ assert.deepStrictEqual([again.json().slug, again.json().duplicate, again.json().title], [slug, true, 'The coast'], 'the picture kim already has, unchanged');++ assert.strictEqual(t.media.objects.size, objects, 'nothing stored twice');++ const smaller = await sharp(jpegWithSecrets).rotate().resize(600).jpeg({ quality: 70 }).toBuffer();++ const near = await up(kim, smaller, { title: 'Smaller' });++ assert.strictEqual(near.status, 201, near.text);++ assert.strictEqual(near.json().similar_to, `https://openvibe.pics/i/${slug}`, 'named after the one it looks like');++ const page = await t.get(`/i/${near.json().slug}?new=1&like=${slug}`, { as: kim, headers: PICS });++ assert.ok(page.text.includes(`It looks like a picture you uploaded before: <a href="/i/${slug}">`), 'the page says so');++ const stranger = await t.get(`/i/${near.json().slug}?new=1&like=${slug}`, { headers: PICS });++ assert.ok(!stranger.text.includes('It looks like a picture you uploaded before'), 'only to its owner');++ const theirs = await up(lee, jpegWithSecrets, { title: 'Lee too' });++ assert.strictEqual(theirs.status, 201, 'another person\'s copy is theirs');++ assert.ok(!('duplicate' in theirs.json()));++ });++await check('only the uploader changes or deletes a picture', async () => {assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'PATCH', json: { title: 'mine now' }, headers: SAME })).status, 403);assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'DELETE', headers: SAME })).status, 403);
@@ -84,7 +84,10 @@ function createPicsApi(ctx) {
@@ -84,7 +84,10 @@ function createPicsApi(ctx) {
const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200) });
const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200) });
if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
res.set('Location', `/api/v1/images/${out.image.slug}`);
res.set('Location', `/api/v1/images/${out.image.slug}`);
- return res.status(201).json({ ...pics.toWire(out.image, { owner: true }), ...(out.deleteKey ? { delete_key: out.deleteKey, delete_url: `${cfg.url}/i/${out.image.slug}/delete?key=${out.deleteKey}` } : {}) });
+ // The same picture again: 200 with the one the person already has (duplicate: true), nothing stored twice.
+ if (out.duplicate) return res.status(200).json({ ...pics.toWire(out.image, { owner: true }), duplicate: true });
+ return res.status(201).json({ ...pics.toWire(out.image, { owner: true }), ...(out.deleteKey ? { delete_key: out.deleteKey, delete_url: `${cfg.url}/i/${out.image.slug}/delete?key=${out.deleteKey}` } : {}),
+ ...(out.similar ? { similar_to: `${cfg.url}/i/${out.similar.slug}` } : {}) });
});
});
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
@@ -315,7 +315,8 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -315,7 +315,8 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '' });
const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '' });
if (!out.ok) return uploadFailed(req, res, out.detail, out.status);
if (!out.ok) return uploadFailed(req, res, out.detail, out.status);
if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });
if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });
- return res.redirect(303, `/i/${out.image.slug}?new=1`);
+ if (out.duplicate) return res.redirect(303, `/i/${out.image.slug}?again=1`);
+ return res.redirect(303, `/i/${out.image.slug}?new=1${out.similar ? `&like=${encodeURIComponent(out.similar.slug)}` : ''}`);
} finally {
} finally {
fs.rm(tmp, { force: true }, () => {});
fs.rm(tmp, { force: true }, () => {});
}
}
@@ -337,6 +338,9 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -337,6 +338,9 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
const holding = owner ? await store.albumsHolding(s, row.id) : [];
const holding = owner ? await store.albumsHolding(s, row.id) : [];
const title = row.title || 'Untitled picture';
const title = row.title || 'Untitled picture';
const hidden = row.state !== 'active';
const hidden = row.state !== 'active';
+ // ?like=<slug> after an upload: a near-duplicate of one of the person's own pictures (VIS-6). Only a slug of theirs is linked.
+ const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;
+ const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;
return page(req, res, {
return page(req, res, {
title, noindex: true,
title, noindex: true,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
@@ -344,7 +348,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -344,7 +348,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
...(owner ? { localStyles: ['css/labels.css'] } : {}),
...(owner ? { localStyles: ['css/labels.css'] } : {}),
private: deleteKey ? true : undefined,
private: deleteKey ? true : undefined,
- body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}
+ body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}
${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}
<article class="ovp-picture">
<article class="ovp-picture">
@@ -38,6 +38,8 @@ const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note:
@@ -38,6 +38,8 @@ const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note:
// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:
// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:
// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.
// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.
const DHASH_DISTANCE = 10;
const DHASH_DISTANCE = 10;
+// A person's own near-duplicate (a recompress, a resize, a screenshot of it): tighter than the takedown match.
+const OWN_SIMILAR_DISTANCE = 6;
const slug = () => Array.from(crypto.randomBytes(8), (b) => SLUG_ALPHABET[b % SLUG_ALPHABET.length]).join('');
const slug = () => Array.from(crypto.randomBytes(8), (b) => SLUG_ALPHABET[b % SLUG_ALPHABET.length]).join('');
const cleanLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
const cleanLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
@@ -108,6 +110,12 @@ function createPics({ s, media, config, log = console, events = null }) {
@@ -108,6 +110,12 @@ function createPics({ s, media, config, log = console, events = null }) {
(log.warn || log.log)(`[OpenVibe.Pics] refused an upload matching a takedown (${hit.reason})`);
(log.warn || log.log)(`[OpenVibe.Pics] refused an upload matching a takedown (${hit.reason})`);
return { ok: false, status: 422, code: 'pics.blocked', detail: 'This picture was taken down from OpenVibe.Pics and cannot be uploaded again.' };
return { ok: false, status: 422, code: 'pics.blocked', detail: 'This picture was taken down from OpenVibe.Pics and cannot be uploaded again.' };
}
}
+ // The same picture again, signed in (its exact bytes, or the copy served): the one the person already has, not a
+ // second copy of it (VIS-6). A near-duplicate is stored as asked and named after (`similar`), since an edit may be meant.
+ const own = anonymous ? [] : await store.ownHashes(s, who.requester);
+ const same = own.find((r) => r.source_sha256 === prepared.sourceSha256 || r.sha256 === prepared.sha256);
+ if (same) return { ok: true, image: await store.imageById(s, same.id), duplicate: true };
+ const similar = distinctive(prepared.dhash) ? own.find((r) => images.distance(r.dhash, prepared.dhash) <= OWN_SIMILAR_DISTANCE) : null;
await store.countUpload(s, uploader, day());
await store.countUpload(s, uploader, day());
const id = s.newId('pic');
const id = s.newId('pic');
@@ -128,7 +136,7 @@ function createPics({ s, media, config, log = console, events = null }) {
@@ -128,7 +136,7 @@ function createPics({ s, media, config, log = console, events = null }) {
});
});
if (events && !anonymous) await events.pictureUploaded(r);
if (events && !anonymous) await events.pictureUploaded(r);
return r; });
return r; });
- return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };
+ return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };
}
}
async function edit(row, { title, description, alt, labels: nextLabels }) {
async function edit(row, { title, description, alt, labels: nextLabels }) {
@@ -38,6 +38,11 @@ async function imagesOf(s, owner, { limit = 48, before = null } = {}) {
@@ -38,6 +38,11 @@ async function imagesOf(s, owner, { limit = 48, before = null } = {}) {
return { rows: rows.slice(0, limit), next: rows.length > limit ? rows[limit - 1].id : null };
return { rows: rows.slice(0, limit), next: rows.length > limit ? rows[limit - 1].id : null };
}
}
+/** A person's pictures as their hashes, newest first: for "you uploaded this before" (VIS-6). */
+const ownHashes = (s, owner, limit = 2000) => s.db.many(
+ `SELECT id, slug, sha256, source_sha256, dhash, created_at FROM pics_images WHERE owner = $1 AND state = 'active' ORDER BY id DESC LIMIT $2`,
+ [owner, limit]);
+
const usageOf = async (s, owner) => {
const usageOf = async (s, owner) => {
const r = await s.db.maybe("SELECT count(*)::int AS n, coalesce(sum(bytes), 0)::bigint AS bytes FROM pics_images WHERE owner = $1 AND state <> 'removed'", [owner]);
const r = await s.db.maybe("SELECT count(*)::int AS n, coalesce(sum(bytes), 0)::bigint AS bytes FROM pics_images WHERE owner = $1 AND state <> 'removed'", [owner]);
return { images: Number(r ? r.n : 0), bytes: Number(r ? r.bytes : 0) };
return { images: Number(r ? r.n : 0), bytes: Number(r ? r.bytes : 0) };
@@ -120,7 +125,7 @@ const removeFromAlbum = (s, albumId, imageId) => s.db.exec('DELETE FROM pics_alb
@@ -120,7 +125,7 @@ const removeFromAlbum = (s, albumId, imageId) => s.db.exec('DELETE FROM pics_alb
const albumsHolding = (s, imageId) => s.db.many('SELECT a.* FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id WHERE ai.image_id = $1 ORDER BY a.title', [imageId]);
const albumsHolding = (s, imageId) => s.db.many('SELECT a.* FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id WHERE ai.image_id = $1 ORDER BY a.title', [imageId]);
module.exports = {
module.exports = {
- imageBySlug, imageById, insertImage, updateImage, deleteImage, imagesOf, usageOf, countUpload, uploadsToday,
+ imageBySlug, imageById, insertImage, updateImage, deleteImage, imagesOf, ownHashes, usageOf, countUpload, uploadsToday,
blockedSha, blockedDhashes, block, addReport, reportsFor, staffQueue, staffCounts, idleAnonymous,
blockedSha, blockedDhashes, block, addReport, reportsFor, staffQueue, staffCounts, idleAnonymous,
albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,
albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,
};
};
@@ -181,6 +181,25 @@ function multipart(fields, file) {
@@ -181,6 +181,25 @@ function multipart(fields, file) {
assert.strictEqual(crossApi.json().code, 'request.cross_site');
assert.strictEqual(crossApi.json().code, 'request.cross_site');
});
});
+ await check('the same picture again is the one already there; a near-duplicate is stored and named (VIS-6)', async () => {
+ const objects = t.media.objects.size;
+ const again = await up(kim, jpegWithSecrets, { title: 'Again' });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.deepStrictEqual([again.json().slug, again.json().duplicate, again.json().title], [slug, true, 'The coast'], 'the picture kim already has, unchanged');
+ assert.strictEqual(t.media.objects.size, objects, 'nothing stored twice');
+ const smaller = await sharp(jpegWithSecrets).rotate().resize(600).jpeg({ quality: 70 }).toBuffer();
+ const near = await up(kim, smaller, { title: 'Smaller' });
+ assert.strictEqual(near.status, 201, near.text);
+ assert.strictEqual(near.json().similar_to, `https://openvibe.pics/i/${slug}`, 'named after the one it looks like');
+ const page = await t.get(`/i/${near.json().slug}?new=1&like=${slug}`, { as: kim, headers: PICS });
+ assert.ok(page.text.includes(`It looks like a picture you uploaded before: <a href="/i/${slug}">`), 'the page says so');
+ const stranger = await t.get(`/i/${near.json().slug}?new=1&like=${slug}`, { headers: PICS });
+ assert.ok(!stranger.text.includes('It looks like a picture you uploaded before'), 'only to its owner');
+ const theirs = await up(lee, jpegWithSecrets, { title: 'Lee too' });
+ assert.strictEqual(theirs.status, 201, 'another person\'s copy is theirs');
+ assert.ok(!('duplicate' in theirs.json()));
+ });
+
await check('only the uploader changes or deletes a picture', async () => {
await check('only the uploader changes or deletes a picture', async () => {
assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'PATCH', json: { title: 'mine now' }, headers: SAME })).status, 403);
assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'PATCH', json: { title: 'mine now' }, headers: SAME })).status, 403);
assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'DELETE', headers: SAME })).status, 403);
assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'DELETE', headers: SAME })).status, 403);