pediatric-ai-scribe-v3/src/routes/nextcloud.js
Daniel cb17a12172 Security hardening: PHI encryption, argon2, DOMPurify, SRI
- App-layer AES-256-GCM crypto helper (src/utils/crypto.js)
- Nextcloud tokens encrypted at rest; transparent migration on next use
- Audio backups encrypted at rest (version byte 0x01 envelope); legacy
  rows still decrypt as-is until overwritten
- argon2id password hashing via src/utils/passwords.js with bcrypt
  fallback; bcrypt hashes rehashed to argon2id on next successful login.
  argon2 package is optional — server keeps running with bcrypt only
  until npm install adds the native dep
- PHI redactor for audit log details (src/utils/redact.js) — strips SSN,
  phone, email, DoB, long IDs; caps at 500 chars; detects note bodies
- DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in
  Learning Hub content rendering
- SRI integrity hashes added for Font Awesome CSS and Chart.js
- Magic-byte file-type verification on document uploads
  (src/utils/fileType.js)
- Generic 500 error responses via src/utils/errors.js applied to
  nextcloud and audioBackups; full detail still logged server-side
- DATA_ENCRYPTION_KEY env documented in .env.example

Deploy: requires rebuild of the container image to pick up the new
files and `npm install` (adds argon2). Existing users keep working
because bcrypt stays available and crypto helpers pass through
plaintext when the key is not yet set in dev.
2026-04-14 02:49:38 +02:00

84 lines
4.5 KiB
JavaScript

var express = require('express');
var router = express.Router();
var axios = require('axios');
var { authMiddleware } = require('../middleware/auth');
var db = require('../db/database');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
var { serverError } = require('../utils/errors');
router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
try {
var { nextcloudUrl, username, appPassword, folder } = req.body;
if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' });
var cleanUrl = nextcloudUrl.replace(/\/+$/, '');
var targetFolder = (folder || '/PediatricScribe').replace(/\/+$/, '');
try {
await axios({ method: 'PROPFIND', url: cleanUrl + '/remote.php/dav/files/' + username + '/', auth: { username: username, password: appPassword }, headers: { Depth: '0' } });
} catch (e) {
return res.status(400).json({ error: 'Cannot connect. Check credentials.' });
}
var parts = targetFolder.split('/').filter(Boolean);
var current = '';
for (var i = 0; i < parts.length; i++) {
current += '/' + parts[i];
try { await axios({ method: 'MKCOL', url: cleanUrl + '/remote.php/dav/files/' + username + current + '/', auth: { username: username, password: appPassword } }); } catch (e) {}
}
await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?',
[cleanUrl, username, cryptoUtil.encryptString(appPassword), targetFolder, req.user.id]);
res.json({ success: true, message: 'Connected! Files saved to ' + targetFolder + '/' });
logger.audit(req.user.id, 'nextcloud_connect', 'Connected Nextcloud', req, { category: 'integration' });
} catch (err) { return serverError(res, 'Nextcloud connect', err, 'Could not connect to Nextcloud'); }
});
router.post('/nextcloud/export', authMiddleware, async function(req, res) {
try {
var { content, filename, type } = req.body;
var user = await db.get('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?', [req.user.id]);
if (!user || !user.nextcloud_url) return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings.' });
var baseFolder = user.nextcloud_folder || '/PediatricScribe';
var today = new Date().toISOString().split('T')[0];
var targetPath = baseFolder + '/' + today;
var pw;
try { pw = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
var parts = targetPath.split('/').filter(Boolean);
var current = '';
for (var i = 0; i < parts.length; i++) {
current += '/' + parts[i];
try { await axios({ method: 'MKCOL', url: user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + current + '/', auth: { username: user.nextcloud_user, password: pw } }); } catch (e) {}
}
var time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');
var safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_');
var filePath = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + targetPath + '/' + safeName + '.txt';
await axios({ method: 'PUT', url: filePath, data: content, auth: { username: user.nextcloud_user, password: pw }, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
// Migrate legacy plaintext token to encrypted form on first successful use.
if (!cryptoUtil.isEncrypted(user.nextcloud_token)) {
db.run('UPDATE users SET nextcloud_token = ? WHERE id = ?', [cryptoUtil.encryptString(pw), req.user.id]).catch(function(){});
}
res.json({ success: true, message: 'Saved to ' + targetPath + '/' + safeName + '.txt' });
logger.audit(req.user.id, 'nextcloud_export', 'Exported note to Nextcloud', req, { category: 'export' });
} catch (err) { return serverError(res, 'Nextcloud export', err, 'Could not export to Nextcloud'); }
});
router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
try {
await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]);
res.json({ success: true });
logger.audit(req.user.id, 'nextcloud_disconnect', 'Disconnected Nextcloud', req, { category: 'integration' });
} catch (err) { return serverError(res, 'Nextcloud disconnect', err, 'Could not disconnect'); }
});
module.exports = router;