Passwords are encrypted with AES-256-GCM before being written to users.json. The GET /api/users/:token endpoint no longer returns the password field, so the settings form asks users to re-enter only when changing it. Legacy plaintext passwords in users.json are decrypted transparently and re-encrypted on next save. Requires a new ENCRYPTION_KEY env var (64-char hex, 32 bytes). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
211 lines
6.0 KiB
JavaScript
211 lines
6.0 KiB
JavaScript
import 'dotenv/config'
|
|
import express from 'express'
|
|
import axios from 'axios'
|
|
import { readFileSync, writeFileSync } from 'fs'
|
|
import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'crypto'
|
|
import path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
|
|
// --- Encryption setup ---
|
|
|
|
const ENCRYPTION_KEY_HEX = process.env.ENCRYPTION_KEY
|
|
if (!ENCRYPTION_KEY_HEX || ENCRYPTION_KEY_HEX.length !== 64) {
|
|
console.error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes).')
|
|
console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"')
|
|
process.exit(1)
|
|
}
|
|
const ENCRYPTION_KEY = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
|
|
|
|
function encryptPassword(plaintext) {
|
|
const iv = randomBytes(12)
|
|
const cipher = createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv)
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
const authTag = cipher.getAuthTag()
|
|
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`
|
|
}
|
|
|
|
function decryptPassword(stored) {
|
|
if (!stored.startsWith('enc:')) {
|
|
// Legacy plaintext — transparently supported until next save
|
|
return stored
|
|
}
|
|
const [, ivHex, authTagHex, encryptedHex] = stored.split(':')
|
|
const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, Buffer.from(ivHex, 'hex'))
|
|
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
|
return decipher.update(Buffer.from(encryptedHex, 'hex')) + decipher.final('utf8')
|
|
}
|
|
|
|
// --- App setup ---
|
|
|
|
const app = express()
|
|
|
|
app.use(express.urlencoded({ extended: false }))
|
|
app.use(express.json())
|
|
app.use(express.text({ type: '*/*' }))
|
|
app.use(express.static(path.join(__dirname, 'public')))
|
|
|
|
const PORT = process.env.PORT || 3000
|
|
const USERS_FILE = process.env.USERS_FILE || 'users.json'
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
|
|
|
if (!ADMIN_PASSWORD) {
|
|
console.warn('Warning: ADMIN_PASSWORD is not set. New user registration is disabled.')
|
|
}
|
|
|
|
let users = {}
|
|
try {
|
|
users = JSON.parse(readFileSync(USERS_FILE, 'utf8'))
|
|
console.log(`Loaded ${Object.keys(users).length} user(s) from ${USERS_FILE}`)
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
console.log(`${USERS_FILE} not found, starting with empty user list`)
|
|
} else {
|
|
console.error(`Failed to load ${USERS_FILE}: ${err.message}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
function saveUsers() {
|
|
writeFileSync(USERS_FILE, JSON.stringify(users, null, 2))
|
|
}
|
|
|
|
// --- Setup API ---
|
|
|
|
app.post('/api/users', (req, res) => {
|
|
if (!ADMIN_PASSWORD) {
|
|
return res.status(503).json({ error: 'Registration is disabled: ADMIN_PASSWORD is not configured.' })
|
|
}
|
|
if (req.body.adminPassword !== ADMIN_PASSWORD) {
|
|
return res.status(403).json({ error: 'Invalid admin password.' })
|
|
}
|
|
|
|
const { nextcloudUrl, username, password, folder } = req.body
|
|
if (!nextcloudUrl || !username || !password) {
|
|
return res.status(400).json({ error: 'nextcloudUrl, username, and password are required.' })
|
|
}
|
|
|
|
const token = randomUUID()
|
|
users[token] = {
|
|
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
|
|
username,
|
|
password: encryptPassword(password),
|
|
folder: folder || 'AudioPen'
|
|
}
|
|
saveUsers()
|
|
|
|
const webhookUrl = `${req.protocol}://${req.get('host')}/webhook/${token}`
|
|
console.log(`New user registered: ${username}`)
|
|
res.json({ token, webhookUrl })
|
|
})
|
|
|
|
// Returns settings without the password
|
|
app.get('/api/users/:token', (req, res) => {
|
|
const user = users[req.params.token]
|
|
if (!user) return res.status(404).json({ error: 'Not found.' })
|
|
const { password: _omit, ...safeUser } = user
|
|
res.json(safeUser)
|
|
})
|
|
|
|
// Password is optional — omit to keep the existing one
|
|
app.put('/api/users/:token', (req, res) => {
|
|
const existing = users[req.params.token]
|
|
if (!existing) return res.status(404).json({ error: 'Not found.' })
|
|
|
|
const { nextcloudUrl, username, password, folder } = req.body
|
|
if (!nextcloudUrl || !username) {
|
|
return res.status(400).json({ error: 'nextcloudUrl and username are required.' })
|
|
}
|
|
|
|
users[req.params.token] = {
|
|
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
|
|
username,
|
|
password: password ? encryptPassword(password) : existing.password,
|
|
folder: folder || 'AudioPen'
|
|
}
|
|
saveUsers()
|
|
|
|
console.log(`User updated: ${username}`)
|
|
res.json({ success: true })
|
|
})
|
|
|
|
// --- Webhook ---
|
|
|
|
app.post('/webhook/:token', async (req, res) => {
|
|
const user = users[req.params.token]
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid token' })
|
|
}
|
|
|
|
try {
|
|
console.log(`Received webhook for user: ${user.username}`)
|
|
|
|
console.log('HEADERS:', req.headers)
|
|
console.log('RAW BODY:', req.body)
|
|
|
|
let data = req.body
|
|
|
|
// If body comes in as raw string (VERY likely here)
|
|
if (typeof data === 'string') {
|
|
try {
|
|
const params = new URLSearchParams(data)
|
|
data = Object.fromEntries(params.entries())
|
|
} catch (e) {
|
|
console.log('Failed to parse raw body')
|
|
}
|
|
}
|
|
|
|
console.log('PARSED DATA:', data)
|
|
|
|
const title = data.title || 'Untitled Note'
|
|
const body = data.body || ''
|
|
const transcript = data.orig_transcript || ''
|
|
|
|
const safeTitle = title
|
|
.replace(/[^a-z0-9]/gi, '-')
|
|
.replace(/-+/g, '-')
|
|
.toLowerCase()
|
|
|
|
const timestamp = new Date().toISOString().replace(/:/g, '-')
|
|
const filename = `${timestamp}-${safeTitle}.md`
|
|
|
|
const markdown = `# ${title}
|
|
|
|
${body}
|
|
|
|
---
|
|
|
|
## Original Transcript
|
|
|
|
${transcript}
|
|
`
|
|
|
|
const webdavUrl =
|
|
`${user.nextcloudUrl}` +
|
|
`/remote.php/dav/files/` +
|
|
`${user.username}/` +
|
|
`${user.folder}/` +
|
|
`${filename}`
|
|
|
|
await axios.put(webdavUrl, markdown, {
|
|
auth: {
|
|
username: user.username,
|
|
password: decryptPassword(user.password)
|
|
},
|
|
headers: {
|
|
'Content-Type': 'text/markdown'
|
|
}
|
|
})
|
|
|
|
res.json({ success: true, filename })
|
|
} catch (err) {
|
|
console.error(err.response?.data || err.message)
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Webhook server listening on ${PORT}`)
|
|
})
|