From 18084d0f69ad77d3aa2266034f95a3cb27fe199d Mon Sep 17 00:00:00 2001 From: kyle Date: Fri, 12 Jun 2026 19:44:42 -0700 Subject: [PATCH] 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. --- README.md | 257 ++++-- agent.md | 1216 ++++++++++++++++++++++++++ architecture.md | 371 ++++++++ css/adminSettings.css | 12 + lib/Controller/WebhookController.php | 12 +- lib/Service/TalkService.php | 25 + templates/adminSettings.php | 72 +- 7 files changed, 1853 insertions(+), 112 deletions(-) create mode 100644 agent.md create mode 100644 architecture.md diff --git a/README.md b/README.md index 0e7c828..ecc9bde 100644 --- a/README.md +++ b/README.md @@ -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// +> **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//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//files/TrueNAS configs/Nextcloud Hooker/nc_bot_webhooks/"` is where your Nextcloud user's synced files land on the server (TrueNAS Docker path). Replace `` 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// +``` + +### Apprise format + +``` +https://your-nextcloud-server/apps/nc_bot_webhooks/apprise-webhook//notify/ +``` + +> **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// -``` +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//` 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//notify/ -``` - -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//` -- 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. | diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..0abbec3 --- /dev/null +++ b/agent.md @@ -0,0 +1,1216 @@ +# NCbotwebhooks — Maintenance Reference + +Complete maintenance reference for the `nc_bot_webhooks` Nextcloud app. Covers every file, API interaction, data flow, design decision, and operational detail. + +--- + +## Table of Contents + +- [Project Structure](#project-structure) +- [App Identity & Configuration](#app-identity--configuration) +- [Every File — Purpose & Key Details](#every-file--purpose--key-details) +- [API Interactions](#api-interactions) +- [Data Flow](#data-flow) +- [Configuration Model](#configuration-model) +- [Security Model](#security-model) +- [Known Issues & Limitations](#known-issues--limitations) +- [Operational Procedures](#operational-procedures) +- [Debugging Guide](#debugging-guide) +- [Migration Notes](#migration-notes) +- [Testing](#testing) + +--- + +## Project Structure + +``` +nc_bot_webhooks/ +├── appinfo/ +│ ├── routes.php # HTTP route definitions +│ ├── info.xml # App metadata (id, version, dependencies, classes) +│ └── cron.php # Background job registration +├── lib/ +│ ├── Controller/ +│ │ └── WebhookController.php # HTTP handlers (webhooks + admin endpoints) +│ ├── Service/ +│ │ └── TalkService.php # Core business logic (1138 lines) +│ ├── Command/ +│ │ └── DebugToggle.php # CLI command for debug endpoint +│ ├── Settings/ +│ │ └── Admin.php # Admin settings form handler (ISettings) +│ ├── NavigationProvider.php # App navigation (INavigationProvider) +│ └── Cron/ +│ └── ImageCleanup.php # Background job (IJob) +├── js/ +│ └── settings.js # Admin settings UI (351 lines) +├── templates/ +│ └── adminSettings.php # Settings form HTML +├── css/ +│ └── adminSettings.css # Settings UI styles +├── img/ +│ ├── app.svg # App icon (used in navigation + admin) +│ └── app (copy).svg # Copy of app.svg (purpose unclear) +├── composer.json # Package name + PSR-4 mapping +├── composer/autoload.php # Manual PSR-4 autoloader +├── composer/autoload_psr4.php # Composer-generated PSR-4 map +├── README.md # User-facing documentation +├── INSTALL.md # Installation guide +├── architecture.md # Architecture documentation +└── agent.md # This file +``` + +**Key paths at runtime:** +- Web URL base: `/apps/nc_bot_webhooks/` +- Admin settings path: `Settings → Admin → NCbotwebhooks` +- Bot user: `talk-bot` (must exist and be admin) +- Image storage: `/nc_bot_webhooks-images//` under `talk-bot`'s personal files + +--- + +## App Identity & Configuration + +### Constants + +| Constant | Value | Location | +|---|---|---| +| `APP_ID` | `'nc_bot_webhooks'` | `TalkService.php:24`, `WebhookController.php:33`, `DebugToggle.php:48` | +| `IMAGES_DIR` | `'nc_bot_webhooks-images'` | `TalkService.php:25`, `ImageCleanup.php:12` | +| `DEBUG_KEY` | `'debug_enabled'` | `DebugToggle.php:49` | + +### AppConfig keys (stored in `oc_appconfig` table, `appid = 'nc_bot_webhooks'`) + +| Key | Type | Purpose | +|---|---|---| +| `bot_password` | encrypted string | Bot user's app password (encrypted via `ICrypto`) | +| `rooms` | JSON object | Room token → display name mapping for configured rooms | +| `retention_days` | string (int) | Image retention period in days | +| `sender_name` | string | Default sender display name | +| `auth_tokens` | JSON object | Room token → auth token array mapping | +| `debug_enabled` | bool | Whether the `/debug` endpoint is active | + +**Critical:** After the rename from `ncdiscordhook` to `nc_bot_webhooks`, old config data under the old app ID is inaccessible. Users upgrading must run: +```sql +UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook'; +``` + +### Namespace + +``` +OCA\Ncbotwebhooks\ +├── Controller\WebhookController +├── Service\TalkService +├── Command\DebugToggle +├── Settings\Admin +├── NavigationProvider +└── Cron\ImageCleanup +``` + +--- + +## Every File — Purpose & Key Details + +### lib/Service/TalkService.php (1138 lines) + +**The single most important file.** Contains all business logic. + +#### Constructor / Dependencies + +```php +public function __construct( + IAppConfig $config, // Nextcloud app config (key-value store) + IClientService $clientService, // HTTP client factory + IShareManager $shareManager, // File share management + IRootFolder $rootFolder, // Filesystem root + IUserManager $userManager, // User lookup + IDBConnection $db, // Database connection + IAttendeeMapper $attendeeMapper, // Talk attendee queries + LoggerInterface $logger, // PSR-3 logger +) +``` + +#### Bot Password Methods + +| Method | Lines | Purpose | +|---|---|---| +| `hasBotPassword()` | ~28-34 | Checks if `bot_password` exists in AppConfig | +| `getBotPassword()` | ~36-48 | Retrieves and decrypts bot password via `ICrypto`. Returns `null` if not set. | +| `setBotPassword(string)` | ~50-58 | Encrypts and stores bot password via `ICrypto` → `IAppConfig` | +| `validateBotPassword(string)` | ~60-80 | **Round-trip encryption test**: encrypt → decrypt → compare. Checks: non-empty, no crypto-breaking chars, crypto layer functional. **Does NOT validate against Nextcloud server.** | + +**Why round-trip test?** The original code used `TalkManager::getRemoteServer()` which requires a valid remote URL. The round-trip test is faster and more reliable — it verifies the password works with the crypto layer without making a network call. + +#### Room Methods + +| Method | Lines | Purpose | +|---|---|---| +| `getRooms()` | ~82-90 | Returns JSON-decoded `rooms` from AppConfig (room token → display name) | +| `setRooms(array)` | ~92-98 | Stores room token → display name mapping as JSON in AppConfig | +| `getAvailableTalkRooms()` | ~200-300 | **Queries Talk DB directly** (bypasses TalkManager). Filters: type IN (1,2,3), excludes deleted/note-to-self/sample/file rooms. Returns token → display name. | +| `isBotEnabledForRoom(string)` | ~996-999 | Checks if room token exists in configured rooms | + +**Why direct DB queries?** Talk 14+ removed `TalkManager::getRoomForToken`. Talk's OCS API requires user sessions, not app passwords. Direct queries bypass both issues. + +**Room type filter:** +- Type 1: public channel +- Type 2: group direct message +- Type 3: public direct message +- Excluded: type 4 (deleted), type 6 (note-to-self), sample rooms (`object_type = 'sample'`), file share rooms (`object_type = 'file'`), private DM rooms (`name LIKE '["%'`) + +**Room name resolution:** In NC33, room display name is in the `name` column. Falls back to `token` if `name` is empty. + +#### Base URL Resolution + +`getBaseUrl()` resolves the server URL in this priority order: + +1. `overwritehost` (highest priority, explicit hostname override) +2. `overwritewebroot` (if full URL; if path, falls through) +3. **Non-loopback trusted domain** — iterates `trusted_domains`, skips `127.0.0.1` and private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1, fc00::/7) for Docker compatibility +4. `overwrite.cli.url` (last resort) + +**Why this order?** Docker/container deployments often have `trusted_domains[0] = 127.0.0.1` (unreachable from within the container) and `overwrite.cli.url` pointing to `localhost:port`. The non-loopback filter handles this. + +#### Auth Token Methods + +| Method | Lines | Purpose | +|---|---|---| +| `getAuthTokens()` | ~474-478 | Returns JSON-decoded `auth_tokens` from AppConfig | +| `setAuthTokens(array)` | ~483-485 | Stores auth tokens as JSON in AppConfig | +| `validateAuthToken(roomToken, authToken)` | ~490-496 | Checks if authToken exists in the array for roomToken | +| `generateAuthToken(roomToken)` | ~502-511 | **Server-side**: `bin2hex(random_bytes(24))` → 48-char hex token | +| `revokeAuthToken(roomToken, authToken)` | ~516-529 | Removes token from array, cleans up empty room entries | + +**Note:** The settings UI generates tokens client-side (`btoa(Math.random() + Date.now())`) — this is a known security limitation. Use `generateAuthToken()` for server-side generation. + +#### Payload Mapping + +| Method | Lines | Purpose | +|---|---|---| +| `mapPayload(array)` | ~548-583 | Maps Discord webhook JSON to Talk message text. Extracts: `content`, embed `title` (bold), embed `description`, embed `fields` (name: value). Joins with `\n\n`. | +| `mapApprisePayload(array, roomToken)` | ~608-766 | Maps Apprise JSON to internal format. Handles: `body`, `title`/`subject` as message, `type` prefix ([Info], [Success], [Warning], [Error]), `attachments` (remote URL, local file, base64), `type=image` special case. Returns `{message, senderName, displayName, richObjects}`. | +| `getSenderName(array)` | ~588-593 | Returns `username` from Discord payload, or config default | +| `getSenderNameDefault()` | ~598-600 | Returns config `sender_name` default | +| `prependDisplayName(string, string)` | ~540-546 | Prepends `🤖 **{name}**\n\n` to message. Since Talk doesn't support per-message avatars. | + +**Apprise attachment formats handled:** +1. **Remote URL**: `attachment['url']` → download → upload → rich object +2. **Local file**: `attachment['path']` starting with `file://` → read file → upload → rich object +3. **Base64**: `attachment['base64']` → decode → upload → rich object +4. **type=image**: special case — uses `attachment` (string URL) or `attachments` (array) as image URLs directly + +**Base64 attachment format** (from Apprise library JSON method): +```json +{ + "base64": "", + "filename": "image.png", + "mimetype": "image/png" +} +``` + +**type=image handling** (lines 622-667): When Apprise sends `type: "image"`, the body may be empty. The code uses `title` as the message text if body is empty, and processes all image URLs from `attachment` or `attachments` fields. + +#### Image Handling + +| Method | Lines | Purpose | +|---|---|---| +| `downloadImage(string)` | ~781-803 | HTTP GET via Nextcloud `IClientService`. Timeout: 15s. `allow_local_address: true` (needed for Docker). Returns `['data' => binary, 'mimeType' => string]` or null. | +| `uploadImage(roomToken, filename, data, mimeType)` | ~809-829 | Writes to `nc_bot_webhooks-images//` under `talk-bot`'s personal storage. Uses `basename()` for path traversal protection. Returns relative path or null. | +| `buildRichObject(filePath, mimeType, roomToken)` | ~835-878 | Creates public link share (`SHARE_TYPE_LINK`) for file. Returns rich object data for Talk Chat API with token, name, mimetype, thumbnailReady, fileTarget, path. | + +**Image flow for Discord:** +1. Extract `embed[].image.url` or `embed[].thumbnail.url` +2. `downloadImage()` — HTTP GET +3. Derive filename from URL path (or use `webhook-image.`) +4. `uploadImage()` — write to bot user storage +5. `buildRichObject()` — create public link share +6. Pass rich object to `postToRoom()` as `richObjects` + +**Image flow for Apprise:** +1. Parse `attachments` array (supports URL, local file, base64) +2. For each attachment: download/read → upload → build rich object +3. Collect all rich objects → pass to `postToRoom()` + +#### Talk Posting + +| Method | Lines | Purpose | +|---|---|---| +| `postToRoom(roomToken, message, senderName, richObjects)` | ~894-991 | Posts message to Talk Chat API v1. Basic auth: `talk-bot:botPassword`. Endpoint: `/ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}`. | + +**Chat API v1 body format:** +```json +{ + "message": "🤖 **CI Bot**\n\nBuild #1234 passed", + "actorType": "users", + "actorId": "talk-bot", + "actorDisplayName": "", + "richObjects": { + "file-0": { "rich_object": {...}, "source": "file" } + }, + "richObjectsEnd": { + "file-0": true + } +} +``` + +**Critical: `actorDisplayName` must match the bot's display name in the room's participant record.** In Talk 14+, if it doesn't match, the message is silently dropped. Resolved via `getBotDisplayNameForRoom()` which queries AttendeeMapper. + +**`getBotDisplayNameForRoom()`** (lines 1005-1035): +1. Queries `talk_rooms` table for room ID from token +2. Uses `AttendeeMapper::findByActor(roomId, ACTOR_USERS, 'talk-bot')` to get attendee record +3. Returns attendee's display name, or `'talk-bot'` as fallback + +#### Config Save + +`saveConfig(array $config)` (lines 1100-1137): +1. Validate bot password (if provided) via round-trip test +2. Set bot password (if provided) +3. Set retention days (if provided) +4. Merge rooms: use provided rooms, remove disabled rooms +5. Set rooms +6. Set auth tokens (if provided) +7. Set sender name (if provided) +8. **`ensureBotParticipants()`** — adds talk-bot as participant in all configured rooms + +**`ensureBotParticipants()`** (called on every save): +- Queries configured rooms +- For each room, checks if talk-bot is already a participant +- If not, inserts an attendee record via `AttendeeMapper` +- Ensures bot is in all rooms for Chat API to accept messages + +#### Image Cleanup + +| Method | Lines | Purpose | +|---|---|---| +| `purgeOldImages()` | ~1042-1059 | Gets retention days, computes cutoff time, gets bot user folder, calls `purgeFolder()` | +| `purgeFolder(Folder, int)` | ~1064-1084 | **Recursively** deletes files/folders by mtime only. Cleans up empty subdirectories. | + +**⚠️ SECURITY CONCERN:** `purgeFolder()` deletes by mtime only — no file ownership verification. It operates on the `nc_bot_webhooks-images/` directory under the bot user's personal storage. This is safe because: +1. It only operates within the bot user's personal folder +2. The bot user is isolated from other users +3. The directory is specifically for webhook images + +**User requested per-file attributes for additional safety — NOT YET IMPLEMENTED.** + +#### Debug Helpers + +| Method | Purpose | +|---|---| +| `detectTalkTableFromCatalog(newName, oldName)` | Queries `information_schema.tables` for Talk table. Tries both `talk_rooms`/`spreed_room` with prefix candidates from `spreed.databaseprefix` and `dbtableprefix`. | +| `getTalkTableColumns(tableName)` | Returns column names + types for a table | +| `getTalkTableSample(tableName, limit)` | Returns sample rows from a table | +| `getAllTalkRoomsDebug(limit)` | Returns all rooms with id, token, type, readable_name, label, name, object_type, object_id | +| `getRoomTypeBreakdown()` | Returns count of rooms per type | + +--- + +### lib/Controller/WebhookController.php + +**HTTP request handlers.** Extends `Controller`, constructor takes `appName`, `request`, `TalkService`, `IAppConfig`, `LoggerInterface`. + +#### Discord Webhook Handler: `receive()` + +**Route:** `POST /discord-webhook/{roomToken}/{token}` + +**Flow:** +1. Validate auth token via `TalkService::validateAuthToken()` +2. Parse JSON from `php://input` +3. `mapPayload()` — convert to Talk message text +4. If no message content → return 400 `no_content` +5. `getSenderName()` — resolve sender name +6. `prependDisplayName()` — embed sender name in message +7. Iterate embeds → extract image/thumbnail URLs → `downloadImage()` → `uploadImage()` → `buildRichObject()` +8. `postToRoom()` — post to Talk Chat API +9. Return 201 `ok` or 500 `error` + +**Response headers:** `X-Webhook-Status: ok|unauthorized|bad_request|no_content|error` + +**HTTP status codes:** 401 (unauthorized), 400 (bad_request/no_content), 201 (ok), 500 (error) + +#### Apprise Webhook Handler: `receiveApprise()` + +**Route:** `POST /apprise-webhook/{roomToken}/{token}` + +**Flow:** +1. Validate auth token +2. Detect content type: JSON → `$_POST` → form-encoded +3. Parse payload (try JSON → `$_POST` → `parse_str`) +4. **Unwrap `notifications` array** if present (Apprise API wrapper) +5. **Propagate wrapper-level `subject`/`title`** to notification entry (preserves original data before extraction) +6. `mapApprisePayload()` — convert to internal format +7. Handle attachments (remote URL, local file, base64) +8. `prependDisplayName()` — embed sender name +9. `postToRoom()` — post to Talk +10. Return response with `X-Webhook-Status` + +**Key difference from Discord handler:** Apprise sends a different payload structure with `notifications` array wrapper. The code handles: +- Single notification (no wrapper) +- Wrapped notification (extract from `notifications[0]`) +- `type=image` notifications (special image handling) +- Multiple attachment formats (URL, local file, base64) + +**Wrapper-level subject/title propagation fix:** When the wrapper has `subject` or `title` but the inner notification doesn't, these values are propagated to the notification entry so the sender name is preserved. + +#### Apprise Notify Handler: `receiveAppriseNotify()` + +**Route:** `POST /apprise-webhook/{roomToken}/notify/{token}` + +Simply delegates to `receiveApprise()`. Apprise's `apprises://` URL scheme inserts `notify` in the path automatically. + +#### Admin Endpoints + +| Method | Route | Auth | Purpose | +|---|---|---|---| +| `saveConfig()` | POST `/save-config` | Admin session | Bulk config save (rooms, auth tokens, retention, sender name, bot password). Validates bot password, saves all config, ensures bot participants. | +| `saveBotPassword()` | POST `/save-bot-password` | Admin session | Standalone bot password save with validation. | +| `getRooms()` | GET `/rooms` | Admin session | Returns available Talk rooms and configured rooms for JS. | +| `debug()` | GET `/debug` | Admin session | Exposes DB schema, bot credentials, config (disabled by default). | + +**`saveConfig()` flow:** +1. `TalkService::saveConfig()` — validates, saves, ensures participants +2. Return JSON: `{'status': 'ok', 'auth_tokens': }` +3. If validation fails → throw Exception → catch → return JSON error + +**`getRooms()` flow:** +1. `TalkService::getAvailableTalkRooms()` → available rooms +2. `TalkService::getRooms()` → configured rooms +3. Merge: for each available room, check if configured → `{'token', 'name', 'configured'}` +4. Return JSON array + +**`debug()` endpoint** (when enabled): +1. Get bot password from AppConfig +2. Get rooms from AppConfig +3. Get auth tokens from AppConfig +4. Get Talk table columns via `detectTalkTableFromCatalog()` → `getTalkTableColumns()` +5. Get room type breakdown +6. Return all as JSON + +--- + +### lib/Command/DebugToggle.php + +CLI command for managing the debug endpoint. Uses `IAppConfig` with key `debug_enabled` under app ID `nc_bot_webhooks`. + +**Commands:** +- `php occ nc_bot_webhooks:debug:enable` — enable +- `php occ nc_bot_webhooks:debug:disable` — disable +- `php occ nc_bot_webhooks:debug:toggle` — toggle +- `php occ nc_bot_webhooks:debug:status` — show status + +**Default:** disabled. **Warning:** enables public access to sensitive data (DB schema, bot credentials, config). + +--- + +### lib/Settings/Admin.php + +Admin settings form handler. Implements `ISettings`. + +**`getForm()`:** +1. Loads CSS (`adminSettings`) and JS (`settings`) +2. Injects config data into template params: + - `hasBotPassword` — whether bot password is configured + - `retentionDays` — current retention value + - `rooms` — configured rooms (for checkbox state) + - `authTokens` — current auth tokens + - `configuredRooms` — configured rooms (for JS state) + - `serverUrl` — resolved base URL + - `senderName` — current sender name + - `l10n` — translated strings +3. Returns `TemplateResponse('nc_bot_webhooks', 'adminSettings', $params)` + +**`getPriority(): 10`** — higher number = displayed lower in settings list +**`getSection(): 'additional'`** — places under "Additional" section +**`getIcons(): [imagePath('nc_bot_webhooks', 'app.svg')]`** — app icon + +--- + +### lib/NavigationProvider.php + +Implements `INavigationProvider`. Adds "NCbotwebhooks" entry to admin settings navigation. + +**Admin-only:** returns empty array if user is not admin. + +**Navigation entry:** +```php +[ + 'id' => 'nc_bot_webhooks', + 'app_id' => 'nc_bot_webhooks', + 'type' => 'settings', + 'name' => 'NCbotwebhooks', + 'href' => linkToRoute('settings.AdminSettings#index'), + 'icon' => imagePath('nc_bot_webhooks', 'app.svg'), + 'order' => 0, +] +``` + +--- + +### lib/Cron/ImageCleanup.php + +Background job. Implements `IJob`. + +**`run($argument)`:** +1. Get `talk-bot` user +2. Get `nc_bot_webhooks-images` folder from bot's personal storage +3. Call `TalkService::purgeOldImages()` +4. Log purge count + +**Registration:** via `appinfo/info.xml` `` and `appinfo/cron.php`. + +**⚠️ NOTE:** Only deletes by mtime — no per-file attribute check. User requested per-file attributes for safety (NOT YET IMPLEMENTED). + +--- + +### js/settings.js (351 lines) + +Admin settings UI. All logic in `DOMContentLoaded` event handler. + +**State variables:** +- `configuredRoomsState` — current room configuration +- `authTokensState` — current auth tokens (client-side) +- `serverAuthTokens` — canonical auth tokens from server (synced after save) + +**Data sources:** Read from DOM data attributes on `#nc-config-data`: +- `data-configured-rooms` — JSON of configured rooms +- `data-auth-tokens` — JSON of auth tokens +- `data-server-url` — server base URL +- `data-sender-name` — current sender name +- `data-has-bot-password` — 1 or 0 +- `data-retention` — current retention days +- `data-l10n` — JSON of translated strings + +**Key functions:** + +| Function | Lines | Purpose | +|---|---|---| +| `parseData(el, key)` | 5-11 | Safely parse JSON from data attribute | +| `showStatus(msg, type)` | 31-35 | Show transient status message (success/error) | +| `fetchRooms()` | 59-122 | GET `/rooms`, render checkboxes + token divs | +| `renderTokens(roomToken, container)` | 131-252 | Render auth tokens with webhook URLs + generate/revoke | +| `savePasswordBtn handler` | 255-282 | POST `/save-bot-password` with validation | +| `saveBtn handler` | 285-349 | POST `/save-config` with rooms, tokens, retention, sender name | + +**Token generation** (line 245-246): +```javascript +var raw = Math.random().toString(36).substring(2) + Date.now().toString(36); +var token = btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +``` +Uses URL-safe base64 encoding of `Math.random() + Date.now()`. **Known security limitation** — server-side `random_bytes(32)` would be more secure. + +**URL generation** (lines 137-140): +```javascript +var discordPath = OC.generateUrl('/apps/' + APP_ID + '/discord-webhook/') + roomToken + '/{token}'; +var apprisePath = OC.generateUrl('/apps/' + APP_ID + '/apprise-webhook/') + roomToken + '/notify/{token}'; +var discordUrl = window.location.protocol + '//' + window.location.host + discordPath; +var appriseUrl = window.location.protocol + '//' + window.location.host + apprisePath; +``` +Uses `window.location` for protocol/host, `{token}` placeholder replaced when token is generated. + +**Save flow** (bulk config): +1. Collect checked checkboxes → rooms +2. Track disabled rooms (previously checked, now unchecked) +3. Build payload: rooms, disabled_rooms, auth_tokens, retention_days, sender_name +4. Include bot_password only if input is non-empty +5. POST to `/save-config` +6. On success: sync `authTokensState` from server response, re-render token divs + +**Auto-fetch:** If configured rooms exist on load, automatically fetches room list. + +--- + +### templates/adminSettings.php + +Settings form HTML. Single `.nc-app-frame` wrapper containing: + +1. **Bot App Password** — password input + conditional save button (shown only if no password configured) +2. **Default Sender Name** — text input +3. **Image Retention** — number input (1-365) +4. **Room Selection** — Fetch Rooms button + checkbox list + Generate Auth Token + Save Configuration + status + +**Config data passed to JS** via `#nc-config-data` data attributes (JSON-encoded). + +--- + +### css/adminSettings.css + +Styles for the settings UI: +- `.nc-app-frame` — bordered frame around entire settings +- `.nc-settings-section` — bordered sections for each config group +- `.nc-room-item` — checkbox row layout +- `.nc-room-tokens` — token container with dark background +- `.nc-token-row` — token input + button row +- `.nc-token-url-input` — monospace URL display +- `.nc-token-copy` / `.nc-token-revoke` — action buttons +- `.nc-generate-token` — generate button +- `.nc-status-success` / `.nc-status-error` — status colors +- `.nc-hint` — helper text + +--- + +### appinfo/routes.php + +Route definitions. All webhook routes use `{roomToken}` and `{token}` path parameters. + +```php +[ + 'webhook#receive', // POST /discord-webhook/{roomToken}/{token} + 'webhook#saveConfig', // POST /save-config + 'webhook#getRooms', // GET /rooms + 'webhook#saveBotPassword', // POST /save-bot-password + 'webhook#debug', // GET /debug (noCsrf) + 'webhook#receiveApprise', // POST /apprise-webhook/{roomToken}/{token} + 'webhook#receiveAppriseNotify', // POST /apprise-webhook/{roomToken}/notify/{token} +] +``` + +--- + +### appinfo/info.xml + +```xml +nc_bot_webhooks +NCbotwebhooks +Discord webhook bridge for Nextcloud Talk with image support +Accepts Discord webhook-style JSON payloads and posts them into Nextcloud Talk rooms, preserving image attachments. +1.1.0 +AGPL-3.0-or-later +Ncbotwebhooks + + + spreed + +``` + +**Dependencies:** +- Nextcloud 33 only (explicit version range) +- `spreed` app (Talk) must be enabled + +**Class registrations:** +- Background job: `OCA\Ncbotwebhooks\Cron\ImageCleanup` +- Admin settings: `OCA\Ncbotwebhooks\Settings\Admin` +- CLI command: `OCA\Ncbotwebhooks\Command\DebugToggle` + +--- + +### composer.json + +```json +{ + "name": "nc_bot_webhooks/app", + "autoload": { + "psr-4": { + "OCA\\Ncbotwebhooks\\": "../lib/" + } + } +} +``` + +Note: The `../lib/` path assumes this is in a `nc_bot_webhooks/` subdirectory of the project root. Nextcloud's app loading may not use this directly — the manual `composer/autoload.php` is the primary autoloader. + +--- + +## API Interactions + +### Nextcloud Talk Chat API v1 + +**Endpoint:** `POST /ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}` + +**Authentication:** Basic auth with `talk-bot:bot_password` + +**Headers:** +``` +OCS-Expect-Formatted: json +OCS-APIRequest: 1 +Authorization: Basic +Content-Type: application/json +``` + +**Request body:** +```json +{ + "message": "Message text (supports rich text formatting)", + "actorType": "users", + "actorId": "talk-bot", + "actorDisplayName": "Display name from room participant", + "richObjects": { + "file-0": { + "rich_object": { + "id": "", + "elements": [{ + "type": "file", + "id": "", + "name": "filename.png", + "mimetype": "image/png", + "thumbnailReady": true, + "fileTarget": "/nc_bot_webhooks-images/roomToken/filename.png", + "path": "filename.png" + }] + }, + "source": "file" + } + }, + "richObjectsEnd": { + "file-0": true + } +} +``` + +**Response:** 200 on success, error on failure. The app checks `statusCode >= 200 && statusCode < 300`. + +**Critical constraint:** `actorDisplayName` MUST match the bot's display name in the room's participant record. Mismatch → message silently dropped. + +### Apprise API + +Apprise sends webhook payloads in several formats depending on the transport: + +**JSON format (primary):** +```json +{ + "version": 0, + "type": "info|success|warning|error|image", + "title": "Title", + "subject": "Subject", + "body": "Message body", + "notifications": [ + { + "subject": "Subject", + "title": "Title", + "body": "Body", + "type": "type", + "attachments": [...] + } + ], + "attachments": [...] +} +``` + +**Wrapper behavior:** Apprise wraps individual notifications in a `notifications` array. The code handles both wrapped (extract from array) and unwrapped (use directly) formats. + +**Wrapper-level subject/title propagation:** When the wrapper has `subject` or `title` but the inner notification doesn't, these are propagated down to preserve sender name context. + +**Content types handled:** +1. `application/json` — direct JSON parse +2. `multipart/form-data` — `$_POST` +3. `application/x-www-form-urlencoded` — `parse_str()` + +**Apprise URL schemes:** +- `discord://` → uses Discord webhook format +- `apprises://` → uses Apprise format with `notify` in path: `/apprise-webhook/{room}/notify/{token}` + +### Discord Webhook API + +Discord webhooks send JSON with these fields: +```json +{ + "content": "Message text", + "embeds": [ + { + "title": "Embed title", + "description": "Embed description", + "color": 3066993, + "fields": [{"name": "Field", "value": "Value"}], + "image": {"url": "https://..."}, + "thumbnail": {"url": "https://..."} + } + ], + "username": "Sender name", + "avatar_url": "https://..." +} +``` + +**Mapping to Talk:** +- `content` → message text +- `embeds[].title` → bold line `**Title**` +- `embeds[].description` → message body +- `embeds[].fields` → `name: value` lines +- `embeds[].image.url` / `thumbnail.url` → download → upload → inline image +- `username` → sender display name (prepended as `🤖 **username**`) +- `avatar_url` → ignored (Talk doesn't support per-message avatars) + +--- + +## Data Flow + +### Webhook Ingestion (Discord) + +``` +POST /discord-webhook/{roomToken}/{token} + │ + ├─► WebhookController::receive() + │ │ + │ ├─ 1. validateAuthToken(roomToken, token) + │ ├─ 2. json_decode(file_get_contents('php://input')) + │ ├─ 3. mapPayload(data) → message text + │ ├─ 4. getSenderName(data) → sender name + │ ├─ 5. prependDisplayName(senderName, message) + │ ├─ 6. For each embed image/thumbnail: + │ │ ├─ downloadImage(url) + │ │ ├─ uploadImage(roomToken, filename, data, mimeType) + │ │ └─ buildRichObject(filePath, mimeType, roomToken) + │ ├─ 7. postToRoom(roomToken, message, senderName, richObjects) + │ │ ├─ getBotPassword() + │ │ ├─ isBotEnabledForRoom(roomToken) + │ │ ├─ getBaseUrl() + │ │ ├─ getBotDisplayNameForRoom(roomToken) + │ │ └─ HTTP POST with Basic auth + │ └─► DataResponse with X-Webhook-Status + │ + └─► HTTP 201/400/401/500 + X-Webhook-Status header +``` + +### Webhook Ingestion (Apprise) + +``` +POST /apprise-webhook/{roomToken}/{token} + │ + ├─► WebhookController::receiveApprise() + │ │ + │ ├─ 1. validateAuthToken(roomToken, token) + │ ├─ 2. Detect content type (JSON → $_POST → parse_str) + │ ├─ 3. Parse payload + │ ├─ 4. If 'notifications' in data: extract first entry, propagate wrapper subject/title + │ ├─ 5. mapApprisePayload(data, roomToken) + │ │ ├─ Resolve displayName from title/subject + │ │ ├─ Build message from body (or title if body empty) + │ │ ├─ Add type prefix ([Info], [Warning], etc.) + │ │ └─ Process attachments (URL → download, file:// → read, base64 → decode) + │ ├─ 6. prependDisplayName(displayName, message) + │ ├─ 7. postToRoom(roomToken, message, senderName, richObjects) + │ └─► DataResponse with X-Webhook-Status + │ + └─► HTTP 201/400/401/500 + X-Webhook-Status header +``` + +### Configuration Save + +``` +POST /save-config (admin) + │ + ├─► WebhookController::saveConfig() + │ │ + │ ├─► TalkService::saveConfig(config) + │ │ ├─ validateBotPassword() if provided + │ │ ├─ setBotPassword() if provided + │ │ ├─ setRetentionDays() if provided + │ │ ├─ setRooms() with disabled rooms removed + │ │ ├─ setAuthTokens() if provided + │ │ ├─ setSenderName() if provided + │ │ └─ ensureBotParticipants() + │ └─► JSON {status: 'ok', auth_tokens: ...} + │ + └─► JSON response +``` + +### Room Listing + +``` +GET /rooms (admin) + │ + ├─► WebhookController::getRooms() + │ │ + │ ├─► TalkService::getAvailableTalkRooms() + │ │ ├─ detectTalkTableFromCatalog('talk_rooms', 'spreed_room') + │ │ ├─ Query: SELECT token, COALESCE(NULLIF(name,''),token) FROM talk_rooms + │ │ │ WHERE type IN (1,2,3) AND object_type NOT IN ('sample','note_to_self','file') + │ │ └─ Return token → display name + │ │ + │ ├─► TalkService::getRooms() (configured rooms) + │ │ + │ └─► Merge: for each available room, add 'configured' flag + │ + └─► JSON array of {token, name, configured} +``` + +--- + +## Configuration Model + +### AppConfig (oc_appconfig table) + +All config is stored in the `oc_appconfig` table with `appid = 'nc_bot_webhooks'`. + +| appid | config_key | config_value | +|---|---|---| +| nc_bot_webhooks | bot_password | `` | +| nc_bot_webhooks | rooms | `{"roomToken1": "Room Name", ...}` | +| nc_bot_webhooks | retention_days | `"90"` | +| nc_bot_webhooks | sender_name | `"Webhook Bot"` | +| nc_bot_webhooks | auth_tokens | `{"roomToken1": ["token1", "token2"], ...}` | +| nc_bot_webhooks | debug_enabled | `0` or `1` | + +### Bot User Storage + +``` +talk-bot/ +└── nc_bot_webhooks-images/ + ├── / + │ ├── screenshot.png + │ └── diagram.jpg + └── / + └── photo.png +``` + +Each image has a public link share (`SHARE_TYPE_LINK`) created via `ShareManager`. The share token is embedded in the Talk message's `rich_object` so Talk resolves and displays the inline image. + +### Base URL Resolution Priority + +1. `overwritehost` — explicit hostname override +2. `overwritewebroot` — if full URL +3. Non-loopback `trusted_domains` — skips 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1, fc00::/7 +4. `overwrite.cli.url` — last resort + +### Talk Table Detection Priority + +1. `spreed.databaseprefix` (app config) + `talk_rooms` +2. `dbtableprefix` (system config) + `talk_rooms` +3. `spreed.databaseprefix` + `spreed_room` +4. `dbtableprefix` + `spreed_room` +5. `talk_rooms` (no prefix) +6. `spreed_room` (no prefix) + +Each candidate is verified via `information_schema.tables` + test query. + +--- + +## Security Model + +### Authentication Layers + +| Layer | Mechanism | +|---|---| +| Webhook access | Auth token in URL path (`/discord-webhook/{room}/{token}`) | +| Admin settings | Nextcloud session auth + `#[AdminRequired]` attribute | +| Bot → Talk | Basic auth (`talk-bot:bot_password`) | + +### Authorization + +- Admin-only endpoints: `/save-config`, `/save-bot-password`, `/rooms` require admin session +- Webhook endpoints: `#[PublicPage]` + `#[NoCSRFRequired]` — auth token is sole access control +- Bot must be participant in target room (enforced by `isBotEnabledForRoom()` + Talk's own check) + +### Data Isolation + +- Bot password: encrypted at rest via `ICrypto` +- Images: stored in `talk-bot`'s personal storage under `nc_bot_webhooks-images//` +- Image cleanup: only operates within `nc_bot_webhooks-images/` under bot user's folder +- Per-file attributes: **NOT YET IMPLEMENTED** (user requested for extra safety) + +### Image Download Safety + +- Uses Nextcloud `IClientService` with built-in local address blocking +- `allow_local_address: true` — needed for Docker (container-internal addresses) +- Filenames sanitized via `basename()` — prevents path traversal + +### Debug Endpoint + +- `/apps/nc_bot_webhooks/debug` exposes DB schema, bot credentials, config +- **Disabled by default** +- Enabled only via CLI (`php occ nc_bot_webhooks:debug:enable`) +- Stored in `appconfig` (not hardcoded) +- **Should be disabled after troubleshooting** + +--- + +## Known Issues & Limitations + +### Critical + +1. **Apprise 500 error after rename:** Config data still under old app ID `ncdiscordhook`. Fix: + ```sql + UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook'; + ``` + +2. **Version pinning to NC33:** `` — app only works on NC33. If upgrading Nextcloud, remove max-version or update it. + +### Security + +3. **Client-side token generation:** Settings UI uses `btoa(Math.random() + Date.now())`. Server-side `random_bytes(32)` would be more secure. Use `TalkService::generateAuthToken()` for server-side generation. + +4. **No rate limiting:** Webhook endpoints have no rate limiting. Handle at web server / reverse proxy level. + +5. **Image cleanup by mtime only:** No per-file ownership verification. Safe because scoped to bot user's folder, but user requested per-file attributes for extra safety (NOT YET IMPLEMENTED). + +### Functional + +6. **avatar_url ignored:** NCTalk doesn't support per-message avatars. `username` is embedded as sender name instead. + +7. **actorDisplayName mismatch:** If bot's display name changes in the room, messages will be silently dropped. `getBotDisplayNameForRoom()` resolves this on each message. + +8. **Table name detection failure:** If `information_schema` is unavailable or Talk table uses an unusual name, `detectTalkTableFromCatalog()` returns null and room listing fails. + +9. **Bot not in room:** If talk-bot is removed from a configured room, `postToRoom()` will fail. `ensureBotParticipants()` runs on every save to prevent this. + +--- + +## Operational Procedures + +### Enabling the App + +```bash +# 1. Create bot user +php occ user:add --password-from-env --display-name="Webhook Bot" talk-bot + +# 2. Make bot admin (via web UI: Settings → Users → talk-bot → Admin) + +# 3. Generate app password (via web UI: Settings → talk-bot → Devices & sessions → Add device) + +# 4. Copy app to Nextcloud apps directory +cp -r nc_bot_webhooks /path/to/nextcloud/apps/ + +# 5. Enable app +php occ app:enable nc_bot_webhooks + +# 6. Configure via web UI (Settings → Admin → NCbotwebhooks) +``` + +### Configuring Rooms + +1. Go to Settings → Admin → NCbotwebhooks +2. Enter bot app password +3. Click "Fetch Rooms" to list available Talk rooms +4. Check rooms to enable webhooks for +5. Generate auth tokens for each room +6. Click "Save Configuration" + +### Managing Auth Tokens + +**Via UI:** +- Click "+ Generate Auth Token" for a room +- Click "Revoke All" to remove all tokens for a room +- Click "Copy" to copy webhook URLs + +**Via CLI / direct DB:** +- Tokens stored as JSON in `oc_appconfig` under `auth_tokens` +- Format: `{"roomToken": ["token1", "token2"], ...}` + +### Debugging Webhook Issues + +1. Check `X-Webhook-Status` header in response +2. Enable debug endpoint: `php occ nc_bot_webhooks:debug:enable` +3. Visit `/apps/nc_bot_webhooks/debug` to inspect config +4. Check Nextcloud log: `data/nextcloud.log` or Settings → Admin → Logging +5. Disable debug: `php occ nc_bot_webhooks:debug:disable` + +### Migrating from ncdiscordhook + +```sql +UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook'; +``` + +### Image Cleanup + +**Manual trigger:** +```bash +# The cron job runs automatically. To trigger manually: +php occ background:cron +``` + +**Adjust retention:** Settings → Admin → NCbotwebhooks → Image Retention (1-365 days) + +**Check images directory:** +```bash +ls /path/to/nextcloud/data/talk-bot/nc_bot_webhooks-images/ +``` + +--- + +## Debugging Guide + +### Common Error Patterns + +| Symptom | Cause | Fix | +|---|---|---| +| Apprise 500 error | Config under old app ID | Run migration SQL above | +| "talk-bot user not found" | Bot user doesn't exist | Re-create bot user | +| "Bot password not configured" | No bot password in settings | Enter bot password | +| Messages not appearing | Bot not in room / actorDisplayName mismatch | Check admin settings, re-save | +| Image upload fails | Bot storage quota exceeded | Check bot user storage | +| "No Talk rooms found" | Talk not enabled / table detection failed | Verify Talk app, check debug endpoint | +| Room listing empty | Bot not admin / Talk version incompatible | Grant admin, check NC version | + +### Debug Endpoints + +**CLI:** +```bash +php occ nc_bot_webhooks:debug:enable # Enable +php occ nc_bot_webhooks:debug:disable # Disable +php occ nc_bot_webhooks:debug:status # Check +php occ nc_bot_webhooks:debug:toggle # Toggle +``` + +**Web (when enabled):** `GET /apps/nc_bot_webhooks/debug` returns JSON with: +- Bot password (plaintext) +- Configured rooms +- Auth tokens +- Talk table columns +- Room type breakdown + +### Talk Service Debug Helpers + +These public methods on `TalkService` are available for programmatic debugging: + +| Method | Returns | +|---|---| +| `detectTalkTableFromCatalog('talk_rooms', 'spreed_room')` | Table name or null | +| `getTalkTableColumns('table_name')` | Array of {name, type, nullable} | +| `getTalkTableSample('table_name', 10)` | Array of sample rows | +| `getAllTalkRoomsDebug(100)` | Array of {id, token, type, readable_name, label, name, object_type, object_id} | +| `getRoomTypeBreakdown()` | {type: count} array | + +### Log Tags + +All log entries use: +- Logger tag: `'NCbotwebhooks:'` +- App tag: `'app' => 'nc_bot_webhooks'` + +Example log entries: +``` +NCbotwebhooks: webhook processed successfully +NCbotwebhooks: failed to post webhook message to Talk +NCbotwebhooks: message posted to room ABC123 +NCbotwebhooks: bot password not configured +NCbotwebhooks: bot not enabled for room +NCbotwebhooks: base URL not configured +NCbotwebhooks: getAvailableTalkRooms +NCbotwebhooks: found 5 rooms +NCbotwebhooks: purgeOldImages +NCbotwebhooks: purged 3 old image files +``` + +--- + +## Migration Notes + +### From ncdiscordhook to nc_bot_webhooks (v1.1.0) + +**Breaking change:** New app ID = new installation. Existing config data is under old app ID. + +**Migration steps:** +1. Copy new app files to Nextcloud apps directory +2. Enable app: `php occ app:enable nc_bot_webhooks` +3. Run config migration SQL +4. Re-enter bot password in settings (encrypted values don't migrate) +5. Re-configure rooms and auth tokens (or migrate via SQL) + +**Config migration SQL:** +```sql +UPDATE oc_appconfig SET appid = 'nc_bot_webhooks' WHERE appid = 'ncdiscordhook'; +``` + +**What migrates:** +- `rooms` — room configuration +- `auth_tokens` — auth tokens +- `retention_days` — retention period +- `sender_name` — sender name + +**What does NOT migrate:** +- `bot_password` — must be re-entered (encrypted values are tied to the instance, not the app ID) + +### Nextcloud Version Compatibility + +Currently pinned to Nextcloud 33 (`min-version="33" max-version="33"`). If upgrading Nextcloud: + +1. Update `max-version` in `info.xml` +2. Test Talk API compatibility (Chat API v1 may change) +3. Test `information_schema` queries on new NC version +4. Test AttendeeMapper API compatibility + +### Talk API Version Notes + +- Uses **Chat API v1** only (`/ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}`) +- Talk 14+ removed `TalkManager::getRoomForToken` — app uses direct DB queries +- Talk 14+ requires `actorDisplayName` to match participant record exactly +- Talk 19 / NC33 only supports v1 of the Chat API (older versions deprecated) + +--- + +## Testing + +### Manual Testing Checklist + +1. **Discord webhook** — POST valid payload → message appears in Talk room +2. **Discord webhook with images** — embed with image URL → image appears inline +3. **Discord webhook with embeds** — embed with title/description/fields → formatted message +4. **Discord webhook invalid token** → 401 unauthorized +5. **Discord webhook invalid JSON** → 400 bad_request +6. **Discord webhook empty content** → 200 no_content +7. **Apprise webhook** — POST valid notification → message appears in Talk room +8. **Apprise webhook with attachments** — URL attachments → images appear inline +9. **Apprise webhook type=image** — image notification → inline image +10. **Apprise webhook base64 attachment** — base64 data → inline image +11. **Apprise webhook form-encoded** — form data → message appears +12. **Admin settings** — save config → rooms configured, bot participants added +13. **Token generation** — generate token → appears in room token list +14. **Token revoke** — revoke all → tokens cleared +15. **Image cleanup** — wait for cron → old images purged +16. **Debug endpoint** — enable → inspect config → disable + +### Curl Test Commands + +**Discord webhook:** +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -d '{"content":"Test message","username":"CI Bot"}' \ + https://your-server/apps/nc_bot_webhooks/discord-webhook// +``` + +**Apprise webhook:** +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -d '{"title":"Test","body":"Test message","type":"info"}' \ + https://your-server/apps/nc_bot_webhooks/apprise-webhook// +``` + +**Check debug endpoint (when enabled):** +```bash +curl -u admin:password https://your-server/apps/nc_bot_webhooks/debug | jq . +``` + +--- + +## Quick Reference + +### File Sizes (approximate) + +| File | Lines | Size | +|---|---|---| +| `TalkService.php` | 1138 | ~35 KB | +| `WebhookController.php` | ~400 | ~15 KB | +| `settings.js` | 351 | ~12 KB | +| `Admin.php` | 74 | ~3 KB | +| `ImageCleanup.php` | 54 | ~1.5 KB | +| `DebugToggle.php` | 102 | ~3 KB | +| `NavigationProvider.php` | 38 | ~1 KB | +| `adminSettings.php` | 54 | ~2 KB | +| `adminSettings.css` | 106 | ~2 KB | +| `routes.php` | 43 | ~1 KB | +| `info.xml` | 33 | ~1 KB | + +### Key Dependencies + +| Dependency | Purpose | +|---|---| +| `IAppConfig` | App-level key-value config storage | +| `IClientService` | HTTP client (with local address blocking) | +| `IShareManager` | File share management (public link shares) | +| `IRootFolder` | Filesystem root (user folder access) | +| `IUserManager` | User lookup (`get('talk-bot')`) | +| `IDBConnection` | Database queries | +| `IAttendeeMapper` | Talk attendee queries | +| `ICrypto` | Password encryption/decryption | +| `IShareManager` | Public link share creation | +| `IL10N` | Internationalization | +| `LoggerInterface` | PSR-3 logging | + +### Nextcloud APIs Used + +| API | Version | Endpoint / Interface | +|---|---|---| +| Talk Chat API | v1 | `/ocs/v2.php/apps/spreed/api/v1/chat/{roomToken}` | +| Talk AttendeeMapper | — | `findByActor(roomId, actorType, actorId)` | +| Talk Room catalog | — | `information_schema.tables` + table name detection | +| Nextcloud Share API | — | `newShare()`, `createShare()`, `getShareById()` | +| Nextcloud File API | — | `getUserFolder()`, `getFolder()`, `newFile()` | +| Nextcloud AppConfig | — | `getAppValue()`, `setAppValue()`, `getValueBool()` | +| Nextcloud Crypto | — | `encrypt()`, `decrypt()` via `ICrypto` | +| Nextcloud OCS API | — | OCS-Expect-Formatted / OCS-APIRequest headers | diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..6d54000 --- /dev/null +++ b/architecture.md @@ -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//`) + +### 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/ +├── / +│ ├── screenshot.png +│ └── diagram.jpg +└── / + └── 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 diff --git a/css/adminSettings.css b/css/adminSettings.css index e970af7..07d3104 100644 --- a/css/adminSettings.css +++ b/css/adminSettings.css @@ -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; diff --git a/lib/Controller/WebhookController.php b/lib/Controller/WebhookController.php index 0f04ec7..90b7c32 100644 --- a/lib/Controller/WebhookController.php +++ b/lib/Controller/WebhookController.php @@ -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, ); } diff --git a/lib/Service/TalkService.php b/lib/Service/TalkService.php index b39a838..2177bd4 100644 --- a/lib/Service/TalkService.php +++ b/lib/Service/TalkService.php @@ -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']); } diff --git a/templates/adminSettings.php b/templates/adminSettings.php index 0551301..a3c4743 100644 --- a/templates/adminSettings.php +++ b/templates/adminSettings.php @@ -1,43 +1,43 @@ -
-

Bot App Password

-
-
- - +
+

Nextcloud Bot Webhooks

+
+

Bot App Password

+
+
+ + +
+

+ Generate in Nextcloud Settings → talk-bot → Devices & sessions → Add device. + +

+

+ The bot user must be an admin to list all Talk rooms. Grant admin access in Settings → Users → [your-bot-user] → Admin. +

-

- Generate in Nextcloud Settings → talk-bot → Devices & sessions → Add device. - -

-

- The bot user must be an admin to list all Talk rooms. Grant admin access in Settings → Users → [your-bot-user] → Admin. -

-
-
-

Default Sender Name

-
- -

This name appears as the sender of webhook messages in Talk.

-
+
+

Default Sender Name

+
+ +

This name appears as the sender of webhook messages in Talk.

+
-
-

Image Retention

-
- -

Images older than this many days will be purged by the daily cron job. Default: 90 days.

-
+
+

Image Retention

+
+ +

Images older than this many days will be purged by the daily cron job. Default: 90 days.

+
-
-

Room Selection

- -
-

Check rooms to enable webhooks for. Each room gets its own auth token.

-
- -
- - +
+

Room Selection

+ +
+

Check rooms to enable webhooks for. Each room gets its own auth token.

+ + +