feat: sharing is by link — follow it, accept, and it is in your resources
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 12s

The author presses "Copy a share link" and sends it however they like.
Whoever follows it (app.pedshub.com/#share=<token>) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 15:20:35 +02:00
parent eb9fdfef35
commit c7864f763e
10 changed files with 162 additions and 33 deletions

View file

@ -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;
`);

View file

@ -279,7 +279,7 @@
<button class="faq-question">How do I download or share a resource?</button>
<div class="faq-answer">
<p><strong>Preview</strong> shows every page in place, on a phone too. Download as <strong>PowerPoint</strong>, <strong>Word</strong> or <strong>PDF</strong>, or send the rendered file straight to your Nextcloud; articles download as Word or PDF.</p>
<p><strong>Share</strong> 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 …".</p>
<p><strong>Share</strong> 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 …".</p>
</div>
</div>
</div>

View file

@ -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=<token>: 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;

View file

@ -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 = '<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" class="mr-share-all"> Anyone signed in to this site can open it</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;"><input type="email" class="mr-share-email admin-control" placeholder="Share with someone by email" style="flex:1;min-width:200px;">' +
'<button type="button" class="btn-sm btn-primary mr-share-add">Add</button></div>' +
panel.innerHTML = '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button type="button" class="btn-sm btn-primary mr-share-link"><i class="fas fa-link"></i> Copy a share link</button>' +
'<span class="mr-share-link-note" style="font-size:12px;color:var(--g500);">Send it however you like. Whoever follows it and accepts gets it in their resources; the link works for 30 days.</span>' +
'<input type="text" class="mr-share-link-url admin-control" readonly hidden style="flex-basis:100%;font-size:12px;"></div>' +
'<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" class="mr-share-all"> Or: anyone signed in to this site can open it</label>' +
'<div class="mr-share-people" style="display:grid;gap:4px;"></div>';
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();

View file

@ -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) {

View file

@ -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=<token>; whoever follows it, signed in, can accept. Only the token hash is stored. Body: { days } (1365, 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' },

View file

@ -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: () => '' },

View file

@ -26,6 +26,9 @@ test('nothing here can return another persons 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');

View file

@ -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=<token> 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']);

View file

@ -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\)/);
});