diff --git a/public/components/settings.html b/public/components/settings.html index de22f6b0..a3a945bb 100644 --- a/public/components/settings.html +++ b/public/components/settings.html @@ -124,7 +124,9 @@

Save generated notes and teaching material to your own Nextcloud.

Not connected
-
+ +
diff --git a/public/js/nextcloud.js b/public/js/nextcloud.js index 7790fb44..a2ed9611 100644 --- a/public/js/nextcloud.js +++ b/public/js/nextcloud.js @@ -12,7 +12,23 @@ statusEl.appendChild(document.createTextNode(' as ' + (user || ''))); } + // The site's Nextcloud, when there is one: the address is filled in and its + // field hidden, so the page reads "Sign in with Nextcloud" and nothing else. + function loadNextcloudDefault() { + fetch('/api/nextcloud/config', { headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (data) { + var group = document.getElementById('nc-url-group'); + var url = document.getElementById('nc-url'); + if (!data || !data.defaultUrl || !group || !url) return; + if (!url.value.trim()) url.value = data.defaultUrl; + group.classList.add('hidden'); + }) + .catch(function () {}); + } + function loadNextcloudStatus() { + loadNextcloudDefault(); fetch('/api/auth/me', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 70d61ea7..f2258acd 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -929,7 +929,7 @@ router.put('/config/:key(*)', async function(req, res) { } // Security: only allow known key prefixes - var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.']; + var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.', 'nextcloud.']; var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); }); if (!isAllowed) { return res.status(400).json({ error: 'Unknown config key' }); diff --git a/src/routes/nextcloud.js b/src/routes/nextcloud.js index 4105cad3..c591e476 100644 --- a/src/routes/nextcloud.js +++ b/src/routes/nextcloud.js @@ -40,9 +40,24 @@ function sweepLoginFlows() { for (var [key, flow] of loginFlows) if (flow.expires < now) loginFlows.delete(key); } +// The site's own Nextcloud. When it is set, nobody types an address: the +// settings page shows "Sign in with Nextcloud" and nothing else, with the +// app-password route underneath for whoever needs it. Someone may still +// connect a different server through the API; the default only fills the +// blank. +async function defaultNextcloudUrl() { + var configured = String(await db.getSetting('nextcloud.url') || process.env.NEXTCLOUD_URL || '').trim(); + return configured.replace(/\/+$/, ''); +} + +router.get('/nextcloud/config', authMiddleware, async function (req, res) { + try { res.json({ success: true, defaultUrl: await defaultNextcloudUrl() }); } + catch (e) { res.status(500).json({ error: 'Request failed' }); } +}); + router.post('/nextcloud/login-flow/start', authMiddleware, async function (req, res) { try { - var cleanUrl = String(req.body.nextcloudUrl || '').trim().replace(/\/+$/, ''); + var cleanUrl = String(req.body.nextcloudUrl || '').trim().replace(/\/+$/, '') || await defaultNextcloudUrl(); if (!cleanUrl) return res.status(400).json({ error: 'Enter your Nextcloud address' }); await assertSafeHttpsUrl(cleanUrl, 'Nextcloud URL'); @@ -143,6 +158,7 @@ router.post('/nextcloud/login-flow/poll', authMiddleware, async function (req, r router.post('/nextcloud/connect', authMiddleware, async function(req, res) { try { var { nextcloudUrl, username, appPassword, folder } = req.body; + nextcloudUrl = String(nextcloudUrl || '').trim() || await defaultNextcloudUrl(); if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' }); var cleanUrl = nextcloudUrl.replace(/\/+$/, ''); diff --git a/src/utils/openapiRoutes.js b/src/utils/openapiRoutes.js index 9ce8b433..5d017635 100644 --- a/src/utils/openapiRoutes.js +++ b/src/utils/openapiRoutes.js @@ -25,6 +25,10 @@ var parameters = { var operations = { // ── Speech ────────────────────────────────────────────────────────── + 'GET /api/nextcloud/config': { + summary: 'The site\'s Nextcloud address, if one is set', + description: 'nextcloud.url (or NEXTCLOUD_URL). When present the settings page hides the address field and the sign-in and app-password routes use it when none is given.' + }, 'PUT /api/admin/config/tts/default': { summary: 'Choose the default speech model and voice', description: 'Sets tts.model and tts.voice together and puts the model on the speech roster (tts.roster) if it is not there. Refused when the model does not accept the voice; the error lists the voices it does accept.', diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index ca3799dc..47ea18bb 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -237,3 +237,20 @@ test('citation quality is measured on the server, where answer and sources both assert.match(route, /tracker\.store\(req\.user\.id, question, result, sources\);/); }); + +test('the site\'s Nextcloud is filled in for people, not typed by them', () => { + // With nextcloud.url set, the settings page shows "Sign in with Nextcloud" + // and nothing else; the app-password route sits underneath for whoever + // needs it. Without it, the address field is there as before. + const route = read('src/routes/nextcloud.js'); + assert.match(route, /async function defaultNextcloudUrl\(\)/); + assert.match(route, /db\.getSetting\('nextcloud\.url'\) \|\| process\.env\.NEXTCLOUD_URL/); + assert.match(route, /router\.get\('\/nextcloud\/config', authMiddleware/); + assert.match(route, /replace\(\/\\\/\+\$\/, ''\) \|\| await defaultNextcloudUrl\(\);/, 'the login flow falls back to it'); + assert.match(route, /nextcloudUrl = String\(nextcloudUrl \|\| ''\)\.trim\(\) \|\| await defaultNextcloudUrl\(\);/, 'and so does the app-password route'); + assert.match(read('src/routes/adminConfig.js'), /'my_resources\.', 'nextcloud\.'\]/, 'an admin can set it'); + const js = read('public/js/nextcloud.js'); + assert.match(js, /fetch\('\/api\/nextcloud\/config'/); + assert.match(js, /group\.classList\.add\('hidden'\)/); + assert.match(read('public/components/settings.html'), /id="nc-url-group"/); +}); diff --git a/test/deck-review.test.js b/test/deck-review.test.js index 71f38e29..a64fdd84 100644 --- a/test/deck-review.test.js +++ b/test/deck-review.test.js @@ -154,7 +154,7 @@ test('the reviewer is admin-chosen, off by default, and runs once per change', ( // Still one pass. The verification runs on the result, never in a loop. assert.equal((refine.match(/deckReview\.review\(/g) || []).length, 1); // The key has to be writable, or saving it silently does nothing. - assert.match(read('src/routes/adminConfig.js'), /'clinical_assistant\.', 'my_resources\.'\]/); + assert.match(read('src/routes/adminConfig.js'), /'clinical_assistant\.', 'my_resources\.', 'nextcloud\.'\]/); // And the image can actually rasterise a deck. assert.match(read('Dockerfile'), /poppler-utils/); assert.match(read('src/utils/deckReview.js'), /'pdftoppm', \['-png', '-r', String\(RENDER_DPI\)/);