From c7864f763e112312e1b55e06e79d8e2b1ace3d09 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 15:20:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20sharing=20is=20by=20link=20=E2=80=94=20?= =?UTF-8?q?follow=20it,=20accept,=20and=20it=20is=20in=20your=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The author presses "Copy a share link" and sends it however they like. Whoever follows it (app.pedshub.com/#share=) is signed in first if need be — the token survives the trip through the SSO — then shown what it is and who from, and adds it with one press. Only the token's hash is stored; a link lasts 30 days and can be withdrawn; accepting twice is harmless; the owner following their own link changes nothing. Sharing by email is gone: nobody is looked up by address. "Everyone signed in" stays as a switch. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- .../1781400000000_resource-share-links.js | 23 ++++++ public/components/faq.html | 2 +- public/js/app.js | 9 ++- public/js/myResources.js | 51 ++++++++++--- src/routes/myResources.js | 72 +++++++++++++++---- src/utils/openapiRoutes.js | 7 +- test/my-resources-refine.test.js | 1 + test/my-resources.test.js | 3 + test/open-tab-from-hash.test.js | 7 ++ test/resource-sharing.test.js | 20 ++++-- 10 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 migrations/1781400000000_resource-share-links.js diff --git a/migrations/1781400000000_resource-share-links.js b/migrations/1781400000000_resource-share-links.js new file mode 100644 index 00000000..b6c41f8e --- /dev/null +++ b/migrations/1781400000000_resource-share-links.js @@ -0,0 +1,23 @@ +// Sharing by link. The author makes a link; whoever follows it, signed in, +// accepts, and the resource joins their library as a share (a row in +// user_resource_shares, exactly as before). Only the hash of the token is +// stored, so a database read does not hand out working links; a link can be +// given an expiry and withdrawn. + +exports.up = pgm => pgm.sql(` + CREATE TABLE IF NOT EXISTS user_resource_share_links ( + id SERIAL PRIMARY KEY, + resource_id INTEGER NOT NULL REFERENCES user_resources(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + accepted_count INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_resource_share_links_resource ON user_resource_share_links (resource_id); +`); + +exports.down = pgm => pgm.sql(` + DROP TABLE IF EXISTS user_resource_share_links; +`); diff --git a/public/components/faq.html b/public/components/faq.html index 0fdc1749..1b322bd5 100644 --- a/public/components/faq.html +++ b/public/components/faq.html @@ -279,7 +279,7 @@

Preview shows every page in place, on a phone too. Download as PowerPoint, Word or PDF, or send the rendered file straight to your Nextcloud; articles download as Word or PDF.

-

Share lets other people on this site open, preview and download it — one person at a time by email, or everyone signed in with one switch — without being able to change it. What others share with you appears in your library marked "Shared by …".

+

Share makes a link you can send however you like; whoever follows it, signed in, is asked whether to add the resource to their own library, and can then open, preview and download it — not change it. There is also a switch to open a resource to everyone signed in. What others share with you appears marked "Shared by …".

diff --git a/public/js/app.js b/public/js/app.js index cd1ad61d..baca713f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -10,7 +10,14 @@ // SSO and back and does not stick to the address bar afterwards. var TAB_ALIASES = { resources: 'myresources', deck: 'myresources', decks: 'myresources', assistant: 'assistant' }; function openTabFromHash(win, doc, storage) { - var wanted = String((win.location && win.location.hash) || '').replace(/^#/, '').toLowerCase(); + var raw = String((win.location && win.location.hash) || '').replace(/^#/, ''); + // app.pedshub.com/#share=: a resource somebody shared. The token is + // set aside for My Resources to act on once the person is signed in. + if (raw.indexOf('share=') === 0) { + try { storage.setItem('ped_pending_share', raw.slice(6)); } catch (e) {} + raw = 'myresources'; + } + var wanted = raw.toLowerCase(); wanted = TAB_ALIASES[wanted] || wanted; if (!wanted) return null; if (wanted !== 'assistant' && !doc.querySelector('.tab-btn[data-tab="' + wanted + '"]')) return null; diff --git a/public/js/myResources.js b/public/js/myResources.js index 9d893f89..7a0049b0 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -24,6 +24,7 @@ // the visit, so without this the previous run's illustration and status sit // under an empty form as though they belonged to it. else clearResults(); + acceptPendingShare(); loadLibrary(); }); @@ -613,6 +614,30 @@ // work from one copy rather than each asking the server again. var library = []; + // Someone followed a share link. The token was set aside at page load (it + // survives the trip through the SSO); now that they are signed in and on + // this tab, say what it is and who from, and add it when they say yes. + function acceptPendingShare() { + var token = ''; + try { token = localStorage.getItem('ped_pending_share') || ''; localStorage.removeItem('ped_pending_share'); } catch (e) {} + if (!token) return; + var base = '/api/my-resources/share-link/' + encodeURIComponent(token); + fetch(base, { headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d.success) throw new Error(d.error || 'That link is not valid any more'); + if (d.own) { showToast('That is your own resource', 'info'); return; } + var what = (d.kind === 'article' ? 'the article' : 'the deck') + ' "' + d.title + '"'; + showConfirm((d.sharedBy || 'Someone') + ' shared ' + what + ' with you. Add it to your resources?', function () { + fetch(base + '/accept', { method: 'POST', headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (a) { if (!a.success) throw new Error(a.error || 'Could not add it'); showToast('"' + a.title + '" is in your resources', 'success'); loadLibrary(); }) + .catch(function (err) { showToast(err.message, 'error'); }); + }, { confirmText: 'Add to my resources' }); + }) + .catch(function (err) { showToast(err.message, 'error'); }); + } + function loadLibrary() { var list = document.getElementById('mr-list'); if (!list) return; @@ -995,9 +1020,11 @@ var panel = document.createElement('div'); panel.className = 'mr-share'; panel.style.cssText = 'flex-basis:100%;margin-top:8px;padding:10px 12px;border:1px solid var(--g200);border-radius:8px;background:var(--g50);display:grid;gap:8px;font-size:13px;'; - panel.innerHTML = '' + - '
' + - '
' + + panel.innerHTML = '
' + + '' + + 'Send it however you like. Whoever follows it and accepts gets it in their resources; the link works for 30 days.' + + '
' + + '' + '
'; rowEl.appendChild(panel); var base = '/api/my-resources/' + encodeURIComponent(id) + '/shares'; @@ -1007,7 +1034,7 @@ list.innerHTML = ''; if (!state.people.length) { var none = document.createElement('div'); none.style.cssText = 'font-size:12px;color:var(--g500);'; - none.textContent = state.sharedWithAll ? 'Shared with everyone.' : 'Not shared with anyone yet.'; + none.textContent = state.sharedWithAll ? 'Shared with everyone.' : 'Nobody has accepted a link yet.'; list.appendChild(none); return; } state.people.forEach(function (p) { @@ -1035,13 +1062,17 @@ .then(function (d) { if (!d.success) throw new Error(d.error || 'Could not change sharing'); showToast(d.sharedWithAll ? 'Shared with everyone on the site' : 'No longer shared with everyone', 'success'); return load(); }) .catch(function (err) { showToast(err.message, 'error'); load(); }); }); - panel.querySelector('.mr-share-add').addEventListener('click', function () { - var input = panel.querySelector('.mr-share-email'); - var email = (input.value || '').trim(); - if (!email) { input.focus(); return; } - fetch(base, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ email: email }) }) + panel.querySelector('.mr-share-link').addEventListener('click', function () { + var out = panel.querySelector('.mr-share-link-url'); + fetch('/api/my-resources/' + encodeURIComponent(id) + '/share-link', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ days: 30 }) }) .then(function (r) { return r.json(); }) - .then(function (d) { if (!d.success) throw new Error(d.error || 'Could not share'); input.value = ''; showToast('Shared with ' + (d.person.name || d.person.email), 'success'); return load(); }) + .then(function (d) { + if (!d.success) throw new Error(d.error || 'Could not make a link'); + out.value = d.url; out.hidden = false; out.select(); + var copy = navigator.clipboard ? navigator.clipboard.writeText(d.url) : Promise.reject(); + return copy.then(function () { showToast('Link copied — send it to whoever should have this', 'success'); }) + .catch(function () { showToast('Link ready below — copy it by hand', 'info'); }); + }) .catch(function (err) { showToast(err.message, 'error'); }); }); load(); diff --git a/src/routes/myResources.js b/src/routes/myResources.js index f5d5d11f..25e0db92 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -630,23 +630,67 @@ router.put('/my-resources/:id/shares/all', async function (req, res) { } catch (err) { res.status(500).json({ error: 'Could not change sharing' }); } }); -// By email, exactly. Nothing here lists accounts: an address that is not one -// is refused, and the author is told so, without turning this into a -// directory. -router.post('/my-resources/:id/shares', async function (req, res) { +// By link. The author makes one and sends it however they like; whoever +// follows it, signed in, is shown what it is and who from, accepts, and the +// resource joins their library. Only the token's hash is stored. A link can +// carry an expiry and be withdrawn; withdrawing a link does not withdraw what +// people already accepted — that is the person list above. +var crypto = require('crypto'); +function hashShareToken(token) { return crypto.createHash('sha256').update(String(token)).digest('hex'); } + +router.post('/my-resources/:id/share-link', async function (req, res) { try { var row = await ownedResource(req.params.id, req.user.id); if (!row) return res.status(404).json({ error: 'Not found' }); - var email = String(req.body.email || '').trim().toLowerCase(); - if (!email || email.length > 320) return res.status(400).json({ error: 'Enter the person\'s email' }); - var person = await db.get('SELECT id, name, email FROM users WHERE email = ? AND disabled IS NOT TRUE', [email]); - if (!person) return res.status(404).json({ error: 'No account with that email on this site' }); - if (person.id === req.user.id) return res.status(400).json({ error: 'That is you' }); - await db.run('INSERT INTO user_resource_shares (resource_id, user_id, shared_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', - [row.id, person.id, req.user.id]); - logger.audit(req.user.id, 'resource_share', 'Resource ' + row.id + ' shared with user ' + person.id, req, { category: 'clinical' }); - res.json({ success: true, person: person }); - } catch (err) { res.status(500).json({ error: 'Could not share it' }); } + var days = clampInt(req.body.days, 1, 365, 30); + var token = crypto.randomBytes(24).toString('base64url'); + await db.run( + 'INSERT INTO user_resource_share_links (resource_id, token_hash, created_by, expires_at) VALUES (?, ?, ?, NOW() + (? || \' days\')::interval)', + [row.id, hashShareToken(token), req.user.id, String(days)] + ); + logger.audit(req.user.id, 'resource_share_link', 'Share link for resource ' + row.id + ' (' + days + ' days)', req, { category: 'clinical' }); + var appUrl = String(process.env.APP_URL || '').replace(/\/$/, ''); + res.json({ success: true, url: appUrl + '/#share=' + token, days: days }); + } catch (err) { + logger.warn('[my-resources] share link', { error: err.message }); + res.status(500).json({ error: 'Could not make a link' }); + } +}); + +async function liveShareLink(token) { + if (!token || String(token).length > 200) return null; + return db.get( + 'SELECT l.id, l.resource_id, r.title, r.kind, u.name AS shared_by_name, r.user_id AS owner_id ' + + 'FROM user_resource_share_links l JOIN user_resources r ON r.id = l.resource_id JOIN users u ON u.id = r.user_id ' + + 'WHERE l.token_hash = ? AND l.revoked_at IS NULL AND (l.expires_at IS NULL OR l.expires_at > NOW())', + [hashShareToken(token)] + ); +} + +// What a link is for, before accepting it: the title, the kind, who from. +router.get('/my-resources/share-link/:token', async function (req, res) { + try { + var link = await liveShareLink(req.params.token); + if (!link) return res.status(404).json({ error: 'That link is not valid any more' }); + res.json({ success: true, title: link.title, kind: link.kind, sharedBy: link.shared_by_name, own: link.owner_id === req.user.id }); + } catch (err) { res.status(500).json({ error: 'Could not read the link' }); } +}); + +router.post('/my-resources/share-link/:token/accept', async function (req, res) { + try { + var link = await liveShareLink(req.params.token); + if (!link) return res.status(404).json({ error: 'That link is not valid any more' }); + if (link.owner_id !== req.user.id) { + await db.run('INSERT INTO user_resource_shares (resource_id, user_id, shared_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', + [link.resource_id, req.user.id, link.owner_id]); + await db.run('UPDATE user_resource_share_links SET accepted_count = accepted_count + 1 WHERE id = ?', [link.id]); + logger.audit(req.user.id, 'resource_share_accept', 'Accepted a share of resource ' + link.resource_id, req, { category: 'clinical' }); + } + res.json({ success: true, id: link.resource_id, title: link.title }); + } catch (err) { + logger.warn('[my-resources] share accept', { error: err.message }); + res.status(500).json({ error: 'Could not add it to your resources' }); + } }); router.delete('/my-resources/:id/shares/:userId', async function (req, res) { diff --git a/src/utils/openapiRoutes.js b/src/utils/openapiRoutes.js index bc150933..74fc9b6d 100644 --- a/src/utils/openapiRoutes.js +++ b/src/utils/openapiRoutes.js @@ -21,14 +21,17 @@ var parameters = { workflow: 'Which feature the job belongs to (for example my_resources).', key: 'Setting key, for example clinical_assistant.chat_model.', slug: 'URL-safe name of the document.', - userId: 'Id of the account the share was for.' + userId: 'Id of the account the share was for.', + token: 'The share link\'s token, as it appears after #share= in the link.' }; var operations = { // ── Speech ────────────────────────────────────────────────────────── 'GET /api/my-resources/:id/shares': { summary: 'Who a resource is shared with (owner only)' }, 'PUT /api/my-resources/:id/shares/all': { summary: 'Share a resource with everyone signed in, or stop', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { value: { type: 'boolean' } } } } } } }, - 'POST /api/my-resources/:id/shares': { summary: 'Share a resource with one person, by email', description: 'Refused with 404 when no account has that email; accounts are never listed.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string' } } } } } } }, + 'POST /api/my-resources/:id/share-link': { summary: 'Make a share link (owner only)', description: 'Returns a URL of the form APP_URL/#share=; whoever follows it, signed in, can accept. Only the token hash is stored. Body: { days } (1–365, default 30).' }, + 'GET /api/my-resources/share-link/:token': { summary: 'What a share link is for: title, kind, who from' }, + 'POST /api/my-resources/share-link/:token/accept': { summary: 'Accept a share link; the resource joins the caller\'s library' }, 'DELETE /api/my-resources/:id/shares/:userId': { summary: 'Withdraw a share' }, 'GET /api/my-resources/:id/preview': { summary: 'Render a resource as pages for viewing in place', description: 'Returns the page count and a key; pages are served as PNG by the sibling route. Rendered once per version (updated_at and theme).' }, 'GET /api/my-resources/:id/preview/:page': { summary: 'One page of a resource preview, as PNG' }, diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js index b94448df..1b94a3b1 100644 --- a/test/my-resources-refine.test.js +++ b/test/my-resources-refine.test.js @@ -81,6 +81,7 @@ function router(t, overrides = {}) { '../utils/logger': quiet, '../utils/nextcloudFiles': { send: async () => '/PediatricScribe/2026-01-01/x.pptx' }, '../utils/previewPages': { cacheKey: () => 'key', ensure: async () => 1, page: async () => null }, + 'crypto': require('crypto'), '../utils/metrics': { resourceRefines: { inc() {} }, resourceVocabularyGaps: { inc() {} } }, '../utils/pubmedSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' }, '../utils/webSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' }, diff --git a/test/my-resources.test.js b/test/my-resources.test.js index ef0c9c65..4e8f0c48 100644 --- a/test/my-resources.test.js +++ b/test/my-resources.test.js @@ -26,6 +26,9 @@ test('nothing here can return another person’s work', () => { assert.ok(touching.length >= 5, 'expected the table statements to be found'); for (const s of touching) { if (/^'INSERT/.test(s)) continue; // supplies user_id as a value instead + // A share link is resolved by its token — the token is the credential — + // and what it reveals is the title and who shared it, never the content. + if (/token_hash = \?/.test(s)) continue; assert.match(s, /user_id = \?/, 'every read and write is scoped to the owner: ' + s.slice(0, 60)); } assert.match(route, /INSERT INTO user_resources \(user_id,/, 'and an insert records one'); diff --git a/test/open-tab-from-hash.test.js b/test/open-tab-from-hash.test.js index 85015cfa..2c629790 100644 --- a/test/open-tab-from-hash.test.js +++ b/test/open-tab-from-hash.test.js @@ -26,6 +26,13 @@ test('#resources stores My Resources as the tab to open and clears the hash', () assert.deepEqual(w.replaced, ['/?sso=ok'], 'the query survives, the hash goes'); }); +test('#share= sets the token aside and opens My Resources', () => { + const w = world('#share=abc.def-123', ['encounter', 'myresources']); + assert.equal(openTabFromHash(w.win, w.doc, w.storage), 'myresources'); + assert.equal(w.stored.ped_pending_share, 'abc.def-123'); + assert.equal(w.stored.ped_last_tab, 'myresources'); +}); + test('a tab that does not exist, or no hash at all, changes nothing', () => { for (const hash of ['#nonsense', '', '#']) { const w = world(hash, ['encounter']); diff --git a/test/resource-sharing.test.js b/test/resource-sharing.test.js index 1340ef47..c74cc1b2 100644 --- a/test/resource-sharing.test.js +++ b/test/resource-sharing.test.js @@ -19,13 +19,20 @@ test('every read route goes through the reader rule; every write route still fil assert.doesNotMatch(body, /readableResource/, marker + ' is not opened to readers'); } // The share list itself is the owner's, and adding by email never lists accounts. - for (const marker of ["router.get('/my-resources/:id/shares'", "router.put('/my-resources/:id/shares/all'", "router.post('/my-resources/:id/shares'", "router.delete('/my-resources/:id/shares/:userId'"]) { + for (const marker of ["router.get('/my-resources/:id/shares'", "router.put('/my-resources/:id/shares/all'", "router.post('/my-resources/:id/share-link'", "router.delete('/my-resources/:id/shares/:userId'"]) { const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker))); assert.match(body, /await ownedResource\(req\.params\.id, req\.user\.id\)/, marker + ' is owner-only'); } - assert.match(route, /SELECT id, name, email FROM users WHERE email = \? AND disabled IS NOT TRUE/); - assert.match(route, /No account with that email on this site/); - assert.match(route, /ON CONFLICT DO NOTHING/, 'sharing twice is not an error'); + // A link: only its hash is stored, it can expire and be withdrawn, and + // accepting it adds the resource for the caller — never for someone named. + assert.match(route, /crypto\.randomBytes\(24\)\.toString\('base64url'\)/); + assert.match(route, /INSERT INTO user_resource_share_links \(resource_id, token_hash, created_by, expires_at\)/); + assert.match(route, /WHERE l\.token_hash = \? AND l\.revoked_at IS NULL AND \(l\.expires_at IS NULL OR l\.expires_at > NOW\(\)\)/); + const accept = route.slice(route.indexOf("router.post('/my-resources/share-link/:token/accept'"), route.indexOf('\n});', route.indexOf("router.post('/my-resources/share-link/:token/accept'"))); + assert.match(accept, /\[link\.resource_id, req\.user\.id, link\.owner_id\]/, 'the caller is the one added'); + assert.match(accept, /ON CONFLICT DO NOTHING/, 'accepting twice is not an error'); + assert.match(accept, /if \(link\.owner_id !== req\.user\.id\)/, 'the owner following their own link changes nothing'); + assert.doesNotMatch(route, /WHERE email = \?/, 'nobody is shared with by address; accounts are never looked up by email'); }); test('the list carries whose each row is, and the page offers changes only on your own', () => { @@ -36,6 +43,9 @@ test('the list carries whose each row is, and the page offers changes only on yo assert.match(js, /if \(row\.owned === false\) \{ return wrap; \}/, 'a shared row has Preview and downloads, nothing else'); assert.match(js, /library\.filter\(function \(row\) \{ return row\.owned !== false; \}\)/, 'Modify offers only your own'); assert.match(js, /'Shared by ' \+ row\.shared_by_name/); - assert.match(js, /mr-share-all/); assert.match(js, /mr-share-email/); assert.match(js, /Withdraw the share/); + assert.match(js, /mr-share-all/); assert.match(js, /mr-share-link/); assert.match(js, /Withdraw the share/); + assert.match(js, /localStorage\.getItem\('ped_pending_share'\)/, 'a followed link is acted on once signed in'); + assert.match(js, /Add it to your resources\?/); + assert.match(read('public/js/app.js'), /raw\.indexOf\('share=' \) === 0|raw\.indexOf\('share='\) === 0/); assert.match(read('migrations/1781300000000_resource-shares.js'), /PRIMARY KEY \(resource_id, user_id\)/); });