diff --git a/README.md b/README.md index d79a927..7796414 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # AudioPen Nextcloud Webhook -A small Node.js webhook service that receives notes from AudioPen and uploads them to a folder in Nextcloud using WebDAV. Supports multiple users, each with their own Nextcloud credentials and a unique webhook token. +A Node.js service that receives notes from AudioPen and uploads them to Nextcloud via WebDAV. Includes a web UI for self-service setup — each user enters their own Nextcloud credentials and gets a unique webhook URL. -When AudioPen sends a webhook request, the service: +When AudioPen sends a webhook, the service: 1. Validates the token in the URL -2. Receives the note content +2. Parses the note content (title, body, original transcript) 3. Creates a timestamped Markdown file -4. Uploads the file to the configured Nextcloud folder +4. Uploads it to the user's configured Nextcloud folder Example filename: @@ -17,49 +17,36 @@ Example filename: ## Setup -### 1. Configure users +### 1. Configure environment -Copy `users.json.example` to `users.json` and fill in credentials for each user: - -```json -{ - "abc123secrettoken": { - "nextcloudUrl": "https://nextcloud.example.com", - "username": "alice", - "password": "alice-app-password", - "folder": "AudioPen" - } -} -``` - -Each key is a secret token that identifies a user. Generate one with: - -```bash -node -e "console.log(crypto.randomUUID())" -``` - -### 2. Configure the server - -Optionally create a `.env` file to set the port or a custom path to `users.json`: +Create a `.env` file in the project root: ```env +ADMIN_PASSWORD=choose-a-strong-password PORT=3000 -USERS_FILE=users.json +# USERS_FILE=users.json # optional, defaults to users.json ``` -### 3. Run +`ADMIN_PASSWORD` is required to create new accounts via the web UI. Without it, registration is disabled (existing users can still update their settings). + +### 2. Run ```bash npm install node server.js ``` -### 4. Configure AudioPen +### 3. Register users -In AudioPen's webhook settings, set the URL to: +Open `http://localhost:3000` in a browser. Each user fills in their Nextcloud credentials and the admin password, then receives: -``` -https://your-server/webhook/ -``` +- A **webhook URL** to paste into AudioPen (`/webhook/`) +- A **settings page URL** to bookmark for updating credentials later (`/?token=`) -Each user gets their own URL containing their unique token. +### Returning users + +Visiting `/?token=` pre-fills the form with existing settings and allows updating credentials without needing the admin password. + +## Manual user management + +User credentials are stored in `users.json` (gitignored). You can also edit this file directly — see `users.json.example` for the format. diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c98fd44 --- /dev/null +++ b/public/index.html @@ -0,0 +1,416 @@ + + + + + + AudioPen → Nextcloud Setup + + + +
+ + + + + + + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ Use an app password, not your login password. +
+ +
+ + + Folder must already exist in your Nextcloud. +
+ +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + + + diff --git a/server.js b/server.js index 84db296..f7cd6b4 100644 --- a/server.js +++ b/server.js @@ -1,26 +1,102 @@ import 'dotenv/config' import express from 'express' import axios from 'axios' -import { readFileSync } from 'fs' +import { readFileSync, writeFileSync } from 'fs' +import { randomUUID } from 'crypto' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) 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) { - console.error(`Failed to load ${USERS_FILE}: ${err.message}`) - process.exit(1) + 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, + folder: folder || 'AudioPen' + } + saveUsers() + + const webhookUrl = `${req.protocol}://${req.get('host')}/webhook/${token}` + console.log(`New user registered: ${username}`) + res.json({ token, webhookUrl }) +}) + +app.get('/api/users/:token', (req, res) => { + const user = users[req.params.token] + if (!user) return res.status(404).json({ error: 'Not found.' }) + res.json(user) +}) + +app.put('/api/users/:token', (req, res) => { + if (!users[req.params.token]) return res.status(404).json({ error: 'Not found.' }) + + const { nextcloudUrl, username, password, folder } = req.body + if (!nextcloudUrl || !username || !password) { + return res.status(400).json({ error: 'nextcloudUrl, username, and password are required.' }) + } + + users[req.params.token] = { + nextcloudUrl: nextcloudUrl.replace(/\/$/, ''), + username, + 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) { @@ -38,9 +114,7 @@ app.post('/webhook/:token', async (req, res) => { // If body comes in as raw string (VERY likely here) if (typeof data === 'string') { try { - // try parsing as URL encoded manually const params = new URLSearchParams(data) - data = Object.fromEntries(params.entries()) } catch (e) { console.log('Failed to parse raw body') @@ -59,7 +133,6 @@ app.post('/webhook/:token', async (req, res) => { .toLowerCase() const timestamp = new Date().toISOString().replace(/:/g, '-') - const filename = `${timestamp}-${safeTitle}.md` const markdown = `# ${title} @@ -99,4 +172,4 @@ ${transcript} app.listen(PORT, () => { console.log(`Webhook server listening on ${PORT}`) -}) \ No newline at end of file +})