383 lines
18 KiB
JavaScript
383 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 — SambaPOS uses MSSQL; host may be "server\INSTANCE"
|
|
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: sql } = await import('mssql')
|
|
const [sqlServer, sqlInstance] = creds.sql_host.split('\\')
|
|
const cfg = {
|
|
server: sqlServer,
|
|
database: creds.sql_database,
|
|
authentication: { type: 'default', options: { userName: creds.sql_username, password: creds.sql_password } },
|
|
options: { trustServerCertificate: true, connectTimeout: 8000, ...(sqlInstance ? { instanceName: sqlInstance } : { port: parseInt(creds.sql_port || '1433') }) },
|
|
}
|
|
const pool = await sql.connect(cfg)
|
|
await pool.request().query('SELECT 1')
|
|
await pool.close()
|
|
} catch (e) { errors.push(`SQL: ${e.message}`) }
|
|
// GraphQL test — token endpoint lives at origin, not inside /api path
|
|
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 = new URL(creds.graphql_endpoint).origin
|
|
const tokenRes = await fetch(`${base}/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,
|
|
}),
|
|
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/graphql`, {
|
|
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(' | '))
|
|
return { ok: true, message: 'SQL + GraphQL connected' }
|
|
}
|
|
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
|
|
})
|
|
}
|