Add Nextcloud WebDAV directory browser endpoint

GET /settings/api/integrations/nextcloud/browse?path=<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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 11:09:42 +00:00
parent 8a4c4182ec
commit 4f6fe6bb37

View file

@ -264,6 +264,65 @@ export async function integrationRoutes(app) {
return { ...row.config, ...decryptSecrets(row) } 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: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:displayname/></d:prop></d:propfind>',
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 // PUT /settings/api/integrations/newbook/rooms — save reorder + colour changes
app.put('/integrations/newbook/rooms', { preHandler: requireAdmin }, async (req, reply) => { 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'`) const { rows: [row] } = await pool.query(`SELECT value FROM global_config WHERE key = 'newbook.rooms'`)