Add self-service web UI for user registration and settings management
Users visit the home page, enter their Nextcloud credentials and an admin password, and receive a unique webhook URL for AudioPen. A bookmarkable settings page (/?token=...) lets them update credentials later without needing the admin password. New users.json entries are written at runtime via POST/PUT API endpoints. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
68be646f37
commit
611a4a6da3
3 files changed
+518
-42
No files matched your search
@@ -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/<token>
|
||||
```
|
||||
- A **webhook URL** to paste into AudioPen (`/webhook/<token>`)
|
||||
- A **settings page URL** to bookmark for updating credentials later (`/?token=<token>`)
|
||||
|
||||
Each user gets their own URL containing their unique token.
|
||||
### Returning users
|
||||
|
||||
Visiting `/?token=<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.
|
||||
@@ -0,0 +1,416 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AudioPen → Nextcloud Setup</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.08);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #0082c9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.webhook-banner {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.webhook-banner label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #0369a1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.copy-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copy-row input {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
padding: 8px 12px;
|
||||
background: #0369a1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-copy:hover { background: #0284c7; }
|
||||
.btn-copy.copied { background: #16a34a; }
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="password"] {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #0082c9;
|
||||
box-shadow: 0 0 0 3px rgba(0,130,201,0.12);
|
||||
}
|
||||
|
||||
.password-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-wrap input {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.toggle-pw {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toggle-pw:hover { color: #374151; }
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #f3f4f6;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.admin-section label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 10px;
|
||||
background: #0082c9;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-submit:hover { background: #006ba6; }
|
||||
.btn-submit:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
|
||||
.alert {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.manage-link {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.manage-link a {
|
||||
color: #0082c9;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.manage-link a:hover { text-decoration: underline; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">▶</div>
|
||||
<div>
|
||||
<h1>AudioPen → Nextcloud</h1>
|
||||
<div class="subtitle" id="subtitle">Connect your AudioPen notes to Nextcloud</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook URL banner (shown after registration or for returning users) -->
|
||||
<div class="webhook-banner hidden" id="webhookBanner">
|
||||
<label>Your AudioPen Webhook URL</label>
|
||||
<div class="copy-row">
|
||||
<input type="text" id="webhookUrl" readonly />
|
||||
<button class="btn-copy" id="copyBtn" onclick="copyWebhook()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alertBox" class="alert hidden"></div>
|
||||
|
||||
<form id="setupForm" onsubmit="handleSubmit(event)">
|
||||
<div class="field">
|
||||
<label for="nextcloudUrl">Nextcloud URL</label>
|
||||
<input type="url" id="nextcloudUrl" placeholder="https://nextcloud.example.com" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" placeholder="youruser" required autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">App Password</label>
|
||||
<div class="password-wrap">
|
||||
<input type="password" id="password" placeholder="Generate one in Nextcloud → Settings → Security" required autocomplete="current-password" />
|
||||
<button type="button" class="toggle-pw" onclick="togglePassword('password', this)">👁</button>
|
||||
</div>
|
||||
<span class="hint">Use an app password, not your login password.</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="folder">Nextcloud Folder</label>
|
||||
<input type="text" id="folder" placeholder="AudioPen" />
|
||||
<span class="hint">Folder must already exist in your Nextcloud.</span>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Admin password field — hidden for returning users updating settings -->
|
||||
<div class="field admin-section" id="adminSection">
|
||||
<label for="adminPassword">Admin Password</label>
|
||||
<div class="password-wrap">
|
||||
<input type="password" id="adminPassword" placeholder="Required to create a new account" autocomplete="off" />
|
||||
<button type="button" class="toggle-pw" onclick="togglePassword('adminPassword', this)">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit" id="submitBtn">Set up integration</button>
|
||||
</form>
|
||||
|
||||
<p class="manage-link hidden" id="manageLink">
|
||||
Bookmark this page to return and update your settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search)
|
||||
const token = params.get('token')
|
||||
let webhookUrlValue = ''
|
||||
|
||||
if (token) {
|
||||
loadExistingUser(token)
|
||||
}
|
||||
|
||||
async function loadExistingUser(token) {
|
||||
try {
|
||||
const res = await fetch(`/api/users/${token}`)
|
||||
if (!res.ok) {
|
||||
showAlert('error', 'This link is invalid or the account no longer exists.')
|
||||
return
|
||||
}
|
||||
const user = await res.json()
|
||||
|
||||
document.getElementById('nextcloudUrl').value = user.nextcloudUrl
|
||||
document.getElementById('username').value = user.username
|
||||
document.getElementById('password').value = user.password
|
||||
document.getElementById('folder').value = user.folder
|
||||
|
||||
document.getElementById('adminSection').classList.add('hidden')
|
||||
document.getElementById('submitBtn').textContent = 'Update settings'
|
||||
document.getElementById('subtitle').textContent = 'Update your integration settings'
|
||||
|
||||
webhookUrlValue = `${location.protocol}//${location.host}/webhook/${token}`
|
||||
showWebhookBanner(webhookUrlValue)
|
||||
document.getElementById('manageLink').classList.add('hidden')
|
||||
} catch (err) {
|
||||
showAlert('error', 'Failed to load settings. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
const btn = document.getElementById('submitBtn')
|
||||
btn.disabled = true
|
||||
btn.textContent = token ? 'Saving…' : 'Creating…'
|
||||
clearAlert()
|
||||
|
||||
const body = {
|
||||
nextcloudUrl: document.getElementById('nextcloudUrl').value.trim(),
|
||||
username: document.getElementById('username').value.trim(),
|
||||
password: document.getElementById('password').value,
|
||||
folder: document.getElementById('folder').value.trim() || 'AudioPen',
|
||||
}
|
||||
|
||||
try {
|
||||
let res, data
|
||||
|
||||
if (token) {
|
||||
res = await fetch(`/api/users/${token}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
showAlert('success', 'Settings updated successfully.')
|
||||
} else {
|
||||
body.adminPassword = document.getElementById('adminPassword').value
|
||||
res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error)
|
||||
|
||||
webhookUrlValue = data.webhookUrl
|
||||
showWebhookBanner(data.webhookUrl)
|
||||
|
||||
const manageUrl = `${location.protocol}//${location.host}/?token=${data.token}`
|
||||
document.getElementById('manageLink').innerHTML =
|
||||
`Bookmark <a href="${manageUrl}">your settings page</a> to update credentials later.`
|
||||
document.getElementById('manageLink').classList.remove('hidden')
|
||||
|
||||
showAlert('success', 'Integration created! Copy your webhook URL and paste it into AudioPen.')
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('error', err.message)
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = token ? 'Update settings' : 'Set up integration'
|
||||
}
|
||||
}
|
||||
|
||||
function showWebhookBanner(url) {
|
||||
document.getElementById('webhookUrl').value = url
|
||||
document.getElementById('webhookBanner').classList.remove('hidden')
|
||||
}
|
||||
|
||||
function copyWebhook() {
|
||||
navigator.clipboard.writeText(webhookUrlValue)
|
||||
const btn = document.getElementById('copyBtn')
|
||||
btn.textContent = 'Copied!'
|
||||
btn.classList.add('copied')
|
||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied') }, 2000)
|
||||
}
|
||||
|
||||
function togglePassword(id, btn) {
|
||||
const input = document.getElementById(id)
|
||||
const isHidden = input.type === 'password'
|
||||
input.type = isHidden ? 'text' : 'password'
|
||||
btn.textContent = isHidden ? '🙈' : '👁'
|
||||
}
|
||||
|
||||
function showAlert(type, msg) {
|
||||
const box = document.getElementById('alertBox')
|
||||
box.className = `alert alert-${type}`
|
||||
box.textContent = msg
|
||||
box.classList.remove('hidden')
|
||||
}
|
||||
|
||||
function clearAlert() {
|
||||
document.getElementById('alertBox').classList.add('hidden')
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user