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
+516
-40
No files matched your search
@@ -1,13 +1,13 @@
|
|||||||
# AudioPen Nextcloud Webhook
|
# 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
|
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
|
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:
|
Example filename:
|
||||||
|
|
||||||
@@ -17,49 +17,36 @@ Example filename:
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### 1. Configure users
|
### 1. Configure environment
|
||||||
|
|
||||||
Copy `users.json.example` to `users.json` and fill in credentials for each user:
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
```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`:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
ADMIN_PASSWORD=choose-a-strong-password
|
||||||
PORT=3000
|
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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
node server.js
|
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:
|
||||||
|
|
||||||
```
|
- A **webhook URL** to paste into AudioPen (`/webhook/<token>`)
|
||||||
https://your-server/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 'dotenv/config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import axios from 'axios'
|
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()
|
const app = express()
|
||||||
|
|
||||||
app.use(express.urlencoded({ extended: false }))
|
app.use(express.urlencoded({ extended: false }))
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use(express.text({ type: '*/*' }))
|
app.use(express.text({ type: '*/*' }))
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')))
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
|
|
||||||
const USERS_FILE = process.env.USERS_FILE || 'users.json'
|
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 = {}
|
let users = {}
|
||||||
try {
|
try {
|
||||||
users = JSON.parse(readFileSync(USERS_FILE, 'utf8'))
|
users = JSON.parse(readFileSync(USERS_FILE, 'utf8'))
|
||||||
console.log(`Loaded ${Object.keys(users).length} user(s) from ${USERS_FILE}`)
|
console.log(`Loaded ${Object.keys(users).length} user(s) from ${USERS_FILE}`)
|
||||||
} catch (err) {
|
} 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}`)
|
console.error(`Failed to load ${USERS_FILE}: ${err.message}`)
|
||||||
process.exit(1)
|
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) => {
|
app.post('/webhook/:token', async (req, res) => {
|
||||||
const user = users[req.params.token]
|
const user = users[req.params.token]
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -38,9 +114,7 @@ app.post('/webhook/:token', async (req, res) => {
|
|||||||
// If body comes in as raw string (VERY likely here)
|
// If body comes in as raw string (VERY likely here)
|
||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
try {
|
try {
|
||||||
// try parsing as URL encoded manually
|
|
||||||
const params = new URLSearchParams(data)
|
const params = new URLSearchParams(data)
|
||||||
|
|
||||||
data = Object.fromEntries(params.entries())
|
data = Object.fromEntries(params.entries())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Failed to parse raw body')
|
console.log('Failed to parse raw body')
|
||||||
@@ -59,7 +133,6 @@ app.post('/webhook/:token', async (req, res) => {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/:/g, '-')
|
const timestamp = new Date().toISOString().replace(/:/g, '-')
|
||||||
|
|
||||||
const filename = `${timestamp}-${safeTitle}.md`
|
const filename = `${timestamp}-${safeTitle}.md`
|
||||||
|
|
||||||
const markdown = `# ${title}
|
const markdown = `# ${title}
|
||||||
|
|||||||
Reference in new issue
Block a user