diff --git a/public/js/myResources.js b/public/js/myResources.js index 61ae9115..30ba0d0b 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -151,6 +151,7 @@ var themeRow = document.getElementById('mr-theme-row'); var themes = Array.isArray(data.themes) ? data.themes : []; themeCatalogue = themes; + nextcloudConnected = Boolean(data.nextcloudConnected); if (themeSelect && themes.length) { var chosenTheme = themeSelect.value; themeSelect.textContent = ''; @@ -359,6 +360,7 @@ // renders — data-image-thumb is what asks hydrateImage for the small copy. // The catalogue, once, shared by the generate form and every library row. var themeCatalogue = []; + var nextcloudConnected = false; var imagesLoaded = false; var nextImagesBefore = null; @@ -807,6 +809,21 @@ wrap.appendChild(theme); } + // Send the rendered file to the owner's own Nextcloud. Offered only when + // there is a Nextcloud to send it to. + if (nextcloudConnected) { + var cloud = document.createElement('button'); + cloud.className = 'btn-sm btn-ghost'; + cloud.type = 'button'; + cloud.dataset.nextcloud = String(row.id); + cloud.dataset.format = row.kind === 'article' ? 'docx' : 'pptx'; + cloud.title = 'Save to my Nextcloud as ' + cloud.dataset.format.toUpperCase(); + var cloudIcon = document.createElement('i'); + cloudIcon.className = 'fas fa-cloud-arrow-up'; + cloud.appendChild(cloudIcon); + wrap.appendChild(cloud); + } + var del = document.createElement('button'); del.className = 'btn-sm btn-ghost'; del.type = 'button'; @@ -848,7 +865,34 @@ .finally(function () { select.disabled = false; }); } + // Rendered server-side and pushed straight to their storage — the file never + // travels through this browser, which is the point: it is a copy in their own + // Nextcloud, not a download they then have to file away. + function sendToNextcloud(id, format, btn) { + var icon = btn.querySelector('i'); + var was = icon ? icon.className : ''; + btn.disabled = true; + if (icon) icon.className = 'fas fa-spinner fa-spin'; + fetch('/api/my-resources/' + encodeURIComponent(id) + '/to-nextcloud', { + method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ format: format }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.success) throw new Error(data.error || 'Could not send it'); + if (icon) icon.className = 'fas fa-cloud-arrow-up'; + if (typeof showToast === 'function') showToast('Saved to your Nextcloud: ' + data.path, 'success'); + }) + .catch(function (err) { + if (icon) icon.className = was; + if (typeof showToast === 'function') showToast(err.message, 'error'); + }) + .finally(function () { btn.disabled = false; }); + } + function onRowClick(event) { + var cloud = event.target.closest && event.target.closest('[data-nextcloud]'); + if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud); + var download = event.target.closest && event.target.closest('[data-download]'); if (download) return downloadResource(download.dataset.download, download.dataset.format, download); diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 6eb3a74c..0d4ae7d8 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -400,6 +400,9 @@ router.put('/config/models/toggle', async function(req, res) { } await db.setSetting('models.disabled', JSON.stringify(disabled)); + // Disabling is as final as removing, from a caller's point of view: the + // model stops being selectable, so it stops being allowed. + if (!enabled) await forgetModelEverywhere(modelId); await require('../utils/models').reconcileDefaultModel(db); logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' }); res.json({ success: true }); @@ -454,6 +457,29 @@ router.post('/config/models/custom', async function(req, res) { }); // ── DELETE custom model ─────────────────────────────────────────────────── +// A model that leaves the roster must leave every list that names it. Otherwise +// clinical_assistant.allowed_models keeps offering an id the gateway no longer +// has, and the only sign is a failed request at the moment someone asks a +// clinical question. The lists are advisory copies of the roster; the roster is +// the fact. +async function forgetModelEverywhere(ids) { + var gone = (Array.isArray(ids) ? ids : [ids]).filter(Boolean); + if (!gone.length) return; + for (var key of ['clinical_assistant.allowed_models', + 'clinical_assistant.allowed_image_models', + 'clinical_assistant.image_model_roster']) { + var current = String(await db.getSetting(key, '') || ''); + if (!current) continue; + var kept = current.split(',').map(function (id) { return id.trim(); }) + .filter(function (id) { return id && gone.indexOf(id) === -1; }); + if (kept.join(',') !== current) await db.setSetting(key, kept.join(',')); + } + // A default that no longer exists is reconciled on the next read, but + // clearing it here means the admin panel never shows it as current. + var current = String(await db.getSetting('models.default', '') || ''); + if (current && gone.indexOf(current) !== -1) await db.setSetting('models.default', ''); +} + router.delete('/config/models/custom/:modelId(*)', async function(req, res) { try { var modelId = req.params.modelId; @@ -463,6 +489,7 @@ router.delete('/config/models/custom/:modelId(*)', async function(req, res) { if (!Array.isArray(custom)) throw new Error('Invalid custom model settings'); custom = custom.filter(function(m) { return m.id !== modelId; }); await db.setSetting('models.custom', JSON.stringify(custom)); + await forgetModelEverywhere(modelId); await require('../utils/models').reconcileDefaultModel(db); logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' }); res.json({ success: true }); @@ -475,6 +502,12 @@ router.post('/config/models/clear-all', async function(req, res) { await db.setSetting('models.custom', '[]'); await db.setSetting('models.disabled', '[]'); await db.setSetting('models.default', ''); + // Nothing is on the roster any more, so nothing may remain allowed. + for (var key of ['clinical_assistant.allowed_models', + 'clinical_assistant.allowed_image_models', + 'clinical_assistant.image_model_roster']) { + await db.setSetting(key, ''); + } logger.audit(req.user.id, 'admin_models_clear_all', 'Cleared all custom models and disabled list', req, { category: 'admin' }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Request failed' }); } diff --git a/src/routes/myResources.js b/src/routes/myResources.js index c83c77f8..cca5b1f2 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -26,6 +26,7 @@ var resourceImages = require('../utils/resourceImages'); var deckSchema = require('../utils/deckSchema'); var deckBuild = require('../utils/deckBuild'); var deckReview = require('../utils/deckReview'); +var nextcloudFiles = require('../utils/nextcloudFiles'); // The diagnostics below are the record of what a generation or a modification // actually did. console goes to the container's stdout, which is destroyed every // time the container is recreated — so the one question these exist to answer, @@ -215,6 +216,11 @@ router.get('/my-resources/options', async function (req, res) { models: models.allowed, defaultModel: models.configured, imagesAvailable: Boolean(await db.getSetting('clinical_assistant.image_model', '')), + // Whether this person has somewhere to send a file. The button is hidden + // rather than shown-and-refused: an action that always fails is worse + // than an action that is not offered. + nextcloudConnected: Boolean((await db.get( + 'SELECT nextcloud_url FROM users WHERE id = ?', [req.user.id]) || {}).nextcloud_url), themes: deckSchema.themes().map(function (t) { return { id: t.id, name: t.name, description: t.description || '' }; }), @@ -487,6 +493,54 @@ router.post('/my-resources/generate', async function (req, res) { } }); +// ── Send a resource to Nextcloud ──────────────────────────── +// The rendered file, not the markdown. A .pptx landing in someone's own storage +// is the thing worth having; a text blob is not, and it is not what they would +// have downloaded. +// +// Rendered by exactly the path the download uses, so what lands in Nextcloud is +// byte-for-byte what the browser would have saved. +router.post('/my-resources/:id/to-nextcloud', async function (req, res) { + var fsp = require('fs/promises'); + var os = require('os'); + var pathMod = require('path'); + var workdir = null; + try { + var format = String(req.body.format || 'pptx'); + if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' }); + + var row = await db.get( + 'SELECT id, title, kind, markdown, image_ids, deck FROM user_resources WHERE id = ? AND user_id = ?', + [parseInt(req.params.id, 10), req.user.id] + ); + if (!row) return res.status(404).json({ error: 'Not found' }); + if (row.kind === 'article' && format === 'pptx') { + return res.status(400).json({ error: 'An article has no slides. Send it as Word or PDF.' }); + } + + workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'nc-')); + var figures = await collectFigures(row.image_ids, req.user, workdir); + var deck = row.deck ? (typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck) : null; + var bytes = await documentExport.render(row.markdown, row.kind, format, { + images: figures, figureIds: figureIdList(row.image_ids), deck: deck + }); + + var saved = await nextcloudFiles.send(req.user.id, + documentExport.filename(row.title, format), bytes, documentExport.mimeFor(format)); + + logger.info('[my-resources] sent to Nextcloud', { id: row.id, format: format }); + res.json({ success: true, path: saved }); + } catch (err) { + // A Nextcloud that is not connected, or a password that no longer works, is + // the caller's to fix and is worth saying plainly. + if (err.statusCode) return res.status(err.statusCode).json({ error: err.message }); + logger.warn('[my-resources] to-nextcloud', { error: err.message }); + res.status(502).json({ error: 'Could not send it to Nextcloud. Check the connection in Settings.' }); + } finally { + if (workdir) await require('fs/promises').rm(workdir, { recursive: true, force: true }).catch(function () {}); + } +}); + // ── Theme previews ────────────────────────────────────────── // One representative slide per theme, rendered by the renderer itself and // cached. Drawn rather than mocked up: a hand-made swatch drifts the moment a diff --git a/src/utils/nextcloudFiles.js b/src/utils/nextcloudFiles.js new file mode 100644 index 00000000..6e890db1 --- /dev/null +++ b/src/utils/nextcloudFiles.js @@ -0,0 +1,82 @@ +// ============================================================ +// NEXTCLOUD FILES +// ============================================================ +// Putting a file in someone's own Nextcloud, in one place. +// +// Two callers want this: the clinical tabs send a note as text, My Resources +// sends a rendered deck or document as bytes. Neither should grow its own copy +// of the WebDAV dance — make the dated folder, PUT the file, migrate a legacy +// token — because this reaches into storage that is not ours and a second +// slightly-different copy is how the two drift. +// +// A route importing another route is what this replaces. +var axios = require('axios'); +var db = require('../db/database'); +var cryptoUtil = require('./crypto'); +var { assertSafeHttpsUrl } = require('./urlSafety'); + +function davRoot(url, username) { + return String(url).replace(/\/+$/, '') + '/remote.php/dav/files/' + encodeURIComponent(username); +} + +function refuse(statusCode, message) { + return Object.assign(new Error(message), { statusCode: statusCode }); +} + +// The connection, the decrypted password, and a dated folder that exists. +async function target(userId) { + var user = await db.get( + 'SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?', + [userId]); + if (!user || !user.nextcloud_url) throw refuse(400, 'Nextcloud is not connected. Connect it in Settings.'); + await assertSafeHttpsUrl(user.nextcloud_url, 'Nextcloud URL'); + + var password; + try { password = cryptoUtil.decryptString(user.nextcloud_token); } + catch (e) { throw refuse(400, 'Nextcloud credentials are invalid. Reconnect in Settings.'); } + + var base = user.nextcloud_folder || '/PediatricScribe'; + var folder = base + '/' + new Date().toISOString().split('T')[0]; + var root = davRoot(user.nextcloud_url, user.nextcloud_user); + + // One MKCOL per segment: WebDAV will not create a tree in a single call, and + // a folder that already exists answers 405, which is success here. + var walked = ''; + for (var part of folder.split('/').filter(Boolean)) { + walked += '/' + part; + try { + await axios({ method: 'MKCOL', url: root + walked + '/', + auth: { username: user.nextcloud_user, password: password }, + timeout: 15000, maxRedirects: 0 }); + } catch (e) { /* already there, or the PUT below will report the real fault */ } + } + return { user: user, password: password, root: root, folder: folder }; +} + +/** Put bytes in the owner's Nextcloud. Returns the path it landed at. */ +async function send(userId, name, bytes, contentType) { + var place = await target(userId); + // The name reaches a filesystem, so it is reduced to characters that cannot + // traverse or confuse one. + var safe = String(name || 'resource').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'resource'; + + await axios({ + method: 'PUT', + url: place.root + place.folder + '/' + safe, + data: bytes, + auth: { username: place.user.nextcloud_user, password: place.password }, + headers: { 'Content-Type': contentType || 'application/octet-stream' }, + timeout: 60000, + maxRedirects: 0, + maxBodyLength: Infinity + }); + + // Migrate a legacy plaintext token to encrypted form on first successful use. + if (!cryptoUtil.isEncrypted(place.user.nextcloud_token)) { + db.run('UPDATE users SET nextcloud_token = ? WHERE id = ?', + [cryptoUtil.encryptString(place.password), userId]).catch(function () {}); + } + return place.folder + '/' + safe; +} + +module.exports = { send, target, davRoot }; diff --git a/test/model-roster-pruning.test.js b/test/model-roster-pruning.test.js new file mode 100644 index 00000000..94d18899 --- /dev/null +++ b/test/model-roster-pruning.test.js @@ -0,0 +1,46 @@ +// A model that leaves the roster has to leave every list that names it. The +// allowed lists are advisory copies of the roster; the roster is the fact. A +// stale id there is invisible until someone asks a clinical question and the +// request fails at the gateway. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/adminConfig.js'), 'utf8'); + +test('one helper owns forgetting a model, and covers every list that names one', () => { + assert.match(route, /async function forgetModelEverywhere\(ids\)/); + for (const key of ['clinical_assistant.allowed_models', + 'clinical_assistant.allowed_image_models', + 'clinical_assistant.image_model_roster']) { + assert.ok(route.includes("'" + key + "'"), key + ' is not pruned'); + } + // And a default pointing at a model that has gone. + assert.match(route, /gone\.indexOf\(current\) !== -1\) await db\.setSetting\('models\.default', ''\)/); +}); + +test('removing a custom model prunes it', () => { + const handler = route.slice(route.indexOf("router.delete('/config/models/custom")); + assert.match(handler.slice(0, 900), /await forgetModelEverywhere\(modelId\)/); +}); + +test('disabling a model prunes it too — it stops being selectable either way', () => { + const handler = route.slice(route.indexOf("router.put('/config/models/toggle")); + assert.match(handler.slice(0, 1600), /if \(!enabled\) await forgetModelEverywhere\(modelId\)/); + // Re-enabling must NOT silently re-allow it: that is a separate decision. + assert.doesNotMatch(handler.slice(0, 1600), /if \(enabled\) await forgetModelEverywhere/); +}); + +test('clearing every model clears every allowed list', () => { + const handler = route.slice(route.indexOf("router.post('/config/models/clear-all")); + assert.match(handler.slice(0, 900), /await db\.setSetting\(key, ''\)/); + assert.match(handler.slice(0, 900), /Nothing is on the roster any more/); +}); + +test('a list is only written when it actually changed', () => { + // Avoids a settings write, an updated_at bump and a cache refresh per removal + // when nothing referenced the model. + const fn = route.slice(route.indexOf('async function forgetModelEverywhere')); + assert.match(fn.slice(0, 1200), /if \(kept\.join\(','\) !== current\)/); +}); diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js index d3258797..713dde32 100644 --- a/test/my-resources-refine.test.js +++ b/test/my-resources-refine.test.js @@ -78,6 +78,7 @@ function router(t, overrides = {}) { '../utils/generatedImages': { service: () => ({ get: async () => null, asset: async () => null }), workflows: ['my_resources'] }, '../utils/learningRetrieval': { retrieve: async () => ({ context: '', sources: [], reason: null }) }, '../utils/logger': quiet, + '../utils/nextcloudFiles': { send: async () => '/PediatricScribe/2026-01-01/x.pptx' }, '../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/resource-to-nextcloud.test.js b/test/resource-to-nextcloud.test.js new file mode 100644 index 00000000..5fde61e8 --- /dev/null +++ b/test/resource-to-nextcloud.test.js @@ -0,0 +1,67 @@ +// Sending a resource to Nextcloud pushes the rendered file, not the markdown. +// A .pptx landing in someone's own storage is the thing worth having; a text +// blob is not, and it is not what they would have downloaded. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); +const route = read('src/routes/myResources.js'); +const util = read('src/utils/nextcloudFiles.js'); +const ui = read('public/js/myResources.js'); + +test('the file is rendered by the same path a download uses', () => { + const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'")); + assert.match(handler.slice(0, 2200), /documentExport\.render\(row\.markdown, row\.kind, format/); + assert.match(handler.slice(0, 2200), /deck: deck/, 'the stored deck, so it renders as designed'); + assert.match(handler.slice(0, 2200), /collectFigures\(row\.image_ids/, 'with its figures'); + // Never the markdown on its own. + assert.doesNotMatch(handler.slice(0, 2200), /send\([^)]*row\.markdown/); +}); + +test('it is scoped to the owner and refuses a format the resource cannot be', () => { + const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'")); + assert.match(handler.slice(0, 2200), /WHERE id = \? AND user_id = \?/); + assert.match(handler.slice(0, 2200), /An article has no slides/); + assert.match(handler.slice(0, 2200), /documentExport\.isSupported\(format\)/); +}); + +test('a disconnected Nextcloud says so, rather than failing as a server error', () => { + assert.match(util, /Nextcloud is not connected\. Connect it in Settings\./); + assert.match(util, /Nextcloud credentials are invalid\. Reconnect in Settings\./); + // Those carry a status the route passes through instead of flattening to 502. + const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'")); + assert.match(handler.slice(0, 2400), /if \(err\.statusCode\) return res\.status\(err\.statusCode\)/); +}); + +test('one module knows how to put a file in Nextcloud', () => { + // A route importing another route is what this replaced. + assert.match(route, /require\('\.\.\/utils\/nextcloudFiles'\)/); + assert.doesNotMatch(route, /require\('\.\/nextcloud'\)/); + assert.match(util, /async function send\(userId, name, bytes, contentType\)/); +}); + +test('the filename cannot traverse or confuse a filesystem', () => { + assert.match(util, /replace\(\/\[\^a-zA-Z0-9\._-\]\/g, '_'\)/); + assert.match(util, /\.slice\(0, 120\) \|\| 'resource'/, 'and cannot end up empty'); +}); + +test('the folder tree is made a segment at a time, and an existing one is not an error', () => { + // WebDAV will not create a tree in one call; MKCOL on an existing folder is 405. + assert.match(util, /for \(var part of folder\.split\('\/'\)\.filter\(Boolean\)\)/); + assert.match(util, /already there, or the PUT below will report the real fault/); +}); + +test('the button appears only when there is a Nextcloud to send to', () => { + assert.match(route, /nextcloudConnected: Boolean/); + assert.match(ui, /if \(nextcloudConnected\) \{/); + assert.match(ui, /data-nextcloud|dataset\.nextcloud/); + // An article offers Word, a deck offers PowerPoint. + assert.match(ui, /row\.kind === 'article' \? 'docx' : 'pptx'/); +}); + +test('a legacy plaintext token is encrypted on first successful use', () => { + assert.match(util, /if \(!cryptoUtil\.isEncrypted\(place\.user\.nextcloud_token\)\)/); + assert.match(util, /cryptoUtil\.encryptString\(place\.password\)/); +});