openvibers/openvibe.live Commits
Keep profile pictures out of public pastes (#763)
Changes
@@@ -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; }}@@@ -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 {@@@ -203,29 +203,15 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, rreturn 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, rtry {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// ═══════════════════════════════════════════════════════════════@@@ -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 = {// clipscreateClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,// files + thumbnails-- uploadFile, getFileMeta, deleteFile,++ uploadFile, getFileMeta, deleteFile, getObject, listObjects,uploadThumbnail, generateThumbnail,// URL buildersvodPlaybackUrl, vodDvrPlaylistUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,@@@ -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;nnew file mode 100644@@@ -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; });@@@ -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 () => {@@@ -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');
@@ -1688,15 +1688,16 @@ async function loadHomePastes(opts) {
@@ -1688,15 +1688,16 @@ async function loadHomePastes(opts) {
· ${timeAgo(p.created_at)}
· ${timeAgo(p.created_at)}
</div>
</div>
${_cardAiHTML(p.ai_summary)}
${_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>
</div>
</div>
</a>`;
</a>`;
}).join(''));
}).join(''));
if (window.OVDensity) OVDensity.attach(list, { key: 'pastes', header: document.getElementById('home-pastes-header') });
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);
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; }
} catch { rail.filling = false; }
}
}
@@ -107,7 +107,7 @@ function _renderSettingsAvatar(u) {
@@ -107,7 +107,7 @@ function _renderSettingsAvatar(u) {
: letter) + chip;
: 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() {
async function _loadAvatarHistory() {
const card = document.getElementById('settings-avatar-history-card');
const card = document.getElementById('settings-avatar-history-card');
const wrap = document.getElementById('settings-avatar-history');
const wrap = document.getElementById('settings-avatar-history');
@@ -120,13 +120,24 @@ async function _loadAvatarHistory() {
@@ -120,13 +120,24 @@ async function _loadAvatarHistory() {
wrap.innerHTML = avatars.map(a => `
wrap.innerHTML = avatars.map(a => `
<button type="button" class="settings-avatar-hist-item${a.active ? ' active' : ''}"
<button type="button" class="settings-avatar-hist-item${a.active ? ' active' : ''}"
title="${a.active ? 'Current avatar' : 'Use this avatar'}"
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">
<img src="${a.url}" alt="" loading="lazy">
${a.active ? '<span class="settings-avatar-hist-active"><i class="fa-solid fa-check"></i></span>' : ''}
${a.active ? '<span class="settings-avatar-hist-active"><i class="fa-solid fa-check"></i></span>' : ''}
</button>`).join('');
</button>`).join('');
} catch { card.style.display = 'none'; }
} 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.
// Re-activate a previously uploaded avatar from history.
async function reuseAvatarPaste(slug) {
async function reuseAvatarPaste(slug) {
try {
try {
@@ -203,29 +203,15 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
@@ -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.' });
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');
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);
const imageBuffer = fs.readFileSync(req.file.path);
try { fs.unlinkSync(req.file.path); } catch { /* */ }
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
// 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.
// 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
@@ -233,11 +219,12 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
try {
try {
const synced = await require('../utils/notify').syncAvatarChange(view);
const synced = await require('../utils/notify').syncAvatarChange(view);
if (synced && synced.avatar_url && synced.avatar_url !== view.avatar_url) {
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);
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 */ }
} 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) {
} catch (err) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
console.error('[Auth] Avatar upload error:', err.message);
console.error('[Auth] Avatar upload error:', err.message);
@@ -245,15 +232,13 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
@@ -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) => {
router.get('/avatar/history', requireAuth, async (req, res) => {
try {
try {
const me = await db.getUserById(req.user.id);
const me = await db.getUserById(req.user.id);
const activePasteId = me?.avatar_paste_id || null;
const activePasteId = me?.avatar_paste_id || null;
const media = require('../media-client');
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);
const url = media.publicUrl(r.screenshot_url);
return {
return {
slug: r.slug,
slug: r.slug,
@@ -263,6 +248,17 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
@@ -263,6 +248,17 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
active: (activePasteId != null && r.id === activePasteId) || (!!url && url === me.avatar_url),
active: (activePasteId != null && r.id === activePasteId) || (!!url && url === me.avatar_url),
};
};
}).filter(r => r.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 });
res.json({ avatars: rows });
} catch (err) {
} catch (err) {
console.error('[Auth] Avatar history error:', err.message);
console.error('[Auth] Avatar history error:', err.message);
@@ -270,6 +266,29 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
@@ -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
// OpenVibe.Tools OAuth2 SSO Integration
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -317,6 +317,10 @@ async function uploadFile(file, fields = {}, opts = {}) {
@@ -317,6 +317,10 @@ async function uploadFile(file, fields = {}, opts = {}) {
return await request('POST', '/files', { body: fd, timeoutMs: 120000, ...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 = {}) {
async function getFileMeta(key, opts = {}) {
return await request('GET', `/files/${encodeURIComponent(key)}`, opts);
return await request('GET', `/files/${encodeURIComponent(key)}`, opts);
}
}
@@ -469,7 +473,7 @@ module.exports = {
@@ -469,7 +473,7 @@ module.exports = {
// clips
// clips
createClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,
createClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,
// files + thumbnails
// files + thumbnails
- uploadFile, getFileMeta, deleteFile,
+ uploadFile, getFileMeta, deleteFile, getObject, listObjects,
uploadThumbnail, generateThumbnail,
uploadThumbnail, generateThumbnail,
// URL builders
// URL builders
vodPlaybackUrl, vodDvrPlaylistUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,
vodPlaybackUrl, vodDvrPlaylistUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl, setPlaybackUrls,
@@ -33,6 +33,24 @@ const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next(
@@ -33,6 +33,24 @@ const anonWriteLimiter = require('../drill').enabled ? (req, res, next) => next(
const pastesClient = require('../pastes-client');
const pastesClient = require('../pastes-client');
const principal = require('../net/network-principal');
const principal = require('../net/network-principal');
const { Readable } = require('stream');
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. */
/** `whenDown(req)`: the answer while Community is unreachable or failing (5xx), instead of a 502. */
function toCommunity(subPath, whenDown = null) {
function toCommunity(subPath, whenDown = null) {
@@ -59,6 +77,11 @@ 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);
const upstream = await fetch(`${pastesClient.COMMUNITY_URL}/api/pastes${p}${qs}`, opts);
if (whenDown && upstream.status >= 500) return res.json(whenDown(req));
if (whenDown && upstream.status >= 500) return res.json(whenDown(req));
if (upstream.status === 401) principal.invalidate('openvibe.community');
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);
res.status(upstream.status);
for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {
for (const h of ['content-type', 'cache-control', 'location', 'retry-after', 'ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']) {
const v = upstream.headers.get(h);
const v = upstream.headers.get(h);
@@ -112,3 +135,4 @@ communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '
@@ -112,3 +135,4 @@ communityRouter.all('*', optionalAuth, toCommunity((req) => req.path === '/' ? '
module.exports = communityRouter;
module.exports = communityRouter;
module.exports.communityRouter = communityRouter;
module.exports.communityRouter = communityRouter;
+module.exports.consecutiveUnique = consecutiveUnique;
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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; });
@@ -106,11 +106,13 @@ async function main() {
@@ -106,11 +106,13 @@ async function main() {
await check('screenshot fallbacks use Media\'s screenshot URL, not the /raw hop', () => {
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(typeof media.pasteRawUrl, 'undefined', 'the /raw URL builder is gone');
assert.strictEqual(media.pasteScreenshotUrl('a b'), `${media.MEDIA_PUBLIC_URL}/p/a%20b/screenshot`);
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');
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
assert.ok(/pasteScreenshotUrl\(/.test(src), `${rel} falls back to the screenshot URL`);
assert.ok(/pasteScreenshotUrl\(/.test(src), `${rel} falls back to the screenshot URL`);
assert.ok(!/pasteRawUrl/.test(src), `${rel} no longer takes the /raw hop`);
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 () => {
await check('Live\'s sitemap lists no paste', async () => {
@@ -138,6 +138,15 @@ const community = http.createServer((req, res) => {
@@ -138,6 +138,15 @@ const community = http.createServer((req, res) => {
assert.strictEqual(client.onCommunity, undefined, 'the paste-authority switch is gone');
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'), 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');
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');
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');
assert.ok(!/media-client/.test(clientSrc) && !clientSrc.includes('PASTES_' + 'AUTHORITY'), 'the pastes client never falls back to Media');