settings/src/routes/integrations.js
2026-07-14 13:09:04 +00:00

380 lines
18 KiB
JavaScript

import { pool } from '../db.js'
import { encrypt, decrypt } from '../crypto.js'
import { requireAdmin } from '../auth.js'
import { INTEGRATIONS, MASKED } from '../integrations/schema.js'
import { testConnection as newbookTest, fetchSiteList } from '../integrations/newbook.js'
import { testConnection as resosTest } from '../integrations/resos.js'
const AUTH_URL = process.env.AUTH_URL || 'http://10.10.10.101:3001'
function bustAuthCache() {
const secret = process.env.SETTINGS_SECRET || ''
fetch(`${AUTH_URL}/api/auth/internal/cache-bust`, {
method: 'POST',
headers: { Authorization: `Bearer ${secret}` },
signal: AbortSignal.timeout(3000),
}).catch(() => {})
}
function decryptSecrets(row) {
if (!row.secrets) return {}
try { return JSON.parse(decrypt(row.secrets)) } catch { return {} }
}
function maskRow(row) {
const schema = INTEGRATIONS[row.slug]
// Always return all schema-defined config fields, even if not yet saved
const config = {}
for (const f of schema?.configFields ?? []) {
config[f] = row.config?.[f] ?? ''
}
const existing = decryptSecrets(row)
const masked = {}
for (const f of schema?.secretFields ?? []) {
masked[f] = existing[f] ? MASKED : null
}
return { slug: row.slug, name: row.name, config, secrets: masked, enabled: row.enabled, updated_at: row.updated_at }
}
export async function integrationRoutes(app) {
// GET /settings/api/integrations
app.get('/integrations', { preHandler: requireAdmin }, async () => {
const { rows } = await pool.query('SELECT * FROM integrations ORDER BY slug')
return rows.map(maskRow)
})
// GET /settings/api/integrations/:slug
app.get('/integrations/:slug', { preHandler: requireAdmin }, async (req, reply) => {
const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug])
if (!row) return reply.status(404).send({ error: 'Not found' })
return maskRow(row)
})
// PUT /settings/api/integrations/:slug
app.put('/integrations/:slug', { preHandler: requireAdmin }, async (req, reply) => {
const { slug } = req.params
const schema = INTEGRATIONS[slug]
if (!schema) return reply.status(404).send({ error: 'Unknown integration' })
const body = req.body || {}
// Build config (plaintext fields)
const config = {}
for (const f of schema.configFields) {
if (body[f] !== undefined) config[f] = body[f]
}
// Merge secrets — skip masked sentinel values so we don't overwrite with garbage
const { rows: [existing] } = await pool.query('SELECT secrets FROM integrations WHERE slug = $1', [slug])
const prev = existing ? decryptSecrets(existing) : {}
const next = { ...prev }
for (const f of schema.secretFields) {
if (body[f] !== undefined && body[f] !== MASKED && body[f] !== '') {
next[f] = body[f]
}
}
const secretsEnc = Object.keys(next).length ? encrypt(JSON.stringify(next)) : null
const enabled = body.enabled !== undefined ? Boolean(body.enabled) : true
await pool.query(`
INSERT INTO integrations (slug, name, config, secrets, enabled, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (slug) DO UPDATE SET
config = EXCLUDED.config,
secrets = EXCLUDED.secrets,
enabled = EXCLUDED.enabled,
updated_at = NOW()
`, [slug, schema.name, config, secretsEnc, enabled])
const { rows: [updated] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [slug])
if (slug === 'workforce' || slug === 'smtp') bustAuthCache()
return maskRow(updated)
})
// POST /settings/api/integrations/:slug/test
app.post('/integrations/:slug/test', { preHandler: requireAdmin }, async (req, reply) => {
const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug])
if (!row) return reply.status(404).send({ error: 'Not found' })
const creds = { ...row.config, ...decryptSecrets(row) }
try {
if (req.params.slug === 'newbook') await newbookTest(creds)
else if (req.params.slug === 'resos') await resosTest(creds)
else if (req.params.slug === 'workforce') {
if (!creds.bearer_token) throw new Error('Workforce bearer token not configured')
const baseUrl = creds.base_url || 'https://my.workforce.com'
const res = await fetch(`${baseUrl}/api/v2/users/me`, {
headers: { Authorization: `Bearer ${creds.bearer_token}` },
signal: AbortSignal.timeout(8000),
})
if (!res.ok) throw new Error(`Workforce bearer token invalid (${res.status})`)
}
else if (req.params.slug === 'smtp') {
const nodemailer = await import('nodemailer')
const transporter = nodemailer.default.createTransport({
host: creds.host, port: parseInt(creds.port || '587'),
auth: { user: creds.user, pass: creds.pass },
})
await transporter.verify()
}
else if (req.params.slug === 'nextcloud') {
if (!creds.base_url || !creds.username || !creds.password) throw new Error('Nextcloud base URL, username and password are required')
const url = `${creds.base_url.replace(/\/$/, '')}/remote.php/dav/files/${encodeURIComponent(creds.username)}/`
const res = await fetch(url, {
method: 'PROPFIND',
headers: { Authorization: `Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString('base64')}`, Depth: '0' },
signal: AbortSignal.timeout(8000),
})
if (res.status === 401) throw new Error('Invalid Nextcloud credentials')
if (!res.ok) throw new Error(`Nextcloud returned ${res.status}`)
}
else if (req.params.slug === 'azure') {
if (!creds.endpoint || !creds.api_key) throw new Error('Azure endpoint and API key are required')
const base = creds.endpoint.replace(/\/$/, '')
const res = await fetch(`${base}/documentintelligence/info?api-version=2024-11-30`, {
headers: { 'Ocp-Apim-Subscription-Key': creds.api_key },
signal: AbortSignal.timeout(8000),
})
if (res.status === 401 || res.status === 403) throw new Error('Invalid Azure API key')
if (!res.ok) throw new Error(`Azure returned ${res.status}`)
}
else if (req.params.slug === 'sambapos') {
const errors = []
// SQL test
try {
if (!creds.sql_host || !creds.sql_database || !creds.sql_username || !creds.sql_password)
throw new Error('SQL host, database, username and password are required')
const { default: pg } = await import('pg')
const sqlPool = new pg.Pool({
host: creds.sql_host, port: parseInt(creds.sql_port || '5432'),
database: creds.sql_database, user: creds.sql_username, password: creds.sql_password,
connectionTimeoutMillis: 8000, max: 1,
})
await sqlPool.query('SELECT 1')
await sqlPool.end()
} catch (e) { errors.push(`SQL: ${e.message}`) }
// GraphQL test — obtain token via password grant then run a minimal query
try {
if (!creds.graphql_endpoint || !creds.graphql_username || !creds.graphql_password || !creds.graphql_client_id)
throw new Error('GraphQL endpoint, username, password and client ID are required')
const base = creds.graphql_endpoint.replace(/\/$/, '')
const tokenRes = await fetch(`${base}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password',
username: creds.graphql_username,
password: creds.graphql_password,
client_id: creds.graphql_client_id,
scope: 'api',
}),
signal: AbortSignal.timeout(8000),
})
if (!tokenRes.ok) throw new Error(`Token request failed (${tokenRes.status})`)
const { access_token } = await tokenRes.json()
if (!access_token) throw new Error('No access token returned')
const gqlRes = await fetch(`${base}/api`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${access_token}` },
body: JSON.stringify({ query: '{ __typename }' }),
signal: AbortSignal.timeout(8000),
})
if (!gqlRes.ok) throw new Error(`GraphQL request failed (${gqlRes.status})`)
} catch (e) { errors.push(`GraphQL: ${e.message}`) }
if (errors.length) throw new Error(errors.join(' | '))
}
else return reply.status(400).send({ error: `No test available for ${req.params.slug}` })
return { ok: true }
} catch (e) {
return { ok: false, error: e.message }
}
})
// POST /settings/api/integrations/newbook/sync-rooms
// GET /settings/api/integrations/workforce/locations — derive locations from teams list
app.get('/integrations/workforce/locations', { preHandler: requireAdmin }, async (req, reply) => {
const { rows: [row] } = await pool.query(`SELECT * FROM integrations WHERE slug = 'workforce'`)
if (!row?.enabled) return reply.status(400).send({ error: 'Workforce integration not enabled' })
const creds = { ...row.config, ...decryptSecrets(row) }
if (!creds.bearer_token) return reply.status(400).send({ error: 'Workforce bearer token not configured' })
const baseUrl = creds.base_url || 'https://my.workforce.com'
// /api/v2/locations list may be billing-locked — get unique IDs from teams then fetch each individually
const teams = []
let page = 1
while (true) {
const res = await fetch(`${baseUrl}/api/v2/departments?page=${page}&page_size=100`, {
headers: { Authorization: `Bearer ${creds.bearer_token}` },
signal: AbortSignal.timeout(8000),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
const msg = (() => { try { return JSON.parse(body).error } catch { return body.slice(0, 200) } })()
return reply.status(502).send({ error: msg || `Workforce API error (${res.status})` })
}
const data = await res.json()
const items = Array.isArray(data) ? data : (data.departments ?? [])
teams.push(...items)
if (items.length < 100) break
page++
}
const locationIds = [...new Set(teams.map(t => t.location_id).filter(Boolean).map(String))]
// Fetch each location individually in parallel
const locations = await Promise.all(locationIds.map(async id => {
try {
const res = await fetch(`${baseUrl}/api/v2/locations/${id}`, {
headers: { Authorization: `Bearer ${creds.bearer_token}` },
signal: AbortSignal.timeout(8000),
})
if (!res.ok) return { id, name: `Location ${id}`, short_name: null }
const loc = await res.json()
return { id, name: loc.name ?? `Location ${id}`, short_name: loc.short_name ?? null }
} catch {
return { id, name: `Location ${id}`, short_name: null }
}
}))
return locations.sort((a, b) => a.name.localeCompare(b.name))
})
app.post('/integrations/newbook/sync-rooms', { preHandler: requireAdmin }, async (req, reply) => {
const { rows: [row] } = await pool.query(`SELECT * FROM integrations WHERE slug = 'newbook'`)
if (!row?.enabled) return reply.status(400).send({ error: 'Newbook integration not enabled' })
const creds = { ...row.config, ...decryptSecrets(row) }
if (!creds.api_key) return reply.status(400).send({ error: 'Newbook credentials not configured' })
let raw
try { raw = await fetchSiteList(creds) }
catch (e) { return reply.status(502).send({ error: e.message }) }
// Normalise: site_list returns array with category_id, category_name, site_id, site_name
const items = Array.isArray(raw) ? raw : (raw?.data ?? [])
const catMap = {}
const sites = []
for (const item of items) {
const cid = String(item.category_id)
if (!catMap[cid]) {
catMap[cid] = { id: cid, name: item.category_name, sort_order: Object.keys(catMap).length, colour: null }
}
if (item.site_id) {
sites.push({ id: String(item.site_id), name: item.site_name, category_id: cid })
}
}
// Preserve existing sort_order and colour customisations
const { rows: [prev] } = await pool.query(`SELECT value FROM global_config WHERE key = 'newbook.rooms'`)
const prevCats = {}
for (const c of prev?.value?.categories ?? []) prevCats[c.id] = c
const categories = Object.values(catMap).map(c => ({
...c,
sort_order: prevCats[c.id]?.sort_order ?? c.sort_order,
colour: prevCats[c.id]?.colour ?? null,
})).sort((a, b) => a.sort_order - b.sort_order)
const value = { categories, sites, synced_at: new Date().toISOString() }
await pool.query(`
INSERT INTO global_config (key, value, updated_at) VALUES ('newbook.rooms', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`, [value])
return value
})
// GET /settings/api/internal/global-config/:key — service-to-service read of global_config
// Authenticated by SETTINGS_SECRET bearer token; returns the value JSON for the given key.
app.get('/internal/global-config/:key', async (req, reply) => {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (!token || token !== process.env.SETTINGS_SECRET) {
return reply.status(401).send({ error: 'Unauthorized' })
}
const { rows: [row] } = await pool.query('SELECT value FROM global_config WHERE key = $1', [req.params.key])
if (!row) return reply.status(404).send({ error: 'Not found' })
return { value: row.value }
})
// GET /settings/api/internal/integration/:slug — service-to-service, no user session required
// Authenticated by SETTINGS_SECRET bearer token; returns unmasked credentials.
app.get('/internal/integration/:slug', async (req, reply) => {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (!token || token !== process.env.SETTINGS_SECRET) {
return reply.status(401).send({ error: 'Unauthorized' })
}
const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug])
if (!row) return reply.status(404).send({ error: 'Integration not found' })
if (!row.enabled) return reply.status(403).send({ error: 'Integration is disabled' })
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
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'`)
if (!row) return reply.status(404).send({ error: 'No room data — sync first' })
const categories = (req.body?.categories ?? row.value.categories).map((c, i) => ({ ...c, sort_order: i }))
const updated = { ...row.value, categories }
await pool.query(`UPDATE global_config SET value = $1, updated_at = NOW() WHERE key = 'newbook.rooms'`, [updated])
return updated
})
}