feat: the site's Nextcloud is filled in, not typed — the settings page is one button
Some checks failed
Forgejo Docker Build / End-to-end (browser) (push) Blocked by required conditions
Forgejo Docker Build / Root app tests (push) Successful in 55s
Forgejo Docker Build / Build Docker image (push) Has been cancelled

nextcloud.url (or NEXTCLOUD_URL) names the site's Nextcloud. With it set,
the address field is hidden and the page reads "Sign in with Nextcloud",
with the app-password route underneath for whoever needs it; both routes
use the default when no address is given. Without it, nothing changes.

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 14:33:25 +02:00
parent 28877ba0c4
commit d893452d9a
7 changed files with 59 additions and 4 deletions

View file

@ -124,7 +124,9 @@
<p>Save generated notes and teaching material to your own Nextcloud.</p>
<div id="nc-status">Not connected</div>
<div class="form-group">
<!-- Shown only when the site has not set its own Nextcloud; then
there is nothing to type and the button below is the whole form. -->
<div class="form-group" id="nc-url-group">
<label for="nc-url">Nextcloud address</label>
<input type="text" id="nc-url" placeholder="https://cloud.example.com">
</div>

View file

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

View file

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

View file

@ -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(/\/+$/, '');

View file

@ -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.',

View file

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

View file

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