Skip to content

Commit 413b396

Browse files
committed
feat(talkbot): move TalkBot bookkeeping to dedicated ex_apps_talk_bots table
Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
1 parent 2f76f5e commit 413b396

8 files changed

Lines changed: 764 additions & 51 deletions

File tree

lib/Db/TalkBot.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Db;
11+
12+
use JsonSerializable;
13+
use OCP\AppFramework\Db\Entity;
14+
15+
/**
16+
* Class TalkBot
17+
*
18+
* @package OCA\AppAPI\Db
19+
*
20+
* @method string getAppid()
21+
* @method string getRoute()
22+
* @method string getSecret()
23+
* @method int getCreatedTime()
24+
* @method void setAppid(string $appid)
25+
* @method void setRoute(string $route)
26+
* @method void setSecret(string $secret)
27+
* @method void setCreatedTime(int $createdTime)
28+
*/
29+
class TalkBot extends Entity implements JsonSerializable {
30+
protected $appid;
31+
protected $route;
32+
protected $secret;
33+
protected $createdTime;
34+
35+
public function __construct() {
36+
$this->addType('appid', 'string');
37+
$this->addType('route', 'string');
38+
$this->addType('secret', 'string');
39+
$this->addType('createdTime', 'integer');
40+
}
41+
42+
public function jsonSerialize(): array {
43+
return [
44+
'id' => $this->getId(),
45+
'appid' => $this->getAppid(),
46+
'route' => $this->getRoute(),
47+
'created_time' => $this->getCreatedTime(),
48+
];
49+
}
50+
}

