diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 2d46c9f6..90f596ee 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -57,6 +57,7 @@ use Utopia\Migration\Resources\Messaging\Subscriber; use Utopia\Migration\Resources\Messaging\Topic; use Utopia\Migration\Resources\Settings\ProjectVariable; +use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment; use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar; use Utopia\Migration\Resources\Sites\Site; @@ -281,6 +282,7 @@ public static function getSupportedResources(): array // Settings Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3101,6 +3103,10 @@ public function importSettingsResource(Resource $resource): Resource /** @var ProjectVariable $resource */ $this->createProjectVariable($resource); break; + case Resource::TYPE_WEBHOOK: + /** @var Webhook $resource */ + $this->createWebhook($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3149,6 +3155,50 @@ protected function createProjectVariable(ProjectVariable $resource): bool return true; } + protected function createWebhook(Webhook $resource): bool + { + $existing = $this->dbForPlatform->findOne('webhooks', [ + Query::equal('projectInternalId', [$this->projectInternalId]), + Query::equal('name', [$resource->getWebhookName()]), + ]); + + if ($existing !== false && !$existing->isEmpty()) { + $resource->setStatus(Resource::STATUS_SKIPPED, 'Webhook already exists'); + return false; + } + + $createdAt = $this->normalizeDateTime($resource->getCreatedAt()); + $updatedAt = $this->normalizeDateTime($resource->getUpdatedAt(), $createdAt); + + try { + $this->dbForPlatform->createDocument('webhooks', new UtopiaDocument([ + '$id' => ID::unique(), + '$permissions' => $resource->getPermissions(), + 'projectInternalId' => $this->projectInternalId, + 'projectId' => $this->project, + 'name' => $resource->getWebhookName(), + 'events' => $resource->getEvents(), + 'url' => $resource->getUrl(), + 'security' => $resource->getSecurity(), + 'httpUser' => $resource->getHttpUser(), + 'httpPass' => $resource->getHttpPass(), + // SDK only returns the signing secret on creation, never on list — regenerate + // a fresh one on the destination to match upstream createWebhook behavior. + 'signatureKey' => \bin2hex(\random_bytes(64)), + 'enabled' => $resource->isEnabled(), + '$createdAt' => $createdAt, + '$updatedAt' => $updatedAt, + ])); + } catch (DuplicateException) { + $resource->setStatus(Resource::STATUS_SKIPPED, 'Webhook already exists'); + return false; + } + + $this->dbForPlatform->purgeCachedDocument('projects', $this->project); + + return true; + } + /** * @throws \Throwable */ diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 6dd1df78..9dc936ac 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -77,6 +77,7 @@ abstract class Resource implements \JsonSerializable // Settings public const TYPE_PROJECT_VARIABLE = 'project-variable'; + public const TYPE_WEBHOOK = 'webhook'; // Messaging public const TYPE_SUBSCRIBER = 'subscriber'; @@ -119,6 +120,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_PLATFORM, self::TYPE_API_KEY, self::TYPE_PROJECT_VARIABLE, + self::TYPE_WEBHOOK, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Settings/Webhook.php b/src/Migration/Resources/Settings/Webhook.php new file mode 100644 index 00000000..20ba47f6 --- /dev/null +++ b/src/Migration/Resources/Settings/Webhook.php @@ -0,0 +1,116 @@ + $events + */ + public function __construct( + string $id, + private readonly string $name, + private readonly string $url, + private readonly array $events = [], + private readonly bool $security = false, + private readonly string $httpUser = '', + private readonly string $httpPass = '', + private readonly bool $enabled = true, + string $createdAt = '', + string $updatedAt = '', + ) { + $this->id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + * @return self + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['name'], + $array['url'], + $array['events'] ?? [], + (bool) ($array['security'] ?? false), + $array['httpUser'] ?? '', + $array['httpPass'] ?? '', + (bool) ($array['enabled'] ?? true), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'url' => $this->url, + 'events' => $this->events, + 'security' => $this->security, + 'httpUser' => $this->httpUser, + 'httpPass' => $this->httpPass, + 'enabled' => $this->enabled, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_WEBHOOK; + } + + public function getGroup(): string + { + return Transfer::GROUP_SETTINGS; + } + + public function getWebhookName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + /** + * @return array + */ + public function getEvents(): array + { + return $this->events; + } + + public function getSecurity(): bool + { + return $this->security; + } + + public function getHttpUser(): string + { + return $this->httpUser; + } + + public function getHttpPass(): string + { + return $this->httpPass; + } + + public function isEnabled(): bool + { + return $this->enabled; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 17be781f..fd03b7d0 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -13,6 +13,7 @@ use Appwrite\Services\TablesDB; use Appwrite\Services\Teams; use Appwrite\Services\Users; +use Appwrite\Services\Webhooks; use Utopia\Database\Database as UtopiaDatabase; use Utopia\Database\DateTime as UtopiaDateTime; use Utopia\Database\Document as UtopiaDocument; @@ -62,6 +63,7 @@ use Utopia\Migration\Resources\Messaging\Subscriber; use Utopia\Migration\Resources\Messaging\Topic; use Utopia\Migration\Resources\Settings\ProjectVariable; +use Utopia\Migration\Resources\Settings\Webhook; use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment; use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar; use Utopia\Migration\Resources\Sites\Site; @@ -98,6 +100,8 @@ class Appwrite extends Source private Project $project; + private Webhooks $webhooks; + /** * @var callable(UtopiaDocument $database|null): UtopiaDatabase */ @@ -127,6 +131,7 @@ public function __construct( $this->messaging = new Messaging($this->client); $this->sites = new Sites($this->client); $this->project = new Project($this->client); + $this->webhooks = new Webhooks($this->client); $this->headers['x-appwrite-project'] = $this->projectId; $this->headers['x-appwrite-key'] = $this->key; @@ -216,6 +221,7 @@ public static function getSupportedResources(): array // Settings Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, ]; } @@ -1466,6 +1472,19 @@ private function reportSettings(array $resources, array &$report, array $resourc $report[Resource::TYPE_PROJECT_VARIABLE] = 0; } } + + if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { + $webhookQueries = $this->buildQueries( + resourceType: Resource::TYPE_WEBHOOK, + resourceIds: $resourceIds, + limit: 1 + ); + try { + $report[Resource::TYPE_WEBHOOK] = $this->webhooks->list($webhookQueries)->total; + } catch (\Throwable) { + $report[Resource::TYPE_WEBHOOK] = 0; + } + } } /** @@ -1487,6 +1506,20 @@ protected function exportGroupSettings(int $batchSize, array $resources): void )); } } + + if (\in_array(Resource::TYPE_WEBHOOK, $resources)) { + try { + $this->exportWebhooks($batchSize); + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_WEBHOOK, + Transfer::GROUP_SETTINGS, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } } /** @@ -1536,6 +1569,57 @@ private function exportProjectVariables(int $batchSize): void } } + /** + * @throws AppwriteException + */ + private function exportWebhooks(int $batchSize): void + { + $lastId = null; + + while (true) { + $queries = [Query::limit($batchSize)]; + + if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_WEBHOOK) { + $queries[] = Query::equal('$id', $this->rootResourceId); + $queries[] = Query::limit(1); + } + + if ($lastId !== null) { + $queries[] = Query::cursorAfter($lastId); + } + + $response = $this->webhooks->list($queries); + if ($response->total === 0) { + break; + } + + $webhooks = []; + + foreach ($response->webhooks as $webhook) { + $webhooks[] = new Webhook( + $webhook->id, + $webhook->name, + $webhook->url, + $webhook->events, + $webhook->tls, + $webhook->authUsername, + $webhook->authPassword, + $webhook->enabled, + createdAt: $webhook->createdAt, + updatedAt: $webhook->updatedAt, + ); + + $lastId = $webhook->id; + } + + $this->callback($webhooks); + + if (\count($response->webhooks) < $batchSize) { + break; + } + } + } + /** * @throws AppwriteException */ diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 069a780b..05c61f40 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -96,6 +96,7 @@ class Transfer public const GROUP_SETTINGS_RESOURCES = [ Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, ]; public const GROUP_BACKUPS_RESOURCES = [ @@ -138,6 +139,7 @@ class Transfer // Settings Resource::TYPE_PROJECT_VARIABLE, + Resource::TYPE_WEBHOOK, // legacy Resource::TYPE_DOCUMENT,