openvibers/openvibe.live Commits

Keep profile pictures out of public pastes (#763)

OP OpenVibers committed 3 days ago · 6f233c6688 · 8 files, +144 −32

Changes

ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex a668e6e8..fc156e16 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1688,15 +1688,16 @@ async function loadHomePastes(opts) {
· ${timeAgo(p.created_at)}
</div>
${_cardAiHTML(p.ai_summary)}
++ ${p.more_like_it && p.ai_summary ? `<div class="home-paste-preview">and ${Number(p.more_like_it)} more like it</div>` : ''}
</div>
</div>
</a>`;
}).join(''));
if (window.OVDensity) OVDensity.attach(list, { key: 'pastes', header: document.getElementById('home-pastes-header') });
-- homeRailLoaded(rail, offset, pastes.length, data.total);
++ homeRailLoaded(rail, offset, data.raw_count || pastes.length, data.total);
renderHomePagination('home-pastes-pagination', rail);
};
-- await apiSWR(`/pastes?limit=${limit}&offset=${offset}&origin=user`, render, { ttl: 180000 });
++ await apiSWR(`/pastes?limit=${limit}&offset=${offset}&origin=user&dedupe=consecutive`, render, { ttl: 180000 });
} catch { rail.filling = false; }
}
ddiff --git a/public/js/settings.js b/public/js/settings.js
iindex f5c78693..a32379b6 100644
---- a/public/js/settings.js
++++ b/public/js/settings.js
@@@ -107,7 +107,7 @@ function _renderSettingsAvatar(u) {
: letter) + chip;
}
--// Render the user's avatar upload history (backed by avatar-tagged pastes).
++// Render the user's avatar history from Media plus older avatar-tagged Community pastes.
async function _loadAvatarHistory() {
const card = document.getElementById('settings-avatar-history-card');
const wrap = document.getElementById('settings-avatar-history');
@@@ -120,13 +120,24 @@ async function _loadAvatarHistory() {
wrap.innerHTML = avatars.map(a => `
<button type="button" class="settings-avatar-hist-item${a.active ? ' active' : ''}"
title="${a.active ? 'Current avatar' : 'Use this avatar'}"
-- onclick="reuseAvatarPaste('${a.slug}')">
++ onclick="${a.store === 'media' ? `reuseAvatarObject('${a.id}')` : `reuseAvatarPaste('${a.slug}')`}">
<img src="${a.url}" alt="" loading="lazy">
${a.active ? '<span class="settings-avatar-hist-active"><i class="fa-solid fa-check"></i></span>' : ''}
</button>`).join('');
} catch { card.style.display = 'none'; }
}
++async function reuseAvatarObject(id) {
++ try {
++ const data = await api(`/auth/avatar/object/${encodeURIComponent(id)}`, { method: 'POST' });
++ toast('Avatar updated', 'success');
++ if (data.avatar_url && currentUser) currentUser.avatar_url = data.avatar_url;
++ _renderSettingsAvatar(currentUser);
++ _loadAvatarHistory();
++ if (typeof onAuthChange === 'function') onAuthChange();
++ } catch (e) { toast(e.message || 'Failed to set avatar', 'error'); }
++}
++
// Re-activate a previously uploaded avatar from history.
async function reuseAvatarPaste(slug) {
try {
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 1d5e2efc..5ba5f267 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -203,29 +203,15 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
return res.status(400).json({ error: 'That file is not a valid or is a corrupt image.' });
}
-- // Back the avatar with a paste: every upload becomes an avatar-tagged
-- // screenshot paste (now stored in OpenVibe.Media), giving users an upload
-- // history and letting staff moderate avatar images via the paste tooling.
++ // Keep the uploaded bytes in Media while Network adopts them into the person's avatar store.
const media = require('../media-client');
-- // Default to a PUBLIC paste (checkbox checked); accept opt-out via `public=false`.
-- const makePublic = req.body.public === undefined ? true : !(req.body.public === 'false' || req.body.public === false || req.body.public === '0');
const imageBuffer = fs.readFileSync(req.file.path);
try { fs.unlinkSync(req.file.path); } catch { /* */ }
-- const paste = await require('../pastes-client').createPaste({
-- title: 'Avatar upload',
-- content: '',
-- language: 'text',
-- visibility: makePublic ? 'public' : 'unlisted',
-- user_id: req.user.id,
-- metadata: JSON.stringify({ kind: 'avatar', original_name: req.file.originalname || null, mime_type: req.file.mimetype || null }),
-- screenshot: {
-- buffer: imageBuffer,
-- filename: req.file.originalname || 'avatar.png',
-- contentType: req.file.mimetype || 'image/png',
-- },
-- });
-- const screenshotUrl = media.publicUrl(paste.screenshot_url) || media.pasteScreenshotUrl(paste.slug);
-- await db.updateUserAvatar(req.user.id, screenshotUrl, paste.id || null);
++ const staged = await media.uploadFile({ buffer: imageBuffer, filename: 'avatar-upload', contentType: req.file.mimetype || 'application/octet-stream' },
++ { user_id: req.user.id }, { actingUser: req.user.id });
++ if (!staged || !staged.key || !staged.url) throw new Error('Media did not return the uploaded picture address');
++ const avatarUrl = staged.url;
++ await db.updateUserAvatar(req.user.id, avatarUrl, null);
// The Network copies the picture into the person's own avatar object (the paste stays as the source, and
// can be cleaned up without breaking the avatar); Live then keeps that object's address.
@@@ -233,11 +219,12 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
try {
const synced = await require('../utils/notify').syncAvatarChange(view);
if (synced && synced.avatar_url && synced.avatar_url !== view.avatar_url) {
-- await db.updateUserAvatar(req.user.id, synced.avatar_url, paste.id || null);
++ await db.updateUserAvatar(req.user.id, synced.avatar_url, null);
view = await db.getUserById(req.user.id);
++ await media.deleteFile(staged.key, { actingUser: req.user.id }).catch(() => {});
}
} catch { /* the Network copies it on the next report; the paste still works until then */ }
-- res.json({ user: await sanitizeUser(view), avatar_url: view.avatar_url || screenshotUrl });
++ res.json({ user: await sanitizeUser(view), avatar_url: view.avatar_url || avatarUrl });
} catch (err) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
console.error('[Auth] Avatar upload error:', err.message);
@@@ -245,15 +232,13 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
}
});
--// The user's avatar upload history (avatar-tagged screenshot pastes, in OpenVibe.Community).
--// Pastes have no metadata filter upstream, so
--// lookups.avatarPastes lists the person's screenshots and keeps the avatar-tagged ones.
++// The user's avatar history: native Media avatar objects, plus old avatar-tagged Community pastes.
router.get('/avatar/history', requireAuth, async (req, res) => {
try {
const me = await db.getUserById(req.user.id);
const activePasteId = me?.avatar_paste_id || null;
const media = require('../media-client');
-- const rows = (await require('../media-proxy/lookups').avatarPastes(me)).map(r => {
++ const legacyRows = (await require('../media-proxy/lookups').avatarPastes(me)).map(r => {
const url = media.publicUrl(r.screenshot_url);
return {
slug: r.slug,
@@@ -263,6 +248,17 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
active: (activePasteId != null && r.id === activePasteId) || (!!url && url === me.avatar_url),
};
}).filter(r => r.url);
++ let nativeRows = [];
++ try {
++ const subject = me.subject_id || await require('../pastes-client').subjectForLiveUser(me.id);
++ if (subject) {
++ const listed = await media.listObjects({ kind: 'avatar', owner: subject, limit: 60 }, { actingUser: me.id });
++ nativeRows = (listed.objects || []).map(o => ({ id: o.id, slug: o.id, title: o.metadata?.title || 'Profile picture',
++ created_at: o.created_at, url: o.public_url || `${media.MEDIA_PUBLIC_URL}/o/${encodeURIComponent(o.id)}`,
++ active: !!(me.avatar_url && (o.public_url === me.avatar_url || `${media.MEDIA_PUBLIC_URL}/o/${o.id}` === me.avatar_url)), store: 'media' }));
++ }
++ } catch { /* old Community history remains usable while Media is unavailable */ }
++ const rows = [...nativeRows, ...legacyRows].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))).slice(0, 60);
res.json({ avatars: rows });
} catch (err) {
console.error('[Auth] Avatar history error:', err.message);
@@@ -270,6 +266,29 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
}
});
++router.post('/avatar/object/:id', requireAuth, async (req, res) => {
++ try {
++ const me = await db.getUserById(req.user.id);
++ const subject = me?.subject_id || await require('../pastes-client').subjectForLiveUser(me.id);
++ if (!subject) return res.status(404).json({ error: 'Picture not found' });
++ const media = require('../media-client');
++ const object = await media.getObject(req.params.id, { actingUser: me.id });
++ if (!object || object.kind !== 'avatar' || !object.owner || object.owner.subject !== subject) return res.status(404).json({ error: 'Picture not found' });
++ const sourceUrl = object.public_url || `${media.MEDIA_PUBLIC_URL}/o/${encodeURIComponent(object.id)}`;
++ await db.updateUserAvatar(me.id, sourceUrl, null);
++ let view = await db.getUserById(me.id);
++ const synced = await require('../utils/notify').syncAvatarChange(view).catch(() => null);
++ if (synced?.avatar_url) {
++ await db.updateUserAvatar(me.id, synced.avatar_url, null);
++ view = await db.getUserById(me.id);
++ }
++ res.json({ success: true, user: await sanitizeUser(view), avatar_url: view.avatar_url || sourceUrl });
++ } catch (err) {
++ console.error('[Auth] Avatar restore error:', err.message);
++ res.status(500).json({ error: 'Failed to restore avatar' });
++ }
++});
++
// ═══════════════════════════════════════════════════════════════
// OpenVibe.Tools OAuth2 SSO Integration
// ═══════════════════════════════════════════════════════════════
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 6c80a180..f691f30b 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -317,6 +317,10 @@ async function uploadFile(file, fields = {}, opts = {}) {
return await request('POST', '/files', { body: fd, timeoutMs: 120000, ...opts });
}
++async function getObject(id, opts = {}) { return await request('GET', `/objects/${encodeURIComponent(id)}`, opts); }
++async function listObjects(query = {}, opts = {}) { return await request('GET', '/objects', { query, ...opts }); }
++
++
async function getFileMeta(key, opts = {}) {
return await request('GET', `/files/${encodeURIComponent(key)}`, opts);
}
@@@ -469,7 +473,7 @@ module.exports = {
// clips
createClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,
// files + thumbnails
-- uploadFile, getFileMeta, deleteFile,
++ uploadFile, getFileMeta, deleteFile, getObject, listObjects,
uploadThumbnail, generateThumbnail,
// URL builders
vodPlaybackUrl, vodDvrPlaylistUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex c3751704..dbd4c5f9 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -33,6 +33,24 @@ const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next(
const pastesClient = require('../pastes-client');
const principal = require('../net/network-principal');
const { Readable } = require('stream');
++const crypto = require('crypto');
++
++function consecutiveUnique(pastes) {
++ const out = [];
++ for (const p of pastes || []) {
++ const owner = p.owner_subject || p.user_id || p.username;
++ const ref = String(p.media_ref || p.screenshot_url || '');
++ const mediaId = /med_[0-9A-HJKMNP-TV-Z]{26}/.exec(ref);
++ const fingerprint = p.content_hash ? `content:${String(p.content_hash).toLowerCase()}`
++ : p.type === 'screenshot' ? (mediaId ? `image:${mediaId[0]}` : null)
++ : `text:${crypto.createHash('sha256').update(String(p.content || ''), 'utf8').digest('hex')}`;
++ const key = owner && fingerprint ? `${owner}:${fingerprint}` : 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);
++}
/** `whenDown(req)`: the answer while Community is unreachable or failing (5xx), instead of a 502. */
function toCommunity(subPath, whenDown = null) {
@@@ -59,6 +77,11 @@ function toCommunity(subPath, whenDown = null) {
const upstream = await fetch(`${pastesClient.COMMUNITY_URL}/api/pastes${p}${qs}`, opts);
if (whenDown && upstream.status >= 500) return res.json(whenDown(req));
if (upstream.status === 401) principal.invalidate('openvibe.community');
++ if (req.method === 'GET' && p === '' && req.query.dedupe === 'consecutive' && upstream.ok) {
++ const payload = await upstream.json();
++ const original = Array.isArray(payload.pastes) ? payload.pastes : [];
++ return res.status(upstream.status).json({ ...payload, raw_count: original.length, pastes: consecutiveUnique(original) });
++ }
res.status(upstream.status);
for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {
const v = upstream.headers.get(h);
@@@ -112,3 +135,4 @@ communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '
module.exports = communityRouter;
module.exports.communityRouter = communityRouter;
++module.exports.consecutiveUnique = consecutiveUnique;
ddiff --git a/test/avatar-history-settings.test.js b/test/avatar-history-settings.test.js
nnew file mode 100644
iindex 00000000..74499070
---- /dev/null
++++ b/test/avatar-history-settings.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/settings.js'), 'utf8');
++function functionSource(name) {
++ const at = src.indexOf(`async function ${name}(`);
++ assert.ok(at >= 0, `${name} exists`);
++ let depth = 0;
++ for (let i = src.indexOf('{', at); i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(at, i + 1);
++ }
++ throw new Error(`${name} has no closing brace`);
++}
++
++(async () => {
++ const calls = [], notices = [], renders = [], changes = [];
++ const currentUser = { avatar_url: '/old.png' };
++ const context = {
++ encodeURIComponent,
++ api: async (...args) => { calls.push(args); return { avatar_url: '/new.png' }; },
++ toast: (...args) => notices.push(args),
++ currentUser,
++ _renderSettingsAvatar: (user) => renders.push(user),
++ _loadAvatarHistory: () => changes.push('history'),
++ onAuthChange: () => changes.push('auth'),
++ };
++ vm.runInNewContext(`${functionSource('reuseAvatarObject')}\nreuseAvatarObject('object/one')`, context);
++ await new Promise((resolve) => setImmediate(resolve));
++ assert.strictEqual(calls.length, 1);
++ assert.strictEqual(calls[0][0], '/auth/avatar/object/object%2Fone');
++ assert.strictEqual(calls[0][1].method, 'POST');
++ assert.strictEqual(currentUser.avatar_url, '/new.png');
++ assert.strictEqual(notices[0][0], 'Avatar updated');
++ assert.strictEqual(notices[0][1], 'success');
++ assert.deepStrictEqual(renders, [currentUser]);
++ assert.deepStrictEqual(changes, ['history', 'auth']);
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/paste-canonical.test.js b/test/paste-canonical.test.js
iindex bcffd202..813c85ed 100644
---- a/test/paste-canonical.test.js
++++ b/test/paste-canonical.test.js
@@@ -106,11 +106,13 @@ async function main() {
await check('screenshot fallbacks use Media\'s screenshot URL, not the /raw hop', () => {
assert.strictEqual(typeof media.pasteRawUrl, 'undefined', 'the /raw URL builder is gone');
assert.strictEqual(media.pasteScreenshotUrl('a b'), `${media.MEDIA_PUBLIC_URL}/p/a%20b/screenshot`);
-- for (const rel of ['server/home/routes.js', 'server/auth/routes.js']) {
++ for (const rel of ['server/home/routes.js']) {
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
assert.ok(/pasteScreenshotUrl\(/.test(src), `${rel} falls back to the screenshot URL`);
assert.ok(!/pasteRawUrl/.test(src), `${rel} no longer takes the /raw hop`);
}
++ const auth = fs.readFileSync(path.join(__dirname, '..', 'server/auth/routes.js'), 'utf8');
++ assert.ok(!/createPaste\(/.test(auth), 'profile picture uploads are not published as Community pastes');
});
await check('Live\'s sitemap lists no paste', async () => {
ddiff --git a/test/pastes-community.test.js b/test/pastes-community.test.js
iindex 1e5f2924..e48ca1a9 100644
---- a/test/pastes-community.test.js
++++ b/test/pastes-community.test.js
@@@ -138,6 +138,15 @@ const community = http.createServer((req, res) => {
assert.strictEqual(client.onCommunity, undefined, 'the paste-authority switch is gone');
assert.strictEqual(require('../server/media-proxy/pastes'), require('../server/media-proxy/pastes').communityRouter, '/api/pastes is the Community router');
assert.strictEqual(require('../server/media-proxy/pastes').mediaRouter, undefined, 'the Media router is gone');
++ const collapsePastes = require('../server/media-proxy/pastes').consecutiveUnique;
++ const repeatedHash = 'b'.repeat(64);
++ const cards = collapsePastes([
++ { slug: 'new', type: 'paste', username: 'ann', content_hash: repeatedHash },
++ { slug: 'older', type: 'paste', username: 'ann', content_hash: repeatedHash },
++ { slug: 'other', type: 'paste', username: 'bob', content_hash: repeatedHash },
++ ]);
++ assert.deepStrictEqual(cards.map((p) => p.slug), ['new', 'other']);
++ assert.strictEqual(cards[0].more_like_it, 1);
const clientSrc = fs.readFileSync(path.join(__dirname, '../server/pastes-client.js'), 'utf8');
assert.ok(!/media-client/.test(clientSrc) && !clientSrc.includes('PASTES_' + 'AUTHORITY'), 'the pastes client never falls back to Media');