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:
kyle
2026-06-12 19:44:42 -07:00
parent 19241c842d
commit 18084d0f69
7 changed files with 1853 additions and 112 deletions
+183 -74
View File
@@ -1,8 +1,26 @@
# NCbotwebhooks # 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 ## Installation
@@ -14,39 +32,105 @@ cp -r /path/to/nc_bot_webhooks .
php occ app:enable 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 ### 2. Create the bot user
```bash ```bash
php occ user:add --password-from-env --display-name="Webhook Bot" talk-bot 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) The bot must have admin privileges to list all Talk rooms. Grant admin access:
2. Go to **Settings → Security → Devices & sessions → Add device**
3. Copy the app password
### 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**: Go to **Settings → Admin → NCbotwebhooks**:
1. **Bot Configuration** — paste the app password from step 3 1. **Bot App Password** — paste the app password from step 4
2. **Image Retention** — set how long to keep uploaded images (default: 90 days) 2. **Default Sender Name** — set the name that appears as the message sender (default: "Webhook Bot")
3. **Room Management** — click "Fetch Rooms" to see available Talk rooms, select the ones you want, and generate auth tokens 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
``` > **Note:** The paths below are specific to a TrueNAS Scale Nextcloud Docker install. Adjust them to match your deployment.
https://your-server.com/apps/nc_bot_webhooks/discord-webhook/<room-token>/<auth-token>
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 ```json
{ {
@@ -57,6 +141,7 @@ Copy the auth token from the app settings for each room.
"description": "Successfully deployed to production", "description": "Successfully deployed to production",
"color": 3066993, "color": 3066993,
"image": { "url": "https://example.com/screenshot.png" }, "image": { "url": "https://example.com/screenshot.png" },
"thumbnail": { "url": "https://example.com/thumb.png" },
"fields": [ "fields": [
{ "name": "Duration", "value": "2m 34s" }, { "name": "Duration", "value": "2m 34s" },
{ "name": "Environment", "value": "Production" } { "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) Apprise sends a JSON wrapper containing a `notifications` array:
- Each image from `embeds[].image` or `embeds[].thumbnail` uploaded and shared inline
- `username` shown as the sender display name
### 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 | | `content` | Sent as text message |
| `embeds[].title` | Included as bold line | | `embeds[].title` | Included as bold line |
| `embeds[].description` | Included in message body | | `embeds[].description` | Included in message body |
| `embeds[].image.url` | Downloaded, uploaded to NC, shared inline | | `embeds[].image.url` | Downloaded, uploaded to NC, shared inline |
| `embeds[].thumbnail.url` | Downloaded, uploaded to NC, shared inline | | `embeds[].thumbnail.url` | Downloaded, uploaded to NC, shared inline |
| `embeds[].fields` | Formatted as `name: value` lines | | `embeds[].fields[].name` + `value` | Formatted as `name: value` lines |
| `username` | Sender display name | | `username` | Sender display name (prepended to message) |
| `avatar_url` | Ignored (NCTalk doesn't support per-message avatars) | | `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:
``` 1. `username` field from Discord payload
/discord-webhook/<room-token>/<auth-token> 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 ## Security
- Auth token in the URL is the primary auth mechanism — keep it secret | Mechanism | Description |
- Bot password is encrypted at rest using Nextcloud's crypto |---|---|
- Image download uses Nextcloud's HTTP client with local address blocking | **Auth tokens** | Each webhook URL contains a unique secret token; the endpoint validates it before processing |
- Rate limiting should be handled at the web server or reverse proxy level | **Bot password** | Encrypted at rest using Nextcloud's crypto layer (`ICrypto`) |
- **Debug endpoint disabled by default** — see below | **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, - Auth tokens generated from the settings UI use client-side generation; for higher security, regenerate them via the server API
database schema, and bot credentials. It is **disabled by default** and must - The webhook endpoint has no rate limiting — consider placing it behind a reverse proxy rate limiter if exposing to untrusted sources
be explicitly enabled via the CLI:
---
## Debug Endpoint
The `/apps/nc_bot_webhooks/debug` endpoint exposes internal configuration, database schema, and bot credentials. It is **disabled by default**.
```bash ```bash
# Check current status # Check status
php occ nc_bot_webhooks:debug:status php occ nc_bot_webhooks:debug:status
# Enable (WARNING: exposes sensitive data) # Enable (WARNING: exposes sensitive data)
@@ -159,13 +254,14 @@ php occ nc_bot_webhooks:debug:disable
php occ nc_bot_webhooks:debug:toggle php occ nc_bot_webhooks:debug:toggle
``` ```
Never leave the debug endpoint enabled in production. After troubleshooting, After troubleshooting, disable it immediately:
disable it immediately:
```bash ```bash
php occ nc_bot_webhooks:debug:disable php occ nc_bot_webhooks:debug:disable
``` ```
---
## Logging ## Logging
Responses include a `X-Webhook-Status` header: Responses include a `X-Webhook-Status` header:
@@ -177,3 +273,16 @@ Responses include a `X-Webhook-Status` header:
| `bad_request` | Invalid JSON payload | | `bad_request` | Invalid JSON payload |
| `no_content` | No message content in payload | | `no_content` | No message content in payload |
| `error` | Check server logs for details | | `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. |
+1216
View File
File diff suppressed because it is too large Load Diff
+371
View File
@@ -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
+12
View File
@@ -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 { .nc-settings-section {
margin-bottom: 2em; margin-bottom: 2em;
padding: 1em; padding: 1em;
+10 -2
View File
@@ -319,9 +319,17 @@ class WebhookController extends Controller {
public function saveBotPassword(): DataResponse { public function saveBotPassword(): DataResponse {
$body = file_get_contents('php://input'); $body = file_get_contents('php://input');
$data = @json_decode($body, true); $data = @json_decode($body, true);
if (!is_array($data) || empty($data['bot_password'])) { if (!is_array($data) || !isset($data['bot_password'])) {
return new DataResponse( 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, Http::STATUS_BAD_REQUEST,
); );
} }
+25
View File
@@ -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 { public function setBotPassword(string $password): void {
$this->config->setAppValue(self::APP_ID, 'bot_password', $this->crypto->encrypt($password)); $this->config->setAppValue(self::APP_ID, 'bot_password', $this->crypto->encrypt($password));
} }
@@ -1078,6 +1099,10 @@ class TalkService {
*/ */
public function saveConfig(array $config): void { public function saveConfig(array $config): void {
if (isset($config['bot_password']) && $config['bot_password'] !== '') { 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']); $this->setBotPassword($config['bot_password']);
} }
+36 -36
View File
@@ -1,43 +1,43 @@
<div class="nc-settings-section"> <div class="nc-app-frame">
<h3>Bot App Password</h3> <h2>Nextcloud Bot Webhooks</h2>
<label for="nc-bot-password"><strong>Enter bot app password</strong></label><br> <div class="nc-settings-section">
<div style="display: flex; gap: 8px; align-items: center; margin: 8px 0;"> <h3>Bot App Password</h3>
<input type="password" id="nc-bot-password" placeholder="<?= $hasBotPassword ? '●●●●●●●● (leave blank to keep current)' : 'Paste talk-bot app password here' ?>" style="flex: 1; padding: 8px; font-size: 1em;"> <label for="nc-bot-password"><strong>Enter bot app password</strong></label><br>
<button id="nc-save-password" type="button" style="display: <?= $hasBotPassword ? 'none' : 'inline-block' ?>;">Save</button> <div style="display: flex; gap: 8px; align-items: center; margin: 8px 0;">
<input type="password" id="nc-bot-password" placeholder="<?= $hasBotPassword ? '●●●●●●●● (leave blank to keep current)' : 'Paste talk-bot app password here' ?>" style="flex: 1; padding: 8px; font-size: 1em;">
<button id="nc-save-password" type="button" style="display: <?= $hasBotPassword ? 'none' : 'inline-block' ?>;">Save</button>
</div>
<p class="nc-hint">
Generate in Nextcloud Settings <strong>talk-bot</strong> Devices &amp; sessions <strong>Add device</strong>.
<?= $hasBotPassword ? 'Leave blank to keep current password.' : 'Required to send messages to Talk.' ?>
</p>
<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>
<p class="nc-hint">
Generate in Nextcloud Settings <strong>talk-bot</strong> Devices &amp; sessions <strong>Add device</strong>.
<?= $hasBotPassword ? 'Leave blank to keep current password.' : 'Required to send messages to Talk.' ?>
</p>
<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 class="nc-settings-section"> <div class="nc-settings-section">
<h3>Default Sender Name</h3> <h3>Default Sender Name</h3>
<label for="nc-sender-name">Sender name used when posting messages</label><br> <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;"> <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> <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> <h3>Image Retention</h3>
<label for="nc-retention">Retention period (days)</label><br> <label for="nc-retention">Retention period (days)</label><br>
<input type="number" id="nc-retention" value="<?= $retentionDays ?>" min="1" max="365" step="1"> <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> <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> <h3>Room Selection</h3>
<button id="nc-fetch-rooms" type="button">Fetch Rooms</button> <button id="nc-fetch-rooms" type="button">Fetch Rooms</button>
<div id="nc-rooms-list"></div> <div id="nc-rooms-list"></div>
<p class="nc-hint">Check rooms to enable webhooks for. Each room gets its own auth token.</p> <p class="nc-hint">Check rooms to enable webhooks for. Each room gets its own auth token.</p>
</div> <button id="nc-save" type="button" style="margin-top: 1em;">Save Configuration</button>
<span id="nc-status" class="nc-status"></span>
<div class="nc-settings-section"> </div>
<button id="nc-save" type="button">Save Configuration</button>
<span id="nc-status" class="nc-status"></span>
</div> </div>
<!-- Config data passed to JS via data attributes --> <!-- Config data passed to JS via data attributes -->