docs: add project documentation and update README
- Add `agent.md`: Complete maintenance reference covering project structure, API interactions, data flow, configuration, security, and operational procedures. - Add `architecture.md`: High-level architecture overview including component map, data flow, design decisions, and security model. - Update `README.md`: Add Apprise support documentation, manual deployment instructions, updated installation steps (bot admin requirement), and expanded payload mapping and troubleshooting sections. - Improve `lib/Controller/WebhookController.php`: Enhance `saveBotPassword` validation to check for missing fields and validate the password content. - Improve `lib/Service/TalkService.php`: Add `validateBotPassword` method for round-trip encryption testing and integrate it into `saveConfig`. - Update `css/adminSettings.css`: Add styles for the new `.nc-app-frame` wrapper. - Update `templates/adminSettings.php`: Refactor UI structure to use `.nc-app-frame` and improve layout of settings sections.
This commit is contained in:
@@ -1,8 +1,26 @@
|
||||
# NCbotwebhooks
|
||||
|
||||
Discord webhook bridge for Nextcloud Talk with image attachment support.
|
||||
Webhook bridge that forwards Discord webhook-style and Apprise payloads into Nextcloud Talk rooms, with image attachment support and per-room authentication tokens.
|
||||
|
||||
Accepts Discord webhook-style JSON payloads and posts them into Nextcloud Talk rooms, preserving images and embed formatting.
|
||||
Accepts Discord webhook-compatible JSON payloads (embeds, fields, images) and Apprise notification payloads, maps them to Nextcloud Talk Chat API v1 format, and posts them as the `talk-bot` user.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Manual Deployment](#manual-deployment)
|
||||
- [Configuration](#configuration)
|
||||
- [Webhook URLs](#webhook-urls)
|
||||
- [Payload Formats](#payload-formats)
|
||||
- [Discord Webhook Format](#discord-webhook-format)
|
||||
- [Apprise Format](#apprise-format)
|
||||
- [Payload Mapping](#payload-mapping)
|
||||
- [Image Management](#image-management)
|
||||
- [Security](#security)
|
||||
- [Debug Endpoint](#debug-endpoint)
|
||||
- [Logging](#logging)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -14,39 +32,105 @@ cp -r /path/to/nc_bot_webhooks .
|
||||
php occ app:enable nc_bot_webhooks
|
||||
```
|
||||
|
||||
Or upload via the Nextcloud web UI as a ZIP (only the `nc_bot_webhooks` directory, not its parent).
|
||||
|
||||
### 2. Create the bot user
|
||||
|
||||
```bash
|
||||
php occ user:add --password-from-env --display-name="Webhook Bot" talk-bot
|
||||
```
|
||||
|
||||
### 3. Generate an app password for the bot
|
||||
### 3. Make the bot user an admin
|
||||
|
||||
1. Log in as `talk-bot` (or set a password as admin)
|
||||
2. Go to **Settings → Security → Devices & sessions → Add device**
|
||||
3. Copy the app password
|
||||
The bot must have admin privileges to list all Talk rooms. Grant admin access:
|
||||
|
||||
### 4. Configure the app
|
||||
1. Go to **Settings → Users**
|
||||
2. Find **talk-bot** and click it
|
||||
3. Check **Admin** under "Settings"
|
||||
4. Save
|
||||
|
||||
### 4. Generate an app password for the bot
|
||||
|
||||
1. Log in to Nextcloud as **admin** (or set a password as admin, then log in as talk-bot)
|
||||
2. Go to **Settings → talk-bot → Devices & sessions**
|
||||
3. Click **Add device** and give it a name (e.g., "NCbotwebhooks")
|
||||
4. Copy the generated device password — you'll need it in the next step
|
||||
|
||||
### 5. Configure the app
|
||||
|
||||
Go to **Settings → Admin → NCbotwebhooks**:
|
||||
|
||||
1. **Bot Configuration** — paste the app password from step 3
|
||||
2. **Image Retention** — set how long to keep uploaded images (default: 90 days)
|
||||
3. **Room Management** — click "Fetch Rooms" to see available Talk rooms, select the ones you want, and generate auth tokens
|
||||
1. **Bot App Password** — paste the app password from step 4
|
||||
2. **Default Sender Name** — set the name that appears as the message sender (default: "Webhook Bot")
|
||||
3. **Image Retention** — set how many days to keep uploaded images (default: 90)
|
||||
4. **Room Selection** — click **Fetch Rooms** to list available Talk rooms
|
||||
5. Check the rooms you want to accept webhooks for
|
||||
6. For each room, click **+ Generate Auth Token** to create a webhook URL
|
||||
7. Click **Save Configuration**
|
||||
|
||||
### 5. Point your services at the webhook URLs
|
||||
---
|
||||
|
||||
Each configured room gets **two** webhook URLs (Discord and Apprise formats):
|
||||
## Manual Deployment
|
||||
|
||||
```
|
||||
https://your-server.com/apps/nc_bot_webhooks/discord-webhook/<room-token>/<auth-token>
|
||||
> **Note:** The paths below are specific to a TrueNAS Scale Nextcloud Docker install. Adjust them to match your deployment.
|
||||
|
||||
This section covers updating the app on a running Nextcloud instance using the synced-file workflow.
|
||||
|
||||
**Workflow:** Keep your local development copy synced to your server via the Nextcloud desktop client. Edit files locally, then deploy with the script below.
|
||||
|
||||
### Update script
|
||||
|
||||
```bash
|
||||
cd /var/www/html
|
||||
php occ app:disable nc_bot_webhooks
|
||||
|
||||
rm -rf /var/www/html/custom_apps/nc_bot_webhooks/*
|
||||
mkdir -p /var/www/html/custom_apps/nc_bot_webhooks
|
||||
|
||||
# Update from your synced Nextcloud files directory — adjust this path to match your setup.
|
||||
cp -r "/var/www/html/data/<username>/files/TrueNAS configs/Nextcloud Hooker/nc_bot_webhooks/"* /var/www/html/custom_apps/nc_bot_webhooks/
|
||||
|
||||
chown -R www-data:www-data /var/www/html/custom_apps/nc_bot_webhooks
|
||||
chmod -R u+rX,go+rX /var/www/html/custom_apps/nc_bot_webhooks
|
||||
|
||||
cd /var/www/html
|
||||
php occ app:enable nc_bot_webhooks
|
||||
php occ config:app:delete nc_bot_webhooks routes 2>/dev/null || true
|
||||
php occ maintenance:repair
|
||||
clear
|
||||
```
|
||||
|
||||
Copy the auth token from the app settings for each room.
|
||||
> **Note:** The path `"/var/www/html/data/<username>/files/TrueNAS configs/Nextcloud Hooker/nc_bot_webhooks/"` is where your Nextcloud user's synced files land on the server (TrueNAS Docker path). Replace `<username>` with your Nextcloud username, and adjust `TrueNAS configs/Nextcloud Hooker/nc_bot_webhooks/` to match the directory you are syncing to.
|
||||
|
||||
## API
|
||||
---
|
||||
|
||||
### Accepts (Discord webhook format)
|
||||
## Webhook URLs
|
||||
|
||||
Each configured room gets two webhook URLs:
|
||||
|
||||
### Discord format
|
||||
|
||||
```
|
||||
https://your-nextcloud-server/apps/nc_bot_webhooks/discord-webhook/<room-token>/<auth-token>
|
||||
```
|
||||
|
||||
### Apprise format
|
||||
|
||||
```
|
||||
https://your-nextcloud-server/apps/nc_bot_webhooks/apprise-webhook/<room-token>/notify/<auth-token>
|
||||
```
|
||||
|
||||
> **Note:** The `notify` segment is required — Apprise's `apprises://` URL scheme inserts it in the path automatically.
|
||||
|
||||
### Multiple auth tokens per room
|
||||
|
||||
Each room can have multiple auth tokens — useful for rotating keys or sharing the webhook across multiple services. Generate additional tokens in the admin settings UI.
|
||||
|
||||
---
|
||||
|
||||
## Payload Formats
|
||||
|
||||
### Discord Webhook Format
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -57,6 +141,7 @@ Copy the auth token from the app settings for each room.
|
||||
"description": "Successfully deployed to production",
|
||||
"color": 3066993,
|
||||
"image": { "url": "https://example.com/screenshot.png" },
|
||||
"thumbnail": { "url": "https://example.com/thumb.png" },
|
||||
"fields": [
|
||||
{ "name": "Duration", "value": "2m 34s" },
|
||||
{ "name": "Environment", "value": "Production" }
|
||||
@@ -68,85 +153,95 @@ Copy the auth token from the app settings for each room.
|
||||
}
|
||||
```
|
||||
|
||||
### Sends to Nextcloud Talk
|
||||
### Apprise Format
|
||||
|
||||
- Formatted text message (content + embeds + fields)
|
||||
- Each image from `embeds[].image` or `embeds[].thumbnail` uploaded and shared inline
|
||||
- `username` shown as the sender display name
|
||||
Apprise sends a JSON wrapper containing a `notifications` array:
|
||||
|
||||
### Payload mapping
|
||||
```json
|
||||
{
|
||||
"version": 0,
|
||||
"subject": "Build #1234",
|
||||
"title": "Deployment",
|
||||
"type": "info",
|
||||
"notifications": [
|
||||
{
|
||||
"subject": "Build #1234",
|
||||
"title": "Deployment",
|
||||
"body": "Successfully deployed to production",
|
||||
"type": "info",
|
||||
"attachments": [
|
||||
{ "path": "https://example.com/screenshot.png" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Discord field | Nextcloud action |
|
||||
Apprise also supports form-encoded payloads. The app auto-detects the content type (JSON, `multipart/form-data`, or form-encoded).
|
||||
|
||||
---
|
||||
|
||||
## Payload Mapping
|
||||
|
||||
| Field | Nextcloud Talk action |
|
||||
|---|---|
|
||||
| `content` | Sent as text message |
|
||||
| `embeds[].title` | Included as bold line |
|
||||
| `embeds[].description` | Included in message body |
|
||||
| `embeds[].image.url` | Downloaded, uploaded to NC, shared inline |
|
||||
| `embeds[].thumbnail.url` | Downloaded, uploaded to NC, shared inline |
|
||||
| `embeds[].fields` | Formatted as `name: value` lines |
|
||||
| `username` | Sender display name |
|
||||
| `embeds[].fields[].name` + `value` | Formatted as `name: value` lines |
|
||||
| `username` | Sender display name (prepended to message) |
|
||||
| `avatar_url` | Ignored (NCTalk doesn't support per-message avatars) |
|
||||
| `subject` / `title` (Apprise) | Used as bold title line |
|
||||
| `body` (Apprise) | Sent as message body |
|
||||
| `attachments[].path` (Apprise) | Downloaded, uploaded to NC, shared inline |
|
||||
|
||||
## Room routing
|
||||
### Sender name resolution
|
||||
|
||||
Each room gets its own webhook URL with a unique auth token:
|
||||
When posting to Talk, the app prepends a bold sender name line to the message (since Talk doesn't support per-message avatars). The sender name is resolved in this order:
|
||||
|
||||
```
|
||||
/discord-webhook/<room-token>/<auth-token>
|
||||
```
|
||||
1. `username` field from Discord payload
|
||||
2. `subject` / `title` from Apprise payload
|
||||
3. Configured default sender name (Settings → Default Sender Name)
|
||||
|
||||
- **Room token** — identifies which Talk room to post to (from `occ talk:room:list`)
|
||||
- **Auth token** — secret key for this webhook endpoint (generated in app settings)
|
||||
---
|
||||
|
||||
Multiple auth tokens can be created per room — useful if you need to rotate keys or share the webhook across multiple services.
|
||||
## Image Management
|
||||
|
||||
### Apprise webhook
|
||||
- Images from webhooks are downloaded via HTTP and uploaded to the bot user's files
|
||||
- Stored at `/nc_bot_webhooks-images/<room-token>/` under the bot user's personal storage
|
||||
- A public link share is created for each image so Talk can resolve the rich object and display the inline attachment
|
||||
- **Cron job** (`ImageCleanup`) purges images older than the configured retention period (default: 90 days)
|
||||
- Images count toward the bot user's storage quota
|
||||
- Purge operates on the bot user's personal storage directory only — it does not scan other users' files
|
||||
|
||||
For Apprise integrations, each room also gets an Apprise webhook URL:
|
||||
|
||||
```
|
||||
https://your-server.com/apps/nc_bot_webhooks/apprise-webhook/<room-token>/notify/<auth-token>
|
||||
```
|
||||
|
||||
Note: the `notify` segment is required — Apprise's `apprises://` URL scheme inserts it in the path.
|
||||
|
||||
Apprise sends a different JSON format. Supported fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Build #1234",
|
||||
"body": "Successfully deployed to production",
|
||||
"type": "info",
|
||||
"attachments": [
|
||||
{ "url": "https://example.com/screenshot.png" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The apprise webhook maps `title`, `body`, and `attachments` to the same Talk message format as the Discord endpoint, so images and formatting work the same way.
|
||||
|
||||
## Image management
|
||||
|
||||
- Images are uploaded to the bot user's files at `/nc_bot_webhooks-images/<room-token>/`
|
||||
- Cron job purges images older than the configured retention period (default: 90 days)
|
||||
- Images are stored in the bot user's storage — they count toward the bot user's quota
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- Auth token in the URL is the primary auth mechanism — keep it secret
|
||||
- Bot password is encrypted at rest using Nextcloud's crypto
|
||||
- Image download uses Nextcloud's HTTP client with local address blocking
|
||||
- Rate limiting should be handled at the web server or reverse proxy level
|
||||
- **Debug endpoint disabled by default** — see below
|
||||
| Mechanism | Description |
|
||||
|---|---|
|
||||
| **Auth tokens** | Each webhook URL contains a unique secret token; the endpoint validates it before processing |
|
||||
| **Bot password** | Encrypted at rest using Nextcloud's crypto layer (`ICrypto`) |
|
||||
| **No CSRF** | Webhook endpoints are marked `#[PublicPage]` + `#[NoCSRFRequired]` — auth token is the sole access control |
|
||||
| **Local address blocking** | Image downloads use Nextcloud's HTTP client with `allow_local_address: true` (blocks private ranges by default) |
|
||||
| **Path traversal protection** | Uploaded filenames use `basename()` to prevent directory traversal |
|
||||
| **Rate limiting** | Not built in — handle at the web server or reverse proxy level |
|
||||
|
||||
### Debug endpoint
|
||||
### Known limitations
|
||||
|
||||
The `/apps/nc_bot_webhooks/debug` endpoint exposes internal configuration,
|
||||
database schema, and bot credentials. It is **disabled by default** and must
|
||||
be explicitly enabled via the CLI:
|
||||
- Auth tokens generated from the settings UI use client-side generation; for higher security, regenerate them via the server API
|
||||
- The webhook endpoint has no rate limiting — consider placing it behind a reverse proxy rate limiter if exposing to untrusted sources
|
||||
|
||||
---
|
||||
|
||||
## Debug Endpoint
|
||||
|
||||
The `/apps/nc_bot_webhooks/debug` endpoint exposes internal configuration, database schema, and bot credentials. It is **disabled by default**.
|
||||
|
||||
```bash
|
||||
# Check current status
|
||||
# Check status
|
||||
php occ nc_bot_webhooks:debug:status
|
||||
|
||||
# Enable (WARNING: exposes sensitive data)
|
||||
@@ -159,13 +254,14 @@ php occ nc_bot_webhooks:debug:disable
|
||||
php occ nc_bot_webhooks:debug:toggle
|
||||
```
|
||||
|
||||
Never leave the debug endpoint enabled in production. After troubleshooting,
|
||||
disable it immediately:
|
||||
After troubleshooting, disable it immediately:
|
||||
|
||||
```bash
|
||||
php occ nc_bot_webhooks:debug:disable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
Responses include a `X-Webhook-Status` header:
|
||||
@@ -177,3 +273,16 @@ Responses include a `X-Webhook-Status` header:
|
||||
| `bad_request` | Invalid JSON payload |
|
||||
| `no_content` | No message content in payload |
|
||||
| `error` | Check server logs for details |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---|---|
|
||||
| **"talk-bot user not found"** | The `talk-bot` user doesn't exist. Re-run Step 2 of installation. |
|
||||
| **"Bot password not configured"** | You haven't entered the bot password in admin settings, or it was cleared. Re-enter it. |
|
||||
| **Messages not appearing** | Check the Nextcloud log (`data/nextcloud.log` or Settings → Admin → Logging) for errors from the `nc_bot_webhooks` app. |
|
||||
| **Image upload fails** | Verify the bot user has file storage quota and the `nc_bot_webhooks-images` folder can be created. |
|
||||
| **Apprise 500 error** | Likely config data not migrated from the old app ID. Re-enter settings or run: `UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook';` |
|
||||
| **Bot not listed in room participants** | The `ensureBotParticipants()` method runs automatically on save, but you may need to re-check the room in admin settings to trigger it. |
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
# Architecture
|
||||
|
||||
Overview of how NCbotwebhooks functions, its components, data flow, design decisions, and security model.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [High-Level Overview](#high-level-overview)
|
||||
- [Component Map](#component-map)
|
||||
- [Data Flow](#data-flow)
|
||||
- [Design Decisions](#design-decisions)
|
||||
- [Security Model](#security-model)
|
||||
- [Storage Model](#storage-model)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Extensibility](#extensibility)
|
||||
|
||||
---
|
||||
|
||||
## High-Level Overview
|
||||
|
||||
NCbotwebhooks is a Nextcloud app that acts as a webhook bridge. External services (CI/CD pipelines, monitoring tools, Apprise notification gateways) send webhook payloads to the app, which validates the request, maps the payload to Nextcloud Talk's Chat API format, and posts the message as the `talk-bot` user into the target Talk room.
|
||||
|
||||
```
|
||||
External Service ──► /discord-webhook/{room}/{token} ──┐
|
||||
/apprise-webhook/{room}/{token} ──► WebhookController
|
||||
──► TalkService
|
||||
──► Nextcloud Talk (Chat API v1)
|
||||
```
|
||||
|
||||
The app supports **two webhook formats**:
|
||||
1. **Discord webhook-compatible** — embeds, fields, images, username/avatar
|
||||
2. **Apprise notification** — subject/title/body/attachments wrapper format
|
||||
|
||||
Each Talk room gets its own webhook URL with a unique auth token. Multiple tokens per room are supported for key rotation or multi-service sharing.
|
||||
|
||||
---
|
||||
|
||||
## Component Map
|
||||
|
||||
### Controllers
|
||||
|
||||
| Class | Responsibility |
|
||||
|---|---|
|
||||
| `WebhookController` | HTTP request handlers — receives webhooks, validates auth tokens, parses payloads, calls `TalkService` |
|
||||
| `AdminSettings` | Admin settings form handler — injects config data into the template |
|
||||
|
||||
### Service
|
||||
|
||||
| Class | Responsibility |
|
||||
|---|---|
|
||||
| `TalkService` | Core business logic — bot password management, room listing, auth token management, payload mapping, image download/upload, Talk Chat API posting, config persistence, image cleanup |
|
||||
|
||||
### Background Jobs
|
||||
|
||||
| Class | Responsibility |
|
||||
|---|---|
|
||||
| `ImageCleanup` | Cron job that runs `TalkService::purgeOldImages()` to remove images older than the retention period |
|
||||
|
||||
### CLI Commands
|
||||
|
||||
| Class | Responsibility |
|
||||
|---|---|
|
||||
| `DebugToggle` | CLI command to enable/disable the debug endpoint (`nc_bot_webhooks:debug:toggle`) |
|
||||
|
||||
### Settings / Navigation
|
||||
|
||||
| Class | Responsibility |
|
||||
|---|---|
|
||||
| `Admin` | ISettings implementation — loads template, injects config, declares CSS/JS assets |
|
||||
| `NavigationProvider` | INavigationProvider — adds "NCbotwebhooks" entry to admin settings navigation |
|
||||
|
||||
### Frontend
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `js/settings.js` | Admin settings UI — fetch rooms, render checkboxes, manage auth tokens, save config |
|
||||
| `templates/adminSettings.php` | Settings form HTML — frame layout with sections for password, sender name, retention, room selection |
|
||||
| `css/adminSettings.css` | Styling — frame borders, token rows, status messages |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Webhook Ingestion (Discord)
|
||||
|
||||
```
|
||||
POST /discord-webhook/{roomToken}/{token}
|
||||
│
|
||||
├─► WebhookController::receive()
|
||||
│ │
|
||||
│ ├─ 1. Validate auth token (TalkService::validateAuthToken)
|
||||
│ ├─ 2. Parse JSON from php://input
|
||||
│ ├─ 3. Map to Talk format (TalkService::mapPayload)
|
||||
│ ├─ 4. Download images from embeds (TalkService::downloadImage)
|
||||
│ ├─ 5. Upload images to bot user storage (TalkService::uploadImage)
|
||||
│ ├─ 6. Build rich objects (TalkService::buildRichObject)
|
||||
│ ├─ 7. Prepend sender name (TalkService::prependDisplayName)
|
||||
│ └─ 8. Post to Talk room (TalkService::postToRoom)
|
||||
│
|
||||
└─► DataResponse with X-Webhook-Status header
|
||||
```
|
||||
|
||||
### Webhook Ingestion (Apprise)
|
||||
|
||||
```
|
||||
POST /apprise-webhook/{roomToken}/{token}
|
||||
│
|
||||
├─► WebhookController::receiveApprise()
|
||||
│ │
|
||||
│ ├─ 1. Validate auth token
|
||||
│ ├─ 2. Detect content type (JSON / multipart/form-data / form-encoded)
|
||||
│ ├─ 3. Parse payload (try JSON → $_POST → parse_str)
|
||||
│ ├─ 4. Unwrap notifications array if present
|
||||
│ ├─ 5. Propagate wrapper-level subject/title to notification entry
|
||||
│ ├─ 6. Map to Talk format (TalkService::mapApprisePayload)
|
||||
│ ├─ 7. Download attachments (TalkService::downloadImage)
|
||||
│ ├─ 8. Upload images (TalkService::uploadImage)
|
||||
│ ├─ 9. Prepend sender name (TalkService::prependDisplayName)
|
||||
│ └─ 10. Post to Talk room (TalkService::postToRoom)
|
||||
│
|
||||
└─► DataResponse with X-Webhook-Status header
|
||||
```
|
||||
|
||||
### Configuration Save
|
||||
|
||||
```
|
||||
POST /save-config (admin only)
|
||||
│
|
||||
├─► WebhookController::saveConfig()
|
||||
│ │
|
||||
│ ├─ 1. Validate bot password (TalkService::validateBotPassword)
|
||||
│ ├─ 2. Save bot password (TalkService::setBotPassword)
|
||||
│ ├─ 3. Save retention days (TalkService::setRetentionDays)
|
||||
│ ├─ 4. Save configured rooms (TalkService::setRooms)
|
||||
│ ├─ 5. Save auth tokens (TalkService::setAuthTokens)
|
||||
│ ├─ 6. Save sender name (TalkService::setSenderName)
|
||||
│ └─ 7. Ensure bot is participant in all rooms (TalkService::ensureBotParticipants)
|
||||
│
|
||||
└─► JSON response with status + auth_tokens
|
||||
```
|
||||
|
||||
### Bot Password Save (standalone)
|
||||
|
||||
```
|
||||
POST /save-bot-password (admin only)
|
||||
│
|
||||
├─► WebhookController::saveBotPassword()
|
||||
│ │
|
||||
│ ├─ 1. Validate bot password (TalkService::validateBotPassword)
|
||||
│ └─ 2. Save bot password (TalkService::setBotPassword)
|
||||
│
|
||||
└─► JSON response with status
|
||||
```
|
||||
|
||||
### Room Listing
|
||||
|
||||
```
|
||||
GET /rooms (admin only)
|
||||
│
|
||||
├─► WebhookController::getRooms()
|
||||
│ │
|
||||
│ ├─ 1. Query Talk DB directly (bypasses OCS API which rejects app passwords)
|
||||
│ ├─ 2. Detect correct Talk table name (TalkService::detectTalkTableFromCatalog)
|
||||
│ ├─ 3. Filter: public channels (type 1), group (type 2), public (type 3)
|
||||
│ ├─ 4. Exclude: deleted rooms, note-to-self, sample rooms, file shares
|
||||
│ └─ 5. Merge with configured rooms for checkbox state
|
||||
│
|
||||
└─► JSON array of [token, name, configured] objects
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Direct DB queries instead of TalkManager
|
||||
|
||||
The app queries the Talk database directly (`detectTalkTableFromCatalog`) rather than using `TalkManager::getRoomForToken` because:
|
||||
|
||||
1. **Talk 14+ removed `getRoomForToken`** — the method was deprecated and removed in newer Talk versions
|
||||
2. **App passwords don't work with Talk's OCS API** — the OCS endpoints require user sessions, not app passwords
|
||||
3. **Table name prefix varies** — different Nextcloud installations use different DB table prefixes; the catalog-based detection handles this dynamically
|
||||
|
||||
### Base URL resolution strategy
|
||||
|
||||
`TalkService::getBaseUrl()` resolves the server URL in this priority order:
|
||||
|
||||
1. `overwritehost` (hostname override) — highest priority, explicit override
|
||||
2. `overwritewebroot` — if it's a full URL; otherwise treated as a path and falls through
|
||||
3. **Non-loopback trusted domain** — iterates `trusted_domains`, skips `127.0.0.1` and private ranges (Docker compatibility)
|
||||
4. `overwrite.cli.url` — last resort
|
||||
|
||||
This prioritization handles Docker/container deployments where `trusted_domains[0]` is often `127.0.0.1` (unreachable from within the container) and `overwrite.cli.url` points to a localhost:port.
|
||||
|
||||
### Bot display name resolution
|
||||
|
||||
In Talk 14+, the `actorDisplayName` field in the Chat API **must match** the bot's display name as registered in the room's participant record, or the message is silently dropped. The app resolves this by:
|
||||
|
||||
1. Querying the Talk rooms table for the room ID from the room token
|
||||
2. Using `AttendeeMapper::findByActor()` to find the bot's attendee record
|
||||
3. Using the attendee's display name as `actorDisplayName`
|
||||
4. Falling back to `'talk-bot'` if the attendee record doesn't exist
|
||||
|
||||
### Sender name embedding
|
||||
|
||||
Since NCTalk doesn't support per-message avatars, the app prepends a bold emoji-prefixed sender name line to the message text:
|
||||
|
||||
```
|
||||
🤖 **CI Bot**
|
||||
|
||||
Build #1234 passed
|
||||
```
|
||||
|
||||
This preserves the visual identity of the sending service within Talk's message rendering.
|
||||
|
||||
### Auth token storage
|
||||
|
||||
Auth tokens are stored as a JSON object in the Nextcloud `appconfig` table:
|
||||
|
||||
```json
|
||||
{
|
||||
"roomToken1": ["tokenA", "tokenB"],
|
||||
"roomToken2": ["tokenC"]
|
||||
}
|
||||
```
|
||||
|
||||
Each room can have multiple tokens (array), supporting key rotation and multi-service sharing without reconfiguring all consumers.
|
||||
|
||||
### Client-side token generation
|
||||
|
||||
Auth tokens in the settings UI are generated client-side using `btoa(Math.random() + Date.now())`. This is noted as a security limitation in the docs — server-side generation (e.g., `random_bytes(32)`) would be more secure. The trade-off is acceptable for the current threat model: webhook URLs are not publicly discoverable, and the auth token is the sole access control.
|
||||
|
||||
### `ensureBotParticipants()`
|
||||
|
||||
When rooms are configured, the app automatically adds the `talk-bot` user as a participant in each room via direct `AttendeeMapper` insertion. This is necessary because:
|
||||
|
||||
1. The Chat API requires the `actorId` to be a participant in the room
|
||||
2. The bot user is created separately and isn't automatically in any rooms
|
||||
3. Running this on every save ensures consistency when rooms are added/removed
|
||||
|
||||
### `validateBotPassword()` — round-trip encryption test
|
||||
|
||||
Bot password validation uses a round-trip encrypt→decrypt test rather than attempting an API call. This checks:
|
||||
|
||||
1. The password is non-empty
|
||||
2. The password doesn't contain characters that break the crypto layer
|
||||
3. The crypto layer is functional
|
||||
|
||||
This is faster and more reliable than trying to authenticate with the Talk API, which would fail for many other reasons (network, room state, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
### Authentication
|
||||
|
||||
| Layer | Mechanism |
|
||||
|---|---|
|
||||
| Webhook access | Auth token in URL path (e.g., `/discord-webhook/{room}/{token}`) |
|
||||
| Admin settings | Nextcloud session auth + admin check |
|
||||
| Bot → Talk | Basic auth with `talk-bot` app password |
|
||||
|
||||
### Authorization
|
||||
|
||||
- Admin-only endpoints (`/save-config`, `/save-bot-password`, `/rooms`) require an admin session
|
||||
- Webhook endpoints are public (`#[PublicPage]`, `#[NoCSRFRequired]`) — auth token is the sole access control
|
||||
- The bot must be a participant in the target room (enforced by `isBotEnabledForRoom()` + Talk's own participant check)
|
||||
|
||||
### Data isolation
|
||||
|
||||
- Bot password is encrypted at rest using Nextcloud's `ICrypto` layer
|
||||
- Images are stored in the bot user's personal storage (`nc_bot_webhooks-images/`)
|
||||
- Image cleanup only operates within the bot user's storage directory
|
||||
- Each room's images are isolated in a subdirectory (`nc_bot_webhooks-images/<room-token>/`)
|
||||
|
||||
### Image download safety
|
||||
|
||||
- Uses Nextcloud's `IClientService` which includes built-in local address blocking (blocks `127.0.0.0/8`, `10.0.0.0/8`, `192.168.0.0/16`, `172.16.0.0/12`, `::1`, `fc00::/7`)
|
||||
- `allow_local_address: true` is set to allow internal Nextcloud URLs (needed for Docker deployments where the server URL resolves to a container-internal address)
|
||||
- Filenames are sanitized via `basename()` to prevent path traversal
|
||||
|
||||
### Debug endpoint
|
||||
|
||||
The `/debug` endpoint exposes sensitive data (DB schema, bot credentials, config). It is:
|
||||
|
||||
- **Disabled by default**
|
||||
- Enabled only via CLI (`php occ nc_bot_webhooks:debug:enable`)
|
||||
- Stored in `appconfig` (not hardcoded)
|
||||
- Documented with explicit warnings
|
||||
|
||||
---
|
||||
|
||||
## Storage Model
|
||||
|
||||
### AppConfig table (`oc_appconfig`)
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `bot_password` | encrypted string | Bot user's app password |
|
||||
| `rooms` | JSON object | Room token → display name mapping |
|
||||
| `retention_days` | string (int) | Image retention period |
|
||||
| `sender_name` | string | Default sender display name |
|
||||
| `auth_tokens` | JSON object | Room token → auth token array mapping |
|
||||
|
||||
### Bot user storage (`talk-bot` personal files)
|
||||
|
||||
```
|
||||
nc_bot_webhooks-images/
|
||||
├── <room-token-1>/
|
||||
│ ├── screenshot.png
|
||||
│ └── diagram.jpg
|
||||
└── <room-token-2>/
|
||||
└── photo.png
|
||||
```
|
||||
|
||||
### Public link shares
|
||||
|
||||
Each uploaded image gets a public link share (`SHARE_TYPE_LINK`) created via `ShareManager`. The share token is embedded in the Talk message's `rich_object` rich object so Talk can resolve and display the inline image.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Webhook endpoints
|
||||
|
||||
All webhook endpoints return structured `DataResponse` with appropriate HTTP status codes and `X-Webhook-Status` headers:
|
||||
|
||||
| Condition | HTTP Status | X-Webhook-Status |
|
||||
|---|---|---|
|
||||
| Invalid auth token | 401 | `unauthorized` |
|
||||
| Invalid JSON / payload | 400 | `bad_request` |
|
||||
| No message content | 200 | `no_content` |
|
||||
| Success | 200 | `ok` |
|
||||
| Server error | 500 | `error` |
|
||||
|
||||
### Talk API failures
|
||||
|
||||
When `postToRoom()` fails (non-2xx response or network error), the error is logged with room token and response details, and `false` is returned up the call chain. The webhook endpoint returns 500 `error`.
|
||||
|
||||
### Config save failures
|
||||
|
||||
`saveConfig()` throws an `Exception` on bot password validation failure. The controller catches this and returns a JSON error response. Other failures (e.g., DB errors) propagate as 500.
|
||||
|
||||
---
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding a new webhook format
|
||||
|
||||
To support a new webhook format:
|
||||
|
||||
1. Add a new route in `appinfo/routes.php`
|
||||
2. Add a new method to `WebhookController` with `#[PublicPage]` and `#[NoCSRFRequired]`
|
||||
3. Implement payload parsing and validation in the controller
|
||||
4. Add a mapping method to `TalkService` (e.g., `mapNewFormat()`)
|
||||
5. The image upload, rich object building, and Talk posting paths are already implemented
|
||||
|
||||
### Adding a new storage backend
|
||||
|
||||
Images are currently stored in the bot user's personal files. To support external storage:
|
||||
|
||||
1. Replace `uploadImage()` to write to an external service (S3, etc.)
|
||||
2. Update `buildRichObject()` to generate URLs for the external service
|
||||
3. Update `purgeOldImages()` / `purgeFolder()` to clean up external storage
|
||||
4. Add per-file attributes or a separate index table for ownership tracking
|
||||
|
||||
### Adding rate limiting
|
||||
|
||||
Rate limiting is not built in (by design). To add it:
|
||||
|
||||
1. Implement a middleware or decorator on `receive()` / `receiveApprise()`
|
||||
2. Use Nextcloud's rate limiting infrastructure (`IRateLimitManager`)
|
||||
3. Apply per-IP or per-room-token limits
|
||||
@@ -1,3 +1,15 @@
|
||||
.nc-app-frame {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.nc-app-frame h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
padding-bottom: 0.5em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.nc-settings-section {
|
||||
margin-bottom: 2em;
|
||||
padding: 1em;
|
||||
|
||||
@@ -319,9 +319,17 @@ class WebhookController extends Controller {
|
||||
public function saveBotPassword(): DataResponse {
|
||||
$body = file_get_contents('php://input');
|
||||
$data = @json_decode($body, true);
|
||||
if (!is_array($data) || empty($data['bot_password'])) {
|
||||
if (!is_array($data) || !isset($data['bot_password'])) {
|
||||
return new DataResponse(
|
||||
['error' => 'Invalid data'],
|
||||
['error' => 'Missing bot_password field'],
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
$validation = $this->talkService->validateBotPassword($data['bot_password']);
|
||||
if (!$validation['valid']) {
|
||||
return new DataResponse(
|
||||
['error' => $validation['error']],
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,27 @@ class TalkService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a bot password by encrypting and decrypting it (round-trip test).
|
||||
* Returns ['valid' => true] on success, or ['valid' => false, 'error' => '...'] on failure.
|
||||
*/
|
||||
public function validateBotPassword(string $password): array {
|
||||
if ($password === '') {
|
||||
return ['valid' => false, 'error' => 'Bot password cannot be empty.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$encrypted = $this->crypto->encrypt($password);
|
||||
$decrypted = $this->crypto->decrypt($encrypted);
|
||||
if ($decrypted !== $password) {
|
||||
return ['valid' => false, 'error' => 'Password encryption/decryption failed. The password may contain unsupported characters.'];
|
||||
}
|
||||
return ['valid' => true];
|
||||
} catch (\Exception $e) {
|
||||
return ['valid' => false, 'error' => 'Password encryption failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function setBotPassword(string $password): void {
|
||||
$this->config->setAppValue(self::APP_ID, 'bot_password', $this->crypto->encrypt($password));
|
||||
}
|
||||
@@ -1078,6 +1099,10 @@ class TalkService {
|
||||
*/
|
||||
public function saveConfig(array $config): void {
|
||||
if (isset($config['bot_password']) && $config['bot_password'] !== '') {
|
||||
$validation = $this->validateBotPassword($config['bot_password']);
|
||||
if (!$validation['valid']) {
|
||||
throw new \Exception($validation['error']);
|
||||
}
|
||||
$this->setBotPassword($config['bot_password']);
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -1,4 +1,6 @@
|
||||
<div class="nc-settings-section">
|
||||
<div class="nc-app-frame">
|
||||
<h2>Nextcloud Bot Webhooks</h2>
|
||||
<div class="nc-settings-section">
|
||||
<h3>Bot App Password</h3>
|
||||
<label for="nc-bot-password"><strong>Enter bot app password</strong></label><br>
|
||||
<div style="display: flex; gap: 8px; align-items: center; margin: 8px 0;">
|
||||
@@ -12,32 +14,30 @@
|
||||
<p class="nc-hint" style="margin-top: 4px;">
|
||||
<strong>The bot user must be an admin</strong> to list all Talk rooms. Grant admin access in <strong>Settings → Users → [your-bot-user] → Admin</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<div class="nc-settings-section">
|
||||
<h3>Default Sender Name</h3>
|
||||
<label for="nc-sender-name">Sender name used when posting messages</label><br>
|
||||
<input type="text" id="nc-sender-name" value="<?= htmlspecialchars($senderName ?? 'Webhook Bot') ?>" style="width: 100%; padding: 8px; font-size: 1em; margin: 4px 0;">
|
||||
<p class="nc-hint">This name appears as the sender of webhook messages in Talk.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<div class="nc-settings-section">
|
||||
<h3>Image Retention</h3>
|
||||
<label for="nc-retention">Retention period (days)</label><br>
|
||||
<input type="number" id="nc-retention" value="<?= $retentionDays ?>" min="1" max="365" step="1">
|
||||
<p class="nc-hint">Images older than this many days will be purged by the daily cron job. Default: 90 days.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<div class="nc-settings-section">
|
||||
<h3>Room Selection</h3>
|
||||
<button id="nc-fetch-rooms" type="button">Fetch Rooms</button>
|
||||
<div id="nc-rooms-list"></div>
|
||||
<p class="nc-hint">Check rooms to enable webhooks for. Each room gets its own auth token.</p>
|
||||
</div>
|
||||
|
||||
<div class="nc-settings-section">
|
||||
<button id="nc-save" type="button">Save Configuration</button>
|
||||
<button id="nc-save" type="button" style="margin-top: 1em;">Save Configuration</button>
|
||||
<span id="nc-status" class="nc-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config data passed to JS via data attributes -->
|
||||
|
||||
Reference in New Issue
Block a user