feat: send a resource to your own Nextcloud, as the rendered file
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

A .pptx landing in someone's own storage is worth having; a markdown blob is
not, and it is not what they would have downloaded. So this renders through
exactly the path the download uses — stored deck, its figures, the chosen
theme — and PUTs the bytes. The file never travels through the browser.

Offered only when a Nextcloud is connected: an action that always fails is
worse than one that is not offered. An article offers Word, a deck PowerPoint,
and asking for slides from an article is refused with the reason.

Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two
callers want it and neither should grow its own copy of the WebDAV dance — make
the dated folder a segment at a time, PUT, migrate a legacy plaintext token —
because it reaches into storage that is not ours and a second slightly
different copy is how the two drift. It also replaces a route importing another
route.

Also: a model that leaves the roster now leaves every list that names it.
clinical_assistant.allowed_models and the image roster are advisory copies of
the roster, and a stale id there was invisible until someone asked a clinical
question and the request failed at the gateway. Removing or disabling a model
prunes it; clearing the roster clears them. Re-enabling deliberately does not
re-allow it — that is a separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 20:34:58 +02:00
parent 025290d64a
commit 24c8d71b7e
7 changed files with 327 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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 };

View file

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

View file

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

View file

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