lib/Db/TalkBotMapper.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Db;
11+
12+
use OCP\AppFramework\Db\DoesNotExistException;
13+
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
14+
use OCP\AppFramework\Db\QBMapper;
15+
use OCP\DB\Exception;
16+
use OCP\DB\QueryBuilder\IQueryBuilder;
17+
use OCP\IDBConnection;
18+
19+
/**
20+
* @template-extends QBMapper<TalkBot>
21+
*/
22+
class TalkBotMapper extends QBMapper {
23+
public function __construct(IDBConnection $db) {
24+
parent::__construct($db, 'ex_apps_talk_bots');
25+
}
26+
27+
/**
28+
* @throws DoesNotExistException if not found
29+
* @throws MultipleObjectsReturnedException if more than one row matched (shouldn't, UNIQUE index)
30+
* @throws Exception
31+
*/
32+
public function findByAppidAndRoute(string $appId, string $route): TalkBot {
33+
$qb = $this->db->getQueryBuilder();
34+
$qb->select('*')
35+
->from($this->tableName)
36+
->where(
37+
$qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)),
38+
$qb->expr()->eq('route', $qb->createNamedParameter($route, IQueryBuilder::PARAM_STR)),
39+
);
40+
return $this->findEntity($qb);
41+
}
42+
43+
/**
44+
* @throws Exception
45+
*
46+
* @return TalkBot[]
47+
*/
48+
public function findAllByAppid(string $appId): array {
49+
$qb = $this->db->getQueryBuilder();
50+
$qb->select('*')
51+
->from($this->tableName)
52+
->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)));
53+
return $this->findEntities($qb);
54+
}
55+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\IDBConnection;
16+
use OCP\Migration\Attributes\CreateTable;
17+
use OCP\Migration\IOutput;
18+
use OCP\Migration\SimpleMigrationStep;
19+
use OCP\Security\ICrypto;
20+
use Throwable;
21+
22+
/**
23+
* Move TalkBot bookkeeping out of the generic appconfig_ex K/V store into a dedicated table.
24+
*
25+
* Backfill walks the two old appconfig_ex rows per bot — secret keyed by sha1(appid_route),
26+
* route indexed under 'talk_bot_route_' . sha1(appid_route) — and collapses them into one row.
27+
* The bot's human-readable name + description remain owned by spreed's talk_bots_server (Talk
28+
* set them via BotInstallEvent at registration time and remains the source of truth); AppAPI's
29+
* table holds only what AppAPI needs to authenticate and route inbound bot messages.
30+
*/
31+
#[CreateTable(table: 'ex_apps_talk_bots', columns: ['id', 'appid', 'route', 'secret', 'created_time'], description: 'TalkBot registrations owned by AppAPI')]
32+
class Version034000Date20260428144801 extends SimpleMigrationStep {
33+
34+
private const TALK_BOT_ROUTE_PREFIX = 'talk_bot_route_';
35+
36+
public function __construct(
37+
private IDBConnection $connection,
38+
private ICrypto $crypto,
39+
) {
40+
}
41+
42+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
43+
/** @var ISchemaWrapper $schema */
44+
$schema = $schemaClosure();
45+
46+
if (!$schema->hasTable('ex_apps_talk_bots')) {
47+
$table = $schema->createTable('ex_apps_talk_bots');
48+
49+
$table->addColumn('id', Types::BIGINT, [
50+
'notnull' => true,
51+
'autoincrement' => true,
52+
]);
53+
$table->addColumn('appid', Types::STRING, [
54+
'notnull' => true,
55+
'length' => 32,
56+
]);
57+
$table->addColumn('route', Types::STRING, [
58+
'notnull' => true,
59+
'length' => 128,
60+
]);
61+
// ICrypto envelope output is variable-length; TEXT keeps headroom across DB engines.
62+
$table->addColumn('secret', Types::TEXT, [
63+
'notnull' => true,
64+
]);
65+
// BIGINT for consistency with oc_ex_apps.created_time and to avoid the year-2038 truncation
66+
// that affects INT-typed Unix timestamps.
67+
$table->addColumn('created_time', Types::BIGINT, [
68+
'notnull' => true,
69+
'default' => 0,
70+
]);
71+
72+
$table->setPrimaryKey(['id']);
73+
$table->addUniqueIndex(['appid', 'route'], 'ex_apps_talk_bots__app_route');
74+
$table->addIndex(['appid'], 'ex_apps_talk_bots__appid');
75+
}
76+
77+
return $schema;
78+
}
79+
80+
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
81+
/** @var ISchemaWrapper $schema */
82+
$schema = $schemaClosure();
83+
if (!$schema->hasTable('appconfig_ex') || !$schema->hasTable('ex_apps_talk_bots')) {
84+
return null;
85+
}
86+
87+
// Materialize the cursor up front. Iterating a forward-only cursor while issuing DML
88+
// against the same table on the same connection is undefined across drivers, and we
89+
// also want each per-bot insert+deletes to commit (or roll back) as a single unit.
90+
$rows = $this->fetchRouteIndexRows();
91+
92+
$migrated = 0;
93+
$skipped = 0;
94+
95+
foreach ($rows as $row) {
96+
$appId = (string)$row['appid'];
97+
$route = (string)$row['configvalue'];
98+
$expectedHash = sha1($appId . '_' . $route);
99+
$keySuffix = substr((string)$row['configkey'], strlen(self::TALK_BOT_ROUTE_PREFIX));
100+
101+
if ($keySuffix !== $expectedHash) {
102+
$output->warning(sprintf(
103+
'TalkBot migration: malformed talk_bot_route row id=%d (appid=%s) — key suffix does not match sha1(appid_route), skipping',
104+
(int)$row['id'], $appId,
105+
));
106+
$skipped++;
107+
continue;
108+
}
109+
110+
$secretRow = $this->fetchSecretRow($appId, $expectedHash);
111+
if ($secretRow === null) {
112+
$output->warning(sprintf(
113+
'TalkBot migration: orphan talk_bot_route_%s for app %s (no matching secret row), skipping',
114+
$expectedHash, $appId,
115+
));
116+
$skipped++;
117+
continue;
118+
}
119+
120+
// Pre-migration TalkBotsService stored bot secrets via ExAppConfigService::setAppConfigValue()
121+
// WITHOUT passing $sensitive=true, so legacy rows are plaintext (sensitive=0). Honor the column
122+
// instead of unconditionally decrypting — Version032002Date20250527174907 retroactively encrypted
123+
// any sensitive=1 rows already, so if someone manually marked a bot secret sensitive after the fact
124+
// we still handle it correctly.
125+
$rawSecret = (string)$secretRow['configvalue'];
126+
if ((int)($secretRow['sensitive'] ?? 0) === 1 && $rawSecret !== '') {
127+
try {
128+
$plaintextSecret = $this->crypto->decrypt($rawSecret);
129+
} catch (Throwable $e) {
130+
$output->warning(sprintf(
131+
'TalkBot migration: failed to decrypt sensitive secret for app %s route %s: %s — skipping',
132+
$appId, $route, $e->getMessage(),
133+
));
134+
$skipped++;
135+
continue;
136+
}
137+
} else {
138+
$plaintextSecret = $rawSecret;
139+
}
140+
141+
$this->connection->beginTransaction();
142+
try {
143+
if (!$this->talkBotExists($appId, $route)) {
144+
$this->insertTalkBot($appId, $route, $this->crypto->encrypt($plaintextSecret));
145+
}
146+
$this->deleteAppconfigRow((int)$row['id']);
147+
$this->deleteAppconfigRow((int)$secretRow['id']);
148+
$this->connection->commit();
149+
$migrated++;
150+
} catch (Throwable $e) {
151+
try {
152+
$this->connection->rollBack();
153+
} catch (Throwable) {
154+
// rollBack failures on an already-aborted transaction are not actionable here.
155+
}
156+
$output->warning(sprintf(
157+
'TalkBot migration: per-bot transaction failed for app %s route %s: %s — old rows preserved, retry by re-running the migration',
158+
$appId, $route, $e->getMessage(),
159+
));
160+
$skipped++;
161+
}
162+
}
163+
164+
$output->info(sprintf('TalkBot migration: %d migrated, %d skipped', $migrated, $skipped));
165+
return null;
166+
}
167+
168+
/**
169+
* @return array<array{id:int,appid:string,configkey:string,configvalue:string}>
170+
*/
171+
private function fetchRouteIndexRows(): array {
172+
$qb = $this->connection->getQueryBuilder();
173+
$qb->select('id', 'appid', 'configkey', 'configvalue')
174+
->from('appconfig_ex')
175+
->where($qb->expr()->like('configkey', $qb->createNamedParameter(self::TALK_BOT_ROUTE_PREFIX . '%')));
176+
$cursor = $qb->executeQuery();
177+
$rows = $cursor->fetchAll();
178+
$cursor->closeCursor();
179+
return $rows;
180+
}
181+
182+
private function fetchSecretRow(string $appId, string $hash): ?array {
183+
$qb = $this->connection->getQueryBuilder();
184+
$qb->select('id', 'configvalue', 'sensitive')
185+
->from('appconfig_ex')
186+
->where(
187+
$qb->expr()->eq('appid', $qb->createNamedParameter($appId)),
188+
$qb->expr()->eq('configkey', $qb->createNamedParameter($hash)),
189+
)
190+
->setMaxResults(1);
191+
$res = $qb->executeQuery();
192+
$row = $res->fetch();
193+
$res->closeCursor();
194+
return $row === false ? null : $row;
195+
}
196+
197+
private function talkBotExists(string $appId, string $route): bool {
198+
$qb = $this->connection->getQueryBuilder();
199+
$qb->select('id')
200+
->from('ex_apps_talk_bots')
201+
->where(
202+
$qb->expr()->eq('appid', $qb->createNamedParameter($appId)),
203+
$qb->expr()->eq('route', $qb->createNamedParameter($route)),
204+
)
205+
->setMaxResults(1);
206+
$res = $qb->executeQuery();
207+
$exists = $res->fetch() !== false;
208+
$res->closeCursor();
209+
return $exists;
210+
}
211+
212+
private function insertTalkBot(string $appId, string $route, string $encryptedSecret): void {
213+
$qb = $this->connection->getQueryBuilder();
214+
$qb->insert('ex_apps_talk_bots')
215+
->values([
216+
'appid' => $qb->createNamedParameter($appId),
217+
'route' => $qb->createNamedParameter($route),
218+
'secret' => $qb->createNamedParameter($encryptedSecret),
219+
'created_time' => $qb->createNamedParameter(time(), Types::BIGINT),
220+
]);
221+
$qb->executeStatement();
222+
}
223+
224+
private function deleteAppconfigRow(int $id): void {
225+
$qb = $this->connection->getQueryBuilder();
226+
$qb->delete('appconfig_ex')
227+
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, Types::INTEGER)));
228+
$qb->executeStatement();
229+
}
230+
}

0 commit comments

Comments
 (0)