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