- 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.
14 KiB
Architecture
Overview of how NCbotwebhooks functions, its components, data flow, design decisions, and security model.
Table of Contents
- High-Level Overview
- Component Map
- Data Flow
- Design Decisions
- Security Model
- Storage Model
- Error Handling
- 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:
- Discord webhook-compatible — embeds, fields, images, username/avatar
- 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:
- Talk 14+ removed
getRoomForToken— the method was deprecated and removed in newer Talk versions - App passwords don't work with Talk's OCS API — the OCS endpoints require user sessions, not app passwords
- 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:
overwritehost(hostname override) — highest priority, explicit overrideoverwritewebroot— if it's a full URL; otherwise treated as a path and falls through- Non-loopback trusted domain — iterates
trusted_domains, skips127.0.0.1and private ranges (Docker compatibility) 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:
- Querying the Talk rooms table for the room ID from the room token
- Using
AttendeeMapper::findByActor()to find the bot's attendee record - Using the attendee's display name as
actorDisplayName - 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:
{
"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:
- The Chat API requires the
actorIdto be a participant in the room - The bot user is created separately and isn't automatically in any rooms
- 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:
- The password is non-empty
- The password doesn't contain characters that break the crypto layer
- 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
ICryptolayer - 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
IClientServicewhich includes built-in local address blocking (blocks127.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: trueis 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:
- Add a new route in
appinfo/routes.php - Add a new method to
WebhookControllerwith#[PublicPage]and#[NoCSRFRequired] - Implement payload parsing and validation in the controller
- Add a mapping method to
TalkService(e.g.,mapNewFormat()) - 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:
- Replace
uploadImage()to write to an external service (S3, etc.) - Update
buildRichObject()to generate URLs for the external service - Update
purgeOldImages()/purgeFolder()to clean up external storage - 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:
- Implement a middleware or decorator on
receive()/receiveApprise() - Use Nextcloud's rate limiting infrastructure (
IRateLimitManager) - Apply per-IP or per-room-token limits