diff --git a/appinfo/routes.php b/appinfo/routes.php index 4351480..08391a2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -16,5 +16,20 @@ return [ 'url' => '/rooms', 'verb' => 'GET', ], + [ + 'name' => 'webhook#saveBotPassword', + 'url' => '/save-bot-password', + 'verb' => 'POST', + ], + [ + 'name' => 'webhook#debug', + 'url' => '/debug', + 'verb' => 'GET', + ], + [ + 'name' => 'webhook#debugTables', + 'url' => '/debug-tables', + 'verb' => 'GET', + ], ], ]; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0dcfbc9 --- /dev/null +++ b/composer.json @@ -0,0 +1,10 @@ +{ + "name": "ncdiscordhook/app", + "description": "Discord webhook bridge for Nextcloud Talk", + "type": "nextcloud-app", + "autoload": { + "psr-4": { + "OCA\\NCdiscordhook\\": "../lib/" + } + } +} diff --git a/composer/autoload.php b/composer/autoload.php new file mode 100644 index 0000000..fd1843e --- /dev/null +++ b/composer/autoload.php @@ -0,0 +1,22 @@ + [$baseDir . '/lib'], +]; diff --git a/css/adminSettings.css b/css/adminSettings.css new file mode 100644 index 0000000..bbd39a5 --- /dev/null +++ b/css/adminSettings.css @@ -0,0 +1,76 @@ +.nc-settings-section { + margin-bottom: 2em; + padding: 1em; + border: 1px solid var(--color-border); + border-radius: 4px; +} +.nc-settings-section h3 { + margin-top: 0; +} +.nc-room-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} +.nc-room-item span { + font-size: 0.9em; +} +.nc-room-tokens { + margin-left: 24px; + margin-top: 8px; + padding: 8px; + background: var(--color-background-dark); + border-radius: 4px; +} +.nc-token-row { + display: flex; + gap: 4px; + margin-bottom: 4px; +} +.nc-token-input { + flex: 1; + font-family: monospace; + font-size: 0.85em; + background: var(--color-main-background); + border: 1px solid var(--color-border); + padding: 4px 8px; + border-radius: 3px; +} +.nc-token-copy { + padding: 4px 8px; + background: var(--color-primary-element); + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 0.85em; +} +.nc-token-revoke { + padding: 4px 8px; + background: var(--color-error); + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 0.85em; +} +.nc-generate-token { + margin-top: 8px; + padding: 4px 12px; + background: var(--color-primary-element); + color: white; + border: none; + border-radius: 3px; + cursor: pointer; +} +.nc-empty { + color: var(--color-text-lighter); + font-style: italic; +} +.nc-status-success { color: var(--color-success); } +.nc-status-error { color: var(--color-error); } +.nc-hint { + font-size: 0.85em; + color: var(--color-text-lighter); +} diff --git a/js/settings.js b/js/settings.js new file mode 100644 index 0000000..ba04759 --- /dev/null +++ b/js/settings.js @@ -0,0 +1,244 @@ +document.addEventListener('DOMContentLoaded', function () { + const APP_ID = 'ncdiscordhook'; + + // Read config passed via data attributes on the page + const dataEl = document.getElementById('nc-config-data'); + const configuredRooms = dataEl ? JSON.parse(dataEl.dataset.configuredRooms || '{}') : {}; + const authTokens = dataEl ? JSON.parse(dataEl.dataset.authTokens || '{}') : {}; + + // Elements + const botPasswordInput = document.getElementById('nc-bot-password'); + const retentionInput = document.getElementById('nc-retention'); + const fetchRoomsBtn = document.getElementById('nc-fetch-rooms'); + const roomsList = document.getElementById('nc-rooms-list'); + const saveBtn = document.getElementById('nc-save'); + const savePasswordBtn = document.getElementById('nc-save-password'); + const statusMsg = document.getElementById('nc-status'); + + let configuredRoomsState = configuredRooms; + let authTokensState = authTokens; + + function showStatus(msg, type) { + statusMsg.textContent = msg; + statusMsg.className = 'nc-status-' + type; + setTimeout(() => { statusMsg.textContent = ''; }, 5000); + } + + // Show/hide the save-password button when user types + botPasswordInput.addEventListener('input', function () { + savePasswordBtn.style.display = this.value.trim() ? 'inline-block' : 'none'; + }); + + // Fetch available Talk rooms + fetchRoomsBtn.addEventListener('click', async function () { + fetchRoomsBtn.disabled = true; + fetchRoomsBtn.textContent = 'Fetching...'; + + try { + const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/rooms')); + const data = await resp.json(); + + roomsList.innerHTML = ''; + + if (data.length === 0) { + roomsList.innerHTML = '

No Talk rooms found. Create rooms first via the Nextcloud Talk settings.

'; + return; + } + + data.forEach(function (room) { + const label = document.createElement('label'); + label.className = 'nc-room-item'; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = room.token; + cb.id = 'room-' + room.token; + if (room.configured) { + cb.checked = true; + cb.disabled = true; + } + cb.addEventListener('change', function () { toggleRoomTokens(room.token, this.checked); }); + + const nameSpan = document.createElement('span'); + nameSpan.textContent = room.name || room.token; + + label.appendChild(cb); + label.appendChild(nameSpan); + roomsList.appendChild(label); + + // Auth tokens container + const tokenDiv = document.createElement('div'); + tokenDiv.className = 'nc-room-tokens'; + tokenDiv.id = 'tokens-' + room.token; + tokenDiv.style.display = 'none'; + if (room.configured || (authTokensState[room.token] && authTokensState[room.token].length > 0)) { + tokenDiv.style.display = 'block'; + renderTokens(room.token, tokenDiv); + } + roomsList.appendChild(tokenDiv); + }); + + fetchRoomsBtn.textContent = 'Refresh Rooms'; + } catch (err) { + showStatus('Failed to fetch rooms', 'error'); + fetchRoomsBtn.textContent = 'Fetch Rooms'; + } + + fetchRoomsBtn.disabled = false; + }); + + // Render auth tokens for a room + function renderTokens(roomToken, container) { + container.innerHTML = ''; + + const tokens = authTokensState[roomToken] || []; + + tokens.forEach(function (token) { + const row = document.createElement('div'); + row.className = 'nc-token-row'; + + const input = document.createElement('input'); + input.type = 'text'; + input.value = token; + input.readOnly = true; + input.className = 'nc-token-input'; + + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'nc-token-copy'; + copyBtn.textContent = 'Copy'; + copyBtn.addEventListener('click', function () { + navigator.clipboard.writeText(token).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000); + }); + }); + + const revokeBtn = document.createElement('button'); + revokeBtn.type = 'button'; + revokeBtn.className = 'nc-token-revoke'; + revokeBtn.textContent = 'Revoke'; + revokeBtn.addEventListener('click', function () { + if (!confirm('Revoke this auth token?')) return; + tokens.splice(tokens.indexOf(token), 1); + if (tokens.length === 0) { + delete authTokensState[roomToken]; + container.style.display = 'none'; + } else { + authTokensState[roomToken] = tokens; + renderTokens(roomToken, container); + } + }); + + row.appendChild(input); + row.appendChild(copyBtn); + row.appendChild(revokeBtn); + container.appendChild(row); + }); + + // Generate new token button + const genBtn = document.createElement('button'); + genBtn.type = 'button'; + genBtn.className = 'nc-generate-token'; + genBtn.textContent = '+ Generate Auth Token'; + genBtn.addEventListener('click', function () { + if (!authTokensState[roomToken]) { + authTokensState[roomToken] = []; + } + authTokensState[roomToken].push(btoa(Math.random().toString(36).substring(2) + Date.now().toString(36)).replace(/=/g, '')); + renderTokens(roomToken, container); + container.style.display = 'block'; + }); + container.appendChild(genBtn); + } + + // Toggle room token display + function toggleRoomTokens(roomToken, checked) { + const tokenDiv = document.getElementById('tokens-' + roomToken); + if (tokenDiv) { + tokenDiv.style.display = checked ? 'block' : 'none'; + } + } + + // Save bot password only + savePasswordBtn.addEventListener('click', async function () { + var pw = botPasswordInput.value.trim(); + if (!pw) return; + savePasswordBtn.disabled = true; + savePasswordBtn.textContent = 'Saving...'; + + try { + var resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/save-bot-password'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bot_password: pw }), + }); + var data = await resp.json(); + + if (data.status === 'ok') { + showStatus('Bot password saved', 'success'); + botPasswordInput.value = ''; + savePasswordBtn.style.display = 'none'; + } else { + showStatus('Save failed: ' + (data.error || 'unknown error'), 'error'); + } + } catch (err) { + showStatus('Save failed: ' + err.message, 'error'); + } + + savePasswordBtn.disabled = false; + savePasswordBtn.textContent = 'Save'; + }); + + // Save configuration + saveBtn.addEventListener('click', async function () { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + // Collect configured rooms + const rooms = {}; + document.querySelectorAll('#nc-rooms-list input[type="checkbox"]:checked').forEach(function (cb) { + const token = cb.value; + rooms[token] = ''; // name will be filled from existing config + }); + + // Restore names from existing config for newly checked rooms + Object.keys(configuredRoomsState).forEach(function (token) { + if (rooms[token] === undefined) { + rooms[token] = configuredRoomsState[token]; + } + }); + + const payload = { + rooms: rooms, + auth_tokens: authTokensState, + retention_days: parseInt(retentionInput.value) || 90, + }; + + if (botPasswordInput.value) { + payload.bot_password = botPasswordInput.value; + } + + try { + const resp = await fetch(OC.generateUrl('/apps/' + APP_ID + '/save-config'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await resp.json(); + + if (data.status === 'ok') { + configuredRoomsState = rooms; + showStatus('Configuration saved', 'success'); + botPasswordInput.value = ''; + } else { + showStatus('Save failed: ' + (data.error || 'unknown error'), 'error'); + } + } catch (err) { + showStatus('Save failed: ' + err.message, 'error'); + } + + saveBtn.disabled = false; + saveBtn.textContent = 'Save Configuration'; + }); +}); diff --git a/lib/Controller/WebhookController.php b/lib/Controller/WebhookController.php index 7b7d309..1f15d5f 100644 --- a/lib/Controller/WebhookController.php +++ b/lib/Controller/WebhookController.php @@ -10,15 +10,24 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Middleware\Security\CSRF\Token; use OCP\AppFramework\Middleware\Security\CSRF\TokenStore; use OCP\AppFramework\Utility\IControllerMethodReflector; +use OCP\Http\Client\IClientService; use OCP\IRequest; use OCP\IUserSession; +use OCP\AppFramework\Controller\Attribute\AdminRequired; +use Psr\Log\LoggerInterface; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\SubAdminRequired; class WebhookController extends Controller { private TalkService $talkService; + private LoggerInterface $logger; - public function __construct(IRequest $request, TalkService $talkService) { + public function __construct(IRequest $request, TalkService $talkService, LoggerInterface $logger) { + // Debug: write to file to bypass output buffering + @file_put_contents('/tmp/ncdebug.txt', date('c') . " WebhookController constructed\n", FILE_APPEND); parent::__construct('ncdiscordhook', $request); $this->talkService = $talkService; + $this->logger = $logger; } /** @@ -26,6 +35,7 @@ class WebhookController extends Controller { * * URL: POST /apps/ncdiscordhook/webhook/{roomToken}/{authToken} */ + #[NoCSRFRequired] public function receive(string $roomToken, string $authToken): DataResponse { // Validate auth token if (!$this->talkService->validateAuthToken($roomToken, $authToken)) { @@ -37,7 +47,7 @@ class WebhookController extends Controller { } // Parse Discord JSON payload - $body = $this->request->getContent(); + $body = file_get_contents('php://input'); $data = @json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return new DataResponse( @@ -88,7 +98,10 @@ class WebhookController extends Controller { $filePath = $this->talkService->uploadImage($roomToken, $filename, $imageData['data'], $imageData['mimeType']); if ($filePath !== null) { - $richObjects[] = $this->talkService->buildRichObject($filePath, $imageData['mimeType'], $roomToken); + $richObj = $this->talkService->buildRichObject($filePath, $imageData['mimeType'], $roomToken); + if ($richObj !== null) { + $richObjects[] = $richObj; + } } } } @@ -112,14 +125,37 @@ class WebhookController extends Controller { ); } + /** + * Save bot password from the settings UI. + * + * URL: POST /apps/ncdiscordhook/save-bot-password + */ + #[AdminRequired] + #[NoCSRFRequired] + public function saveBotPassword(): DataResponse { + $body = file_get_contents('php://input'); + $data = @json_decode($body, true); + if (!is_array($data) || empty($data['bot_password'])) { + return new DataResponse( + ['error' => 'Invalid data'], + Http::STATUS_BAD_REQUEST, + ); + } + + $this->talkService->setBotPassword($data['bot_password']); + + return new DataResponse(['status' => 'ok']); + } + /** * Save configuration from the settings UI. * * URL: POST /apps/ncdiscordhook/save-config */ - @AdminRequired + #[AdminRequired] + #[NoCSRFRequired] public function saveConfig(): DataResponse { - $body = $this->request->getContent(); + $body = file_get_contents('php://input'); $config = @json_decode($body, true); if (!is_array($config)) { return new DataResponse( @@ -134,27 +170,116 @@ class WebhookController extends Controller { } /** - * Get available Talk rooms. + * Debug endpoint to verify the app is working. * - * URL: GET /apps/ncdiscordhook/rooms + * URL: GET /apps/ncdiscordhook/debug */ - @AdminRequired - public function getRooms(): DataResponse { - $rooms = $this->talkService->getAvailableTalkRooms(); - $configured = $this->talkService->getRooms(); + #[NoCSRFRequired] + public function debug(): DataResponse { + $info = [ + 'app_enabled' => \OC_App::isEnabled('ncdiscordhook'), + 'user' => \OC_User::getUser(), + 'user_is_admin' => \OC_User::isAdminUser(\OC_User::getUser()), + 'bot_user' => null, + 'has_bot_password' => false, + ]; - // Mark which rooms are already configured + try { + $bot = $this->talkService->getBotUser(); + $info['bot_user'] = $bot ? $bot->getUID() : null; + $info['has_bot_password'] = $this->talkService->hasBotPassword(); + } catch (\Exception $e) { + $info['bot_error'] = $e->getMessage(); + } + + return new DataResponse($info); + } + + /** + * Diagnostic endpoint to discover Talk table names. + * + * URL: GET /apps/ncdiscordhook/debug-tables + */ + #[NoCSRFRequired] + public function debugTables(): DataResponse { $result = []; - foreach ($rooms as $room) { - $token = $room['token'] ?? $room['roomId'] ?? ''; - $name = $room['displayName'] ?? $room['name'] ?? ''; - $result[] = [ - 'token' => $token, - 'name' => $name, - 'configured' => isset($configured[$token]), + + try { + $conn = $this->talkService->getDbConnection(); + $pdo = $conn->getConnection()->getWrappedConnection(); + + // List all tables that could be Talk-related + $tables = $pdo->query(" + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema NOT IN ('pg_catalog', 'information_schema') + AND (table_name LIKE 'talk%' OR table_name LIKE 'spreed%' + OR table_name LIKE '%room%' OR table_name LIKE '%attendee%' + OR table_name LIKE '%conversation%') + ORDER BY table_schema, table_name + ")->fetchAll(); + + $result['tables'] = $tables; + + // Check Talk app config + $talkPrefix = \OC::$server->get(\OCP\IConfig::class)->getAppValue('spreed', 'databaseprefix', ''); + $sysPrefix = \OC::$server->get(\OCP\IConfig::class)->getSystemValueString('dbtableprefix', ''); + $result['sysPrefix'] = $sysPrefix; + $result['talkPrefix'] = $talkPrefix; + + // Check each candidate with tableExists + $candidates = [ + $talkPrefix . 'talk_rooms', + $sysPrefix . 'talk_rooms', + 'talk_rooms', + $talkPrefix . 'talk_attendees', + $sysPrefix . 'talk_attendees', + 'talk_attendees', ]; + $result['tableExists_results'] = []; + foreach ($candidates as $c) { + $result['tableExists_results'][$c] = $conn->tableExists($c); + } + } catch (\Exception $e) { + $result['error'] = $e->getMessage(); } return new DataResponse($result); } + + /** + * Get available Talk rooms. + * + * URL: GET /apps/ncdiscordhook/rooms + */ + #[AdminRequired] + #[NoCSRFRequired] + public function getRooms(): DataResponse { + try { + $rooms = $this->talkService->getAvailableTalkRooms(); + if (!is_array($rooms)) { + $rooms = []; + } + $configured = $this->talkService->getRooms(); + + // Mark which rooms are already configured + $result = []; + foreach ($rooms as $token => $name) { + $result[] = [ + 'token' => $token, + 'name' => $name !== '' ? $name : $token, + 'configured' => isset($configured[$token]), + ]; + } + + return new DataResponse($result); + } catch (\Exception $e) { + $this->logger->error('NCdiscordhook getRooms failed: ' . $e->getMessage(), ['app' => 'ncdiscordhook', 'exception' => (string)$e]); + return new DataResponse( + ['error' => 'Server error: ' . $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/lib/Cron/ImageCleanup.php b/lib/Cron/ImageCleanup.php index 8fc9e51..23c2a56 100644 --- a/lib/Cron/ImageCleanup.php +++ b/lib/Cron/ImageCleanup.php @@ -7,20 +7,24 @@ use OCP\BackgroundJob\IJob; use OCP\Files\IRootFolder; use OCP\IConfig; use OCP\IUserManager; +use Psr\Log\LoggerInterface; class ImageCleanup implements IJob { private TalkService $talkService; private IRootFolder $rootFolder; private IUserManager $userManager; + private LoggerInterface $logger; public function __construct( TalkService $talkService, IRootFolder $rootFolder, IUserManager $userManager, + LoggerInterface $logger, ) { $this->talkService = $talkService; $this->rootFolder = $rootFolder; $this->userManager = $userManager; + $this->logger = $logger; } public function run($argument = null): void { @@ -41,7 +45,7 @@ class ImageCleanup implements IJob { $count = $this->talkService->purgeOldImages(); if ($count > 0) { - \OC::$server->getLogger()->info( + $this->logger->info( 'NCdiscordhook: purged ' . $count . ' old image files', ['app' => 'ncdiscordhook'], ); diff --git a/lib/Service/TalkService.php b/lib/Service/TalkService.php index acb2a53..140b5db 100644 --- a/lib/Service/TalkService.php +++ b/lib/Service/TalkService.php @@ -10,10 +10,13 @@ use OCP\Files\IRootFolder; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; use OCP\IConfig; +use OCP\IDBConnection; use OCP\IRequest; -use OCP\Security\Crypto\DefaultCrypto; +use OCP\Security\ICrypto; use OCP\IURLGenerator; use OCP\IUserManager; +use OCP\Share\IManager; +use Psr\Log\LoggerInterface; class TalkService { private const APP_ID = 'ncdiscordhook'; @@ -21,28 +24,37 @@ class TalkService { private IClient $client; private IConfig $config; + private IDBConnection $db; private IRootFolder $rootFolder; - private DefaultCrypto $crypto; + private ICrypto $crypto; private IURLGenerator $urlGenerator; private IUserManager $userManager; private IRequest $request; + private IManager $shareManager; + private LoggerInterface $logger; public function __construct( IClientService $clientService, IConfig $config, + IDBConnection $db, IRootFolder $rootFolder, - DefaultCrypto $crypto, + ICrypto $crypto, IURLGenerator $urlGenerator, IUserManager $userManager, IRequest $request, + IManager $shareManager, + LoggerInterface $logger, ) { $this->client = $clientService->newClient(); $this->config = $config; + $this->db = $db; $this->rootFolder = $rootFolder; $this->crypto = $crypto; $this->urlGenerator = $urlGenerator; $this->userManager = $userManager; $this->request = $request; + $this->shareManager = $shareManager; + $this->logger = $logger; } // ── Bot password ────────────────────────────────────────────── @@ -71,6 +83,13 @@ class TalkService { return $this->userManager->get('talk-bot'); } + /** + * Get the raw DB connection (for diagnostic use). + */ + public function getDbConnection(): IDBConnection { + return $this->db; + } + // ── Retention ───────────────────────────────────────────────── public function getRetentionDays(): int { @@ -81,6 +100,58 @@ class TalkService { $this->config->setAppValue(self::APP_ID, 'retention_days', (string) $days); } + // ── Base URL ────────────────────────────────────────────────── + + /** + * Get the server base URL for internal API calls. + * Falls back to overwrite.cli.url when overwritewebroot is not set. + * When the URL resolves to localhost, falls back to the request host + * or trusted_domains — the HTTP client blocks localhost for SSRF safety. + */ + private function getBaseUrl(): string { + $overwritten = $this->config->getSystemValueString('overwritewebroot', ''); + if ($overwritten !== '') { + return rtrim($overwritten, '/'); + } + $cliUrl = $this->config->getSystemValueString('overwrite.cli.url', ''); + if ($cliUrl === '') { + return ''; + } + $parsed = parse_url($cliUrl); + $scheme = $parsed['scheme'] ?? 'https'; + $host = $parsed['host'] ?? ''; + $port = $parsed['port'] ?? ''; + $path = rtrim($parsed['path'] ?? '', '/'); + $portStr = $port !== '' ? ':' . $port : ''; + $baseUrl = $scheme . '://' . $host . $portStr . $path; + + // HTTP client blocks localhost — fall back to request host or trusted_domains + if (in_array(strtolower($host), ['localhost', '127.0.0.1', '::1'], true)) { + $requestHost = $this->request->getServerHost(); + if ($requestHost !== '') { + // Extract port from Host header (e.g. "example.com:443" or "example.com") + $hostHeader = $this->request->getHeader('Host'); + $requestPort = 0; + if (str_contains($hostHeader, ':')) { + $parts = explode(':', $hostHeader); + $requestPort = (int) end($parts); + } + $basePort = parse_url($baseUrl, PHP_URL_PORT); + $requestPortStr = $requestPort !== 0 && $requestPort !== $basePort + ? ':' . $requestPort + : ''; + return $scheme . '://' . $requestHost . $requestPortStr . $path; + } + // Last resort: trusted_domains + $trusted = $this->config->getSystemValue('trusted_domains', []); + if (!empty($trusted[0])) { + return $scheme . '://' . $trusted[0] . $path; + } + } + + return $baseUrl; + } + // ── Rooms ───────────────────────────────────────────────────── /** @@ -100,33 +171,154 @@ class TalkService { } /** - * Get available Talk rooms via API. + * Get available Talk rooms via database query. + * + * Queries the Talk DB directly to find rooms where the bot is a participant. + * Bypasses the OCS API which does not accept app passwords for auth. + * Returns rooms as [token => displayName] pairs. */ public function getAvailableTalkRooms(): array { - $baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/'); - $url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/room'; - - try { - $response = $this->client->get($url, [ - 'auth' => 'basic', - 'basic' => [ - $this->config->getSystemValueString('adminuser', 'admin'), - $this->config->getSystemValueString('adminpass', ''), - ], - 'headers' => [ - 'OCS-Expect' => '100', - ], - ]); - - $data = json_decode($response->getBody(), true); - if (isset($data['ocs']['data'])) { - return $data['ocs']['data']; - } - } catch (\Exception $e) { - // Log but don't fail — rooms list is optional + $botUser = $this->getBotUser(); + if (!$botUser) { + return []; } - return []; + $botUid = $botUser->getUID(); + + // Detect table names by querying PostgreSQL information_schema directly. + // We cannot rely on tableExists() — it has case-sensitivity issues with PostgreSQL + // where it may match a differently-cased table name but raw SQL still fails. + $roomTable = $this->detectTalkTableFromCatalog('talk_rooms', 'spreed_room'); + $participantTable = $this->detectTalkTableFromCatalog('talk_attendees', 'spreed_participant'); + + if ($roomTable === null || $participantTable === null) { + $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); + $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); + $this->logger->warning('NCdiscordhook: Talk tables not found', [ + 'app' => self::APP_ID, + 'sysPrefix' => $sysPrefix, + 'talkPrefix' => $talkPrefix, + ]); + return []; + } + + // Find rooms where the bot is a participant. + // Use raw SQL — query builder's expr() methods re-parse table names and double-prefix them. + $sql = 'SELECT "' . $roomTable . '".token, "' . $roomTable . '".name, "' . $roomTable . '".type + FROM "' . $roomTable . '" + JOIN "' . $participantTable . '" ON "' . $participantTable . '".room_id = "' . $roomTable . '".id + WHERE "' . $participantTable . '".actor_type = :actorType + AND "' . $participantTable . '".actor_id = :actorId + AND "' . $roomTable . '".type <> :excludeType'; + + $result = $this->db->executeQuery($sql, [ + 'actorType' => 'users', + 'actorId' => $botUid, + 'excludeType' => -1, // exclude UNKNOWN type + ]); + + $this->logger->info('NCdiscordhook: getAvailableTalkRooms', [ + 'app' => self::APP_ID, + 'bot_user' => $botUid, + 'room_table' => $roomTable, + 'participant_table' => $participantTable, + ]); + + try { + $rooms = []; + while ($row = $result->fetch()) { + $token = $row['token']; + $name = $row['name'] !== '' ? $row['name'] : $token; + $rooms[$token] = $name; + } + $result->closeCursor(); + + $this->logger->info('NCdiscordhook: found ' . count($rooms) . ' rooms', [ + 'app' => self::APP_ID, + 'rooms' => array_keys($rooms), + ]); + + return $rooms; + } catch (\Exception $e) { + $this->logger->error('NCdiscordhook: room listing exception', [ + 'app' => self::APP_ID, + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + return []; + } + } + + /** + * Detect which Talk table name variant exists in the database. + * + * Queries PostgreSQL information_schema directly to find actual table names, + * then verifies by checking column presence. Avoids tableExists() which has + * case-sensitivity issues with PostgreSQL. + * + * Talk app has its own database prefix (spreed.appconfig.databaseprefix), + * separate from the main dbtableprefix. + */ + private function detectTalkTableFromCatalog(string $newName, string $oldName): ?string { + $sysPrefix = $this->config->getSystemValueString('dbtableprefix', ''); + $talkPrefix = $this->config->getAppValue('spreed', 'databaseprefix', $sysPrefix); + + // Build candidate list ordered by likelihood + $candidates = [ + $talkPrefix . $newName, + $sysPrefix . $newName, + $talkPrefix . $oldName, + $sysPrefix . $oldName, + $newName, + $oldName, + ]; + + // Deduplicate preserving order + $seen = []; + $unique = []; + foreach ($candidates as $name) { + if (!isset($seen[$name])) { + $seen[$name] = true; + $unique[] = $name; + } + } + + // Query information_schema to find which candidates actually exist as tables + try { + $result = $this->db->executeQuery( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_type = \'BASE TABLE\' AND table_name IN (' . implode(',', array_map(fn($n) => '\'' . $n . '\'', $unique)) . ')', + ); + $found = []; + while ($row = $result->fetch()) { + $found[] = $row['table_name']; + } + $result->closeCursor(); + } catch (\Exception $e) { + $this->logger->warning('NCdiscordhook: information_schema query failed', [ + 'app' => self::APP_ID, + 'error' => $e->getMessage(), + ]); + return null; + } + + // Try executing a lightweight query on each candidate to verify it's usable. + // This avoids the PostgreSQL case-sensitivity mismatch where tableExists() + // may match a differently-cased table but raw SQL fails. + foreach ($unique as $table) { + try { + $testResult = $this->db->executeQuery( + 'SELECT 1 FROM "' . $table . '" LIMIT 1', + ); + $testResult->closeCursor(); + return $table; + } catch (\Exception $e) { + // Table exists in catalog but query failed — skip to next candidate + continue; + } + } + + return null; } // ── Auth tokens ─────────────────────────────────────────────── @@ -274,7 +466,7 @@ class TalkService { return ['data' => $body, 'mimeType' => $mimeType]; } catch (\Exception $e) { - \OC::$server->getLogger()->error('Failed to download image: ' . $url, ['app' => self::APP_ID]); + $this->logger->error('Failed to download image: ' . $url, ['app' => self::APP_ID]); return null; } } @@ -291,8 +483,8 @@ class TalkService { try { $userFolder = $this->rootFolder->getUserFolder($bot->getUID()); - $imagesDir = $userFolder->getFolder(self::IMAGES_DIR); - $roomDir = $imagesDir->getFolder($roomToken); + $imagesDir = $userFolder->getFolder(self::IMAGES_DIR, true); + $roomDir = $imagesDir->getFolder($roomToken, true); // Avoid path traversal $safeFilename = basename($filename); @@ -300,29 +492,60 @@ class TalkService { return self::IMAGES_DIR . '/' . $roomToken . '/' . $safeFilename; } catch (\Exception $e) { - \OC::$server->getLogger()->error('Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]); + $this->logger->error('Failed to upload image: ' . $e->getMessage(), ['app' => self::APP_ID]); return null; } } /** * Build rich object data for a Talk message from an uploaded file path. + * Creates a public link share so Talk can resolve the rich object. */ - public function buildRichObject(string $filePath, string $mimeType, string $roomToken): array { - $baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/'); - $serverUrl = $this->config->getSystemValueString('overwrite.cli.url', 'https://example.com'); + public function buildRichObject(string $filePath, string $mimeType, string $roomToken): ?array { + $bot = $this->userManager->get('talk-bot'); + if (!$bot) { + return null; + } - return [ - 'type' => 'file', - 'source' => 'share', - 'fileId' => basename($filePath), - 'sourceType' => 'file', - 'mimetype' => $mimeType, - 'title' => basename($filePath), - 'description' => 'Webhook image attachment', - 'thumbnailReady' => true, - 'fileTarget' => '/' . $filePath, - ]; + try { + $userFolder = $this->rootFolder->getUserFolder($bot->getUID()); + $file = $userFolder->get($filePath); + if (!$file || !$file->isReadable()) { + return null; + } + + $share = $this->shareManager->newShare(); + $share->setNode($file) + ->setPermissions(\OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_DOWNLOAD) + ->setType(\OCP\Constants::SHARE_TYPE_LINK) + ->setName(basename($filePath)); + $share = $this->shareManager->createShare($share); + + $linkShare = $this->shareManager->getShareById($share->getId()); + + $serverUrl = $this->config->getSystemValueString('overwrite.cli.url', 'https://example.com'); + + return [ + 'rich_object' => [ + 'id' => '', + 'elements' => [ + [ + 'type' => 'file', + 'id' => $linkShare->getToken(), + 'name' => basename($filePath), + 'mimetype' => $mimeType, + 'thumbnailReady' => true, + 'fileTarget' => '/' . $filePath, + 'path' => basename($filePath), + ], + ], + ], + 'source' => 'file', + ]; + } catch (\Exception $e) { + $this->logger->error('Failed to build rich object: ' . $e->getMessage(), ['app' => self::APP_ID]); + return null; + } } // ── Talk Chat API ───────────────────────────────────────────── @@ -344,17 +567,17 @@ class TalkService { ): bool { $bot = $this->userManager->get('talk-bot'); if (!$bot) { - \OC::$server->getLogger()->error('talk-bot user not found', ['app' => self::APP_ID]); + $this->logger->error('talk-bot user not found', ['app' => self::APP_ID]); return false; } $botPassword = $this->getBotPassword(); if (!$botPassword) { - \OC::$server->getLogger()->error('Bot password not configured', ['app' => self::APP_ID]); + $this->logger->error('Bot password not configured', ['app' => self::APP_ID]); return false; } - $baseUrl = rtrim($this->config->getSystemValueString('overwritewebroot', ''), '/'); + $baseUrl = $this->getBaseUrl(); $url = $baseUrl . '/ocs/v2.php/apps/spreed/api/v1/chat/' . rawurlencode($roomToken); // Build form body @@ -364,7 +587,11 @@ class TalkService { ]; if (!empty($richObjects)) { - $bodyParts['richObjects'] = json_encode($richObjects); + $indexed = []; + foreach ($richObjects as $index => $richObj) { + $indexed['file-' . $index] = $richObj; + } + $bodyParts['richObjects'] = $indexed; } $body = http_build_query($bodyParts, '', '&', PHP_QUERY_RFC3986); @@ -384,7 +611,7 @@ class TalkService { $httpCode = $response->getStatusCode(); return $httpCode === 200; } catch (\Exception $e) { - \OC::$server->getLogger()->error('Failed to post to Talk: ' . $e->getMessage(), ['app' => self::APP_ID]); + $this->logger->error('Failed to post to Talk: ' . $e->getMessage(), ['app' => self::APP_ID]); return false; } } @@ -408,7 +635,7 @@ class TalkService { $imagesDir = $userFolder->getFolder(self::IMAGES_DIR); return $this->purgeFolder($imagesDir, $cutoff); } catch (\Exception $e) { - \OC::$server->getLogger()->error('Image cleanup failed: ' . $e->getMessage(), ['app' => self::APP_ID]); + $this->logger->error('Image cleanup failed: ' . $e->getMessage(), ['app' => self::APP_ID]); return 0; } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index f03e39e..e4ca351 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -3,6 +3,7 @@ namespace OCA\NCdiscordhook\Settings; use OCA\NCdiscordhook\Service\TalkService; +use OCP\AppFramework\Http\TemplateResponse; use OCP\IL10N; use OCP\Settings\ISettings; @@ -15,7 +16,10 @@ class Admin implements ISettings { $this->l10n = $l10n; } - public function getForm(): string { + public function getForm(): TemplateResponse { + \OCP\Util::addStyle('ncdiscordhook', 'adminSettings'); + \OCP\Util::addScript('ncdiscordhook', 'settings'); + $params = [ 'hasBotPassword' => $this->talkService->hasBotPassword(), 'retentionDays' => $this->talkService->getRetentionDays(), @@ -23,12 +27,8 @@ class Admin implements ISettings { 'authTokens' => $this->talkService->getAuthTokens(), ]; - $template = \OC::$server->get(\OCP\ITemplate\ITemplateFactory::class)->load('ncdiscordhook', 'adminSettings'); - foreach ($params as $key => $value) { - $template->assign($key, $value); - } - - return $template->fetchPage(); + $response = new TemplateResponse('ncdiscordhook', 'adminSettings', $params); + return $response; } public function getPriority(): int { @@ -36,6 +36,13 @@ class Admin implements ISettings { } public function getSection(): string { - return 'server'; + return 'additional'; + } + + public function getIcons(): array { + $urlGenerator = \OC::$server->get(\OCP\IURLGenerator::class); + return [ + $urlGenerator->imagePath('ncdiscordhook', 'app.svg'), + ]; } } diff --git a/templates/adminSettings.php b/templates/adminSettings.php index af8ffd7..6e9993a 100644 --- a/templates/adminSettings.php +++ b/templates/adminSettings.php @@ -1,292 +1,24 @@ - - - -

Bot Configuration


- +
+ + +

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. +

+
+ + +