From 4f6fe6bb373b78310dfc6559c191b132eee6ce13 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 14 Jul 2026 11:09:42 +0000 Subject: [PATCH] Add Nextcloud WebDAV directory browser endpoint GET /settings/api/integrations/nextcloud/browse?path= Uses stored Nextcloud credentials to PROPFIND the given path and returns a sorted list of subdirectories for the UI picker. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/integrations.js | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/routes/integrations.js b/src/routes/integrations.js index de42318..7ac6b75 100644 --- a/src/routes/integrations.js +++ b/src/routes/integrations.js @@ -264,6 +264,65 @@ export async function integrationRoutes(app) { return { ...row.config, ...decryptSecrets(row) } }) + // GET /settings/api/integrations/nextcloud/browse?path=/ — list directories via WebDAV PROPFIND + app.get('/integrations/nextcloud/browse', { preHandler: requireAdmin }, async (req, reply) => { + const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', ['nextcloud']) + if (!row?.enabled) return reply.status(400).send({ error: 'Nextcloud integration is not enabled' }) + const creds = { ...row.config, ...decryptSecrets(row) } + if (!creds.base_url || !creds.username || !creds.password) { + return reply.status(400).send({ error: 'Nextcloud credentials not fully configured' }) + } + + const rawPath = (req.query.path || '/').replace(/\/+/g, '/') + if (rawPath.includes('..') || !rawPath.startsWith('/')) { + return reply.status(400).send({ error: 'Invalid path' }) + } + const browsePath = rawPath === '/' ? '/' : rawPath.replace(/\/$/, '') + + const davBase = `${creds.base_url.replace(/\/$/, '')}/remote.php/dav/files/${encodeURIComponent(creds.username)}` + const davUrl = browsePath === '/' ? `${davBase}/` : `${davBase}${browsePath}/` + const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64') + + let xmlRes + try { + xmlRes = await fetch(davUrl, { + method: 'PROPFIND', + headers: { + Authorization: `Basic ${auth}`, + Depth: '1', + 'Content-Type': 'application/xml', + }, + body: '', + signal: AbortSignal.timeout(10_000), + }) + } catch (e) { + return reply.status(502).send({ error: `Could not reach Nextcloud: ${e.message}` }) + } + + if (xmlRes.status === 401) return reply.status(502).send({ error: 'Nextcloud credentials rejected' }) + if (xmlRes.status === 404) return reply.status(404).send({ error: 'Path not found' }) + if (xmlRes.status !== 207) return reply.status(502).send({ error: `Nextcloud returned ${xmlRes.status}` }) + + const xml = await xmlRes.text() + const prefix = `/remote.php/dav/files/${creds.username}` + const dirs = [] + + for (const block of xml.split(/<[^:>]*:?response[^>]*>/).slice(1)) { + if (!block.includes('collection')) continue + const m = block.match(/<[^:>]*:?href[^>]*>([^<]+)<\/[^:>]*:?href>/) + if (!m) continue + const decoded = decodeURIComponent(m[1].trim()) + if (!decoded.startsWith(prefix)) continue + const relPath = decoded.slice(prefix.length).replace(/\/$/, '') || '/' + if (relPath === browsePath) continue // skip self + const name = relPath.split('/').filter(Boolean).pop() || '' + if (name) dirs.push({ name, path: relPath }) + } + + dirs.sort((a, b) => a.name.localeCompare(b.name)) + return { path: browsePath, dirs } + }) + // PUT /settings/api/integrations/newbook/rooms — save reorder + colour changes app.put('/integrations/newbook/rooms', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query(`SELECT value FROM global_config WHERE key = 'newbook.rooms'`)