From f15af15708ff9f860c0b1c84362f536462330e87 Mon Sep 17 00:00:00 2001 From: secus Date: Tue, 23 Sep 2025 14:50:54 +0700 Subject: [PATCH 01/11] Fix: build Docker image with sqlx by preparing query cache (cargo sqlx prepare) and update webhooks handler for query compatibility --- .env.development | 8 + .env.example | 11 + ...1faecd762789418a15e68cedc88ef666fec8d.json | 14 + ...ab9378701e7d675d45e4fcb7404aba853d589.json | 14 + ...d64c6ea32b916e841115921886c0049ee8958.json | 70 +++ ...a2f6740dfd3ac6b9264c5554429824338ccea.json | 23 + ...5b3a9730d85635ac5f63baa9e197895fd9005.json | 70 +++ ...61ab4ca9c7170290effe5f2461cb492c03fd2.json | 19 + ...f604f96bdee291cda04d37fddff52d92e92a0.json | 15 + ...30922e2d15325d2cee9c73bd90c0be4cd3046.json | 71 +++ ...93dd8e587fc9e51b0fb414f5773f30fbcd6c8.json | 22 + ...223aa3f5d25a3128140a9310f9d91cc7cf513.json | 34 ++ ...a22f8f832bfa16f2065135f8f53bcd8c0fa1f.json | 16 + ...d66a3d28cd32e5bb7e47e46392f935c3e6339.json | 23 + ...bc618f85d8cb78920e3374aec08ee68bc8ef9.json | 35 ++ ...40e53af91be8afe5d6a76681deb856486545c.json | 24 + ...5a51ae8ff746a092b325fa4f703c1906165cf.json | 23 + APIs.md | 468 +++++++++++------- Cargo.toml | 1 + apps/container-engine-frontend/src/App.tsx | 2 + .../src/components/Layout/DashboardLayout.tsx | 7 + .../src/components/icons.tsx | 47 ++ .../src/pages/AuthPage.tsx | 154 ++++-- .../src/pages/DocumentationPage.tsx | 48 +- .../src/pages/WebhooksPage.tsx | 430 ++++++++++++++++ .../src/services/webhookService.ts | 66 +++ .../20240202000001_password_reset_tokens.sql | 19 + migrations/20240301000001_user_webhooks.sql | 22 + src/auth/middleware.rs | 2 +- src/auth/mod.rs | 2 - src/auth/models.rs | 39 ++ src/config.rs | 4 +- src/database.rs | 2 - src/deployment/mod.rs | 1 - src/deployment/models.rs | 1 - src/email/mod.rs | 3 + src/email/service.rs | 186 +++++++ src/email/templates.rs | 184 +++++++ src/handlers/auth.rs | 158 +++++- src/handlers/logs.rs | 2 +- src/handlers/mod.rs | 6 +- src/handlers/user.rs | 1 - src/handlers/webhooks.rs | 318 ++++++++++++ src/jobs/deployment_worker.rs | 125 ++++- src/main.rs | 100 +++- src/notifications/mod.rs | 1 - src/services/kubernetes.rs | 2 +- src/services/mod.rs | 3 +- src/services/webhook.rs | 118 +++++ src/user/mod.rs | 2 +- src/user/webhook_models.rs | 98 ++++ 51 files changed, 2830 insertions(+), 284 deletions(-) create mode 100644 .sqlx/query-19945d2e23d4e54bcaafd212df71faecd762789418a15e68cedc88ef666fec8d.json create mode 100644 .sqlx/query-2cbdf5c505a0a7d65eb01c482a4ab9378701e7d675d45e4fcb7404aba853d589.json create mode 100644 .sqlx/query-32aa79a0a9654fa2e3acbbd0f6ed64c6ea32b916e841115921886c0049ee8958.json create mode 100644 .sqlx/query-38bba728896ac61542b8279a6b5a2f6740dfd3ac6b9264c5554429824338ccea.json create mode 100644 .sqlx/query-3f4d708c6810b85421669ada5f45b3a9730d85635ac5f63baa9e197895fd9005.json create mode 100644 .sqlx/query-5889f23a18bf32671c979a8a18261ab4ca9c7170290effe5f2461cb492c03fd2.json create mode 100644 .sqlx/query-6291008bd24d8a6f907e71c0040f604f96bdee291cda04d37fddff52d92e92a0.json create mode 100644 .sqlx/query-6319c10d4f5c165e2e3c5eeec8230922e2d15325d2cee9c73bd90c0be4cd3046.json create mode 100644 .sqlx/query-ac2818b04f46f739d803b90a12e93dd8e587fc9e51b0fb414f5773f30fbcd6c8.json create mode 100644 .sqlx/query-bafd62b66947dc636dd2990d969223aa3f5d25a3128140a9310f9d91cc7cf513.json create mode 100644 .sqlx/query-d1db3347028467d3a35f5c85f7ea22f8f832bfa16f2065135f8f53bcd8c0fa1f.json create mode 100644 .sqlx/query-e6e92e69ca96e5085874a0e8142d66a3d28cd32e5bb7e47e46392f935c3e6339.json create mode 100644 .sqlx/query-ea944c26183e9b1181ccb75b26abc618f85d8cb78920e3374aec08ee68bc8ef9.json create mode 100644 .sqlx/query-ee4598c953a21125db0c7506c9f40e53af91be8afe5d6a76681deb856486545c.json create mode 100644 .sqlx/query-fea9a78339b5e89b18a59301e395a51ae8ff746a092b325fa4f703c1906165cf.json create mode 100644 apps/container-engine-frontend/src/components/icons.tsx create mode 100644 apps/container-engine-frontend/src/pages/WebhooksPage.tsx create mode 100644 apps/container-engine-frontend/src/services/webhookService.ts create mode 100644 migrations/20240202000001_password_reset_tokens.sql create mode 100644 migrations/20240301000001_user_webhooks.sql create mode 100644 src/email/mod.rs create mode 100644 src/email/service.rs create mode 100644 src/email/templates.rs create mode 100644 src/handlers/webhooks.rs create mode 100644 src/services/webhook.rs create mode 100644 src/user/webhook_models.rs diff --git a/.env.development b/.env.development index 38a3afc..6b3b9f5 100644 --- a/.env.development +++ b/.env.development @@ -21,6 +21,14 @@ KUBERNETES_NAMESPACE=container-engine-dev # Domain Configuration DOMAIN_SUFFIX=vinhomes.co.uk +# Email Configuration - Mailtrap +MAILTRAP_SMTP_HOST=live.smtp.mailtrap.io +MAILTRAP_SMTP_PORT=587 +MAILTRAP_USERNAME=your_mailtrap_username +MAILTRAP_PASSWORD=your_mailtrap_password +EMAIL_FROM=noreply@vinhomes.co.uk +EMAIL_FROM_NAME=Container Engine + # Logging RUST_LOG=container_engine=debug,tower_http=debug KUBECONFIG_PATH=./k8sConfig.yaml diff --git a/.env.example b/.env.example index 00ff960..e68f960 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,17 @@ KUBERNETES_NAMESPACE=container-engine # Domain Configuration DOMAIN_SUFFIX=container-engine.app +# Email Configuration - Mailtrap Live SMTP +MAILTRAP_SMTP_HOST=live.smtp.mailtrap.io +MAILTRAP_SMTP_PORT=587 +MAILTRAP_USERNAME=your_mailtrap_username +MAILTRAP_PASSWORD=your_mailtrap_password +EMAIL_FROM=noreply@yourdomain.com +EMAIL_FROM_NAME=Container Engine + +# Webhook Configuration +WEBHOOK_URL=https://your-webhook-endpoint.com/api/webhook/container-engine/events?api_key=your_api_key + # Logging RUST_LOG=container_engine=debug,tower_http=debug # Front end path diff --git a/.sqlx/query-19945d2e23d4e54bcaafd212df71faecd762789418a15e68cedc88ef666fec8d.json b/.sqlx/query-19945d2e23d4e54bcaafd212df71faecd762789418a15e68cedc88ef666fec8d.json new file mode 100644 index 0000000..639a378 --- /dev/null +++ b/.sqlx/query-19945d2e23d4e54bcaafd212df71faecd762789418a15e68cedc88ef666fec8d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password_reset_tokens SET used_at = NOW(), updated_at = NOW() WHERE token = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "19945d2e23d4e54bcaafd212df71faecd762789418a15e68cedc88ef666fec8d" +} diff --git a/.sqlx/query-2cbdf5c505a0a7d65eb01c482a4ab9378701e7d675d45e4fcb7404aba853d589.json b/.sqlx/query-2cbdf5c505a0a7d65eb01c482a4ab9378701e7d675d45e4fcb7404aba853d589.json new file mode 100644 index 0000000..e1e9675 --- /dev/null +++ b/.sqlx/query-2cbdf5c505a0a7d65eb01c482a4ab9378701e7d675d45e4fcb7404aba853d589.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM password_reset_tokens WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2cbdf5c505a0a7d65eb01c482a4ab9378701e7d675d45e4fcb7404aba853d589" +} diff --git a/.sqlx/query-32aa79a0a9654fa2e3acbbd0f6ed64c6ea32b916e841115921886c0049ee8958.json b/.sqlx/query-32aa79a0a9654fa2e3acbbd0f6ed64c6ea32b916e841115921886c0049ee8958.json new file mode 100644 index 0000000..e54407e --- /dev/null +++ b/.sqlx/query-32aa79a0a9654fa2e3acbbd0f6ed64c6ea32b916e841115921886c0049ee8958.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM user_webhooks WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "events", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "32aa79a0a9654fa2e3acbbd0f6ed64c6ea32b916e841115921886c0049ee8958" +} diff --git a/.sqlx/query-38bba728896ac61542b8279a6b5a2f6740dfd3ac6b9264c5554429824338ccea.json b/.sqlx/query-38bba728896ac61542b8279a6b5a2f6740dfd3ac6b9264c5554429824338ccea.json new file mode 100644 index 0000000..ff5580e --- /dev/null +++ b/.sqlx/query-38bba728896ac61542b8279a6b5a2f6740dfd3ac6b9264c5554429824338ccea.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM user_webhooks WHERE id = $1 AND user_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "38bba728896ac61542b8279a6b5a2f6740dfd3ac6b9264c5554429824338ccea" +} diff --git a/.sqlx/query-3f4d708c6810b85421669ada5f45b3a9730d85635ac5f63baa9e197895fd9005.json b/.sqlx/query-3f4d708c6810b85421669ada5f45b3a9730d85635ac5f63baa9e197895fd9005.json new file mode 100644 index 0000000..fe3d3a9 --- /dev/null +++ b/.sqlx/query-3f4d708c6810b85421669ada5f45b3a9730d85635ac5f63baa9e197895fd9005.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM user_webhooks WHERE user_id = $1 ORDER BY created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "events", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "3f4d708c6810b85421669ada5f45b3a9730d85635ac5f63baa9e197895fd9005" +} diff --git a/.sqlx/query-5889f23a18bf32671c979a8a18261ab4ca9c7170290effe5f2461cb492c03fd2.json b/.sqlx/query-5889f23a18bf32671c979a8a18261ab4ca9c7170290effe5f2461cb492c03fd2.json new file mode 100644 index 0000000..6322d69 --- /dev/null +++ b/.sqlx/query-5889f23a18bf32671c979a8a18261ab4ca9c7170290effe5f2461cb492c03fd2.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_webhooks \n SET \n name = COALESCE($1, name),\n url = COALESCE($2, url),\n secret = COALESCE($3, secret),\n is_active = COALESCE($4, is_active),\n events = COALESCE($5, events),\n updated_at = NOW()\n WHERE id = $6\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "TextArray", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5889f23a18bf32671c979a8a18261ab4ca9c7170290effe5f2461cb492c03fd2" +} diff --git a/.sqlx/query-6291008bd24d8a6f907e71c0040f604f96bdee291cda04d37fddff52d92e92a0.json b/.sqlx/query-6291008bd24d8a6f907e71c0040f604f96bdee291cda04d37fddff52d92e92a0.json new file mode 100644 index 0000000..2a5f0b9 --- /dev/null +++ b/.sqlx/query-6291008bd24d8a6f907e71c0040f604f96bdee291cda04d37fddff52d92e92a0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM user_webhooks WHERE id = $1 AND user_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "6291008bd24d8a6f907e71c0040f604f96bdee291cda04d37fddff52d92e92a0" +} diff --git a/.sqlx/query-6319c10d4f5c165e2e3c5eeec8230922e2d15325d2cee9c73bd90c0be4cd3046.json b/.sqlx/query-6319c10d4f5c165e2e3c5eeec8230922e2d15325d2cee9c73bd90c0be4cd3046.json new file mode 100644 index 0000000..de932b5 --- /dev/null +++ b/.sqlx/query-6319c10d4f5c165e2e3c5eeec8230922e2d15325d2cee9c73bd90c0be4cd3046.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM user_webhooks WHERE id = $1 AND user_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "events", + "type_info": "TextArray" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "6319c10d4f5c165e2e3c5eeec8230922e2d15325d2cee9c73bd90c0be4cd3046" +} diff --git a/.sqlx/query-ac2818b04f46f739d803b90a12e93dd8e587fc9e51b0fb414f5773f30fbcd6c8.json b/.sqlx/query-ac2818b04f46f739d803b90a12e93dd8e587fc9e51b0fb414f5773f30fbcd6c8.json new file mode 100644 index 0000000..7ca6138 --- /dev/null +++ b/.sqlx/query-ac2818b04f46f739d803b90a12e93dd8e587fc9e51b0fb414f5773f30fbcd6c8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_webhooks (\n id, user_id, name, url, secret, is_active, events, created_at, updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "TextArray", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "ac2818b04f46f739d803b90a12e93dd8e587fc9e51b0fb414f5773f30fbcd6c8" +} diff --git a/.sqlx/query-bafd62b66947dc636dd2990d969223aa3f5d25a3128140a9310f9d91cc7cf513.json b/.sqlx/query-bafd62b66947dc636dd2990d969223aa3f5d25a3128140a9310f9d91cc7cf513.json new file mode 100644 index 0000000..c369497 --- /dev/null +++ b/.sqlx/query-bafd62b66947dc636dd2990d969223aa3f5d25a3128140a9310f9d91cc7cf513.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id, expires_at, used_at\n FROM password_reset_tokens \n WHERE token = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "bafd62b66947dc636dd2990d969223aa3f5d25a3128140a9310f9d91cc7cf513" +} diff --git a/.sqlx/query-d1db3347028467d3a35f5c85f7ea22f8f832bfa16f2065135f8f53bcd8c0fa1f.json b/.sqlx/query-d1db3347028467d3a35f5c85f7ea22f8f832bfa16f2065135f8f53bcd8c0fa1f.json new file mode 100644 index 0000000..33ddd06 --- /dev/null +++ b/.sqlx/query-d1db3347028467d3a35f5c85f7ea22f8f832bfa16f2065135f8f53bcd8c0fa1f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO password_reset_tokens (user_id, token, expires_at, created_at, updated_at)\n VALUES ($1, $2, $3, NOW(), NOW())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "d1db3347028467d3a35f5c85f7ea22f8f832bfa16f2065135f8f53bcd8c0fa1f" +} diff --git a/.sqlx/query-e6e92e69ca96e5085874a0e8142d66a3d28cd32e5bb7e47e46392f935c3e6339.json b/.sqlx/query-e6e92e69ca96e5085874a0e8142d66a3d28cd32e5bb7e47e46392f935c3e6339.json new file mode 100644 index 0000000..6a742e2 --- /dev/null +++ b/.sqlx/query-e6e92e69ca96e5085874a0e8142d66a3d28cd32e5bb7e47e46392f935c3e6339.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT url FROM user_webhooks WHERE id = $1 AND user_id = $2 AND is_active = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "url", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e6e92e69ca96e5085874a0e8142d66a3d28cd32e5bb7e47e46392f935c3e6339" +} diff --git a/.sqlx/query-ea944c26183e9b1181ccb75b26abc618f85d8cb78920e3374aec08ee68bc8ef9.json b/.sqlx/query-ea944c26183e9b1181ccb75b26abc618f85d8cb78920e3374aec08ee68bc8ef9.json new file mode 100644 index 0000000..6cdefbd --- /dev/null +++ b/.sqlx/query-ea944c26183e9b1181ccb75b26abc618f85d8cb78920e3374aec08ee68bc8ef9.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, url, secret \n FROM user_webhooks \n WHERE user_id = $1 AND is_active = true \n AND ($2 = ANY(events) OR 'all' = ANY(events))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "secret", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "ea944c26183e9b1181ccb75b26abc618f85d8cb78920e3374aec08ee68bc8ef9" +} diff --git a/.sqlx/query-ee4598c953a21125db0c7506c9f40e53af91be8afe5d6a76681deb856486545c.json b/.sqlx/query-ee4598c953a21125db0c7506c9f40e53af91be8afe5d6a76681deb856486545c.json new file mode 100644 index 0000000..f5969cf --- /dev/null +++ b/.sqlx/query-ee4598c953a21125db0c7506c9f40e53af91be8afe5d6a76681deb856486545c.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM user_webhooks WHERE user_id = $1 AND name = $2 AND id != $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ee4598c953a21125db0c7506c9f40e53af91be8afe5d6a76681deb856486545c" +} diff --git a/.sqlx/query-fea9a78339b5e89b18a59301e395a51ae8ff746a092b325fa4f703c1906165cf.json b/.sqlx/query-fea9a78339b5e89b18a59301e395a51ae8ff746a092b325fa4f703c1906165cf.json new file mode 100644 index 0000000..200c0bf --- /dev/null +++ b/.sqlx/query-fea9a78339b5e89b18a59301e395a51ae8ff746a092b325fa4f703c1906165cf.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM user_webhooks WHERE user_id = $1 AND name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fea9a78339b5e89b18a59301e395a51ae8ff746a092b325fa4f703c1906165cf" +} diff --git a/APIs.md b/APIs.md index ec28db1..bb1842b 100644 --- a/APIs.md +++ b/APIs.md @@ -5,9 +5,13 @@ This document provides comprehensive documentation for all API endpoints availab ## Base URL ``` -https://api.container-engine.app +http://localhost:3000 ``` +For production deployment: Replace with your actual domain. + +**Domain Suffix:** `vinhomes.co.uk` (configured in environment) + ## Authentication All API endpoints (except registration and login) require authentication via API key in the Authorization header: @@ -37,18 +41,21 @@ Content-Type: application/json "username": "string", "email": "string", "password": "string", - "confirmPassword": "string" + "confirm_password": "string" } ``` **Response (201 Created):** ```json { - "id": "usr-a1b2c3d4e5", - "username": "string", - "email": "string", - "createdAt": "2025-01-01T00:00:00Z", - "status": "active" + "access_token": "string", + "refresh_token": "string", + "expires_at": "2025-01-01T01:00:00Z", + "user": { + "id": "uuid", + "username": "string", + "email": "string" + } } ``` @@ -80,11 +87,11 @@ Content-Type: application/json **Response (200 OK):** ```json { - "accessToken": "string", - "refreshToken": "string", - "expiresAt": "2025-01-01T01:00:00Z", + "access_token": "string", + "refresh_token": "string", + "expires_at": "2025-01-01T01:00:00Z", "user": { - "id": "usr-a1b2c3d4e5", + "id": "uuid", "username": "string", "email": "string" } @@ -111,15 +118,15 @@ Content-Type: application/json **Request Body:** ```json { - "refreshToken": "string" + "refresh_token": "string" } ``` **Response (200 OK):** ```json { - "accessToken": "string", - "expiresAt": "2025-01-01T01:00:00Z" + "access_token": "string", + "expires_at": "2025-01-01T01:00:00Z" } ``` @@ -145,6 +152,61 @@ Authorization: Bearer --- +## Forgot Password + +Request a password reset link. + +**Endpoint:** `POST /v1/auth/forgot-password` + +**Headers:** +```http +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "email": "string" +} +``` + +**Response (200 OK):** +```json +{ + "message": "If an account with that email exists, a password reset link has been sent." +} +``` + +--- + +## Reset Password + +Reset password using a token. + +**Endpoint:** `POST /v1/auth/reset-password` + +**Headers:** +```http +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "token": "string", + "new_password": "string" +} +``` + +**Response (200 OK):** +```json +{ + "message": "Password reset successfully" +} +``` + +--- + ## API Key Management ### Create API Key @@ -163,21 +225,17 @@ Content-Type: application/json ```json { "name": "string", - "description": "string", - "expiresAt": "2025-12-31T23:59:59Z" // optional + "description": "string" } ``` **Response (201 Created):** ```json { - "id": "key-a1b2c3d4e5", - "name": "string", - "description": "string", - "apiKey": "ce_api_1234567890abcdef...", - "createdAt": "2025-01-01T00:00:00Z", - "expiresAt": "2025-12-31T23:59:59Z", - "lastUsed": null + "id": "uuid", + "name": "string", + "key": "ce_dev_1234567890abcdef...", + "created_at": "2025-01-01T00:00:00Z" } ``` @@ -201,21 +259,22 @@ Authorization: Bearer **Response (200 OK):** ```json { - "apiKeys": [ + "api_keys": [ { - "id": "key-a1b2c3d4e5", + "id": "uuid", "name": "string", "description": "string", - "createdAt": "2025-01-01T00:00:00Z", - "expiresAt": "2025-12-31T23:59:59Z", - "lastUsed": "2025-01-01T12:00:00Z" + "key_prefix": "ce_dev_", + "created_at": "2025-01-01T00:00:00Z", + "expires_at": "2025-12-31T23:59:59Z", + "last_used": "2025-01-01T12:00:00Z" } ], "pagination": { "page": 1, "limit": 10, "total": 1, - "totalPages": 1 + "total_pages": 1 } } ``` @@ -258,13 +317,12 @@ Authorization: Bearer **Response (200 OK):** ```json { - "id": "usr-a1b2c3d4e5", + "id": "uuid", "username": "string", "email": "string", - "createdAt": "2025-01-01T00:00:00Z", - "lastLogin": "2025-01-01T12:00:00Z", - "deploymentCount": 5, - "apiKeyCount": 2 + "created_at": "2025-01-01T00:00:00Z", + "last_login": "2025-01-01T12:00:00Z", + "is_active": true } ``` @@ -293,10 +351,10 @@ Content-Type: application/json **Response (200 OK):** ```json { - "id": "usr-a1b2c3d4e5", + "id": "uuid", "username": "string", "email": "string", - "updatedAt": "2025-01-01T12:00:00Z" + "updated_at": "2025-01-01T12:00:00Z" } ``` @@ -317,9 +375,9 @@ Content-Type: application/json **Request Body:** ```json { - "currentPassword": "string", - "newPassword": "string", - "confirmNewPassword": "string" + "current_password": "string", + "new_password": "string", + "confirm_new_password": "string" } ``` @@ -349,25 +407,25 @@ Content-Type: application/json **Request Body:** ```json { - "appName": "string", + "app_name": "string", "image": "string", "port": 80, - "envVars": { + "env_vars": { "ENV_VAR_NAME": "value" }, - "replicas": 1, // optional, default: 1 - "resources": { // optional + "replicas": 1, + "resources": { "cpu": "100m", "memory": "128Mi" }, - "healthCheck": { // optional + "health_check": { "path": "/health", - "initialDelaySeconds": 5, - "periodSeconds": 10, - "timeoutSeconds": 5, - "failureThreshold": 3 + "initial_delay_seconds": 30, + "period_seconds": 10, + "timeout_seconds": 5, + "failure_threshold": 3 }, - "registryAuth": { // optional, for private registries + "registry_auth": { "username": "string", "password": "string" } @@ -377,12 +435,12 @@ Content-Type: application/json **Response (201 Created):** ```json { - "id": "dpl-a1b2c3d4e5", - "appName": "string", + "id": "uuid", + "app_name": "string", "image": "string", "status": "pending", - "url": "https://app-name.container-engine.app", - "createdAt": "2025-01-01T00:00:00Z", + "url": null, + "created_at": "2025-01-01T00:00:00Z", "message": "Deployment is being processed" } ``` @@ -415,21 +473,21 @@ Authorization: Bearer { "deployments": [ { - "id": "dpl-a1b2c3d4e5", - "appName": "string", + "id": "uuid", + "app_name": "string", "image": "string", "status": "running", - "url": "https://app-name.container-engine.app", + "url": "https://app-name.vinhomes.co.uk", "replicas": 1, - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T00:05:00Z" + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:05:00Z" } ], "pagination": { "page": 1, "limit": 10, "total": 1, - "totalPages": 1 + "total_pages": 1 } } ``` @@ -450,30 +508,32 @@ Authorization: Bearer **Response (200 OK):** ```json { - "id": "dpl-a1b2c3d4e5", - "appName": "string", + "id": "uuid", + "user_id": "uuid", + "app_name": "string", "image": "string", "status": "running", - "url": "https://app-name.container-engine.app", + "url": "https://app-name.vinhomes.co.uk", "port": 80, - "replicas": 1, - "envVars": { + "env_vars": { "ENV_VAR_NAME": "value" }, + "replicas": 1, "resources": { "cpu": "100m", "memory": "128Mi" }, - "healthCheck": { + "health_check": { "path": "/health", - "initialDelaySeconds": 5, - "periodSeconds": 10, - "timeoutSeconds": 5, - "failureThreshold": 3 + "initial_delay_seconds": 30, + "period_seconds": 10, + "timeout_seconds": 5, + "failure_threshold": 3 }, - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T00:05:00Z", - "deployedAt": "2025-01-01T00:05:00Z" + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:05:00Z", + "deployed_at": "2025-01-01T00:05:00Z", + "error_message": null } ``` @@ -494,12 +554,12 @@ Content-Type: application/json **Request Body:** ```json { - "image": "string", // optional - "envVars": { // optional + "image": "string", + "env_vars": { "ENV_VAR_NAME": "new_value" }, - "replicas": 2, // optional - "resources": { // optional + "replicas": 2, + "resources": { "cpu": "200m", "memory": "256Mi" } @@ -509,10 +569,10 @@ Content-Type: application/json **Response (200 OK):** ```json { - "id": "dpl-a1b2c3d4e5", + "id": "uuid", "status": "updating", "message": "Deployment update in progress", - "updatedAt": "2025-01-01T12:00:00Z" + "updated_at": "2025-01-01T12:00:00Z" } ``` @@ -540,10 +600,10 @@ Content-Type: application/json **Response (200 OK):** ```json { - "id": "dpl-a1b2c3d4e5", + "id": "uuid", "replicas": 5, - "status": "scaling", - "message": "Deployment scaling in progress" + "status": "running", + "message": "Deployment scaled successfully" } ``` @@ -563,9 +623,9 @@ Authorization: Bearer **Response (200 OK):** ```json { - "id": "dpl-a1b2c3d4e5", - "status": "stopping", - "message": "Deployment is being stopped" + "id": "uuid", + "status": "stopped", + "message": "Deployment stopped successfully" } ``` @@ -585,9 +645,10 @@ Authorization: Bearer **Response (200 OK):** ```json { - "id": "dpl-a1b2c3d4e5", - "status": "starting", - "message": "Deployment is being started" + "id": "uuid", + "status": "running", + "replicas": 1, + "message": "Deployment started successfully" } ``` @@ -607,7 +668,10 @@ Authorization: Bearer **Response (200 OK):** ```json { - "message": "Deployment deleted successfully" + "message": "Deployment deleted successfully", + "deployment_id": "uuid", + "app_name": "string", + "namespace_deleted": true } ``` @@ -634,20 +698,12 @@ Content-Type: application/json } ``` -**Response (201 Created):** +**Response (500 Internal Server Error):** ```json { - "id": "dom-a1b2c3d4e5", - "domain": "myapp.example.com", - "status": "pending", - "createdAt": "2025-01-01T00:00:00Z", - "dnsRecords": [ - { - "type": "CNAME", - "name": "myapp.example.com", - "value": "app-name.container-engine.app" - } - ] + "error": { + "message": "Domain management not yet implemented" + } } ``` @@ -667,15 +723,7 @@ Authorization: Bearer **Response (200 OK):** ```json { - "domains": [ - { - "id": "dom-a1b2c3d4e5", - "domain": "myapp.example.com", - "status": "active", - "createdAt": "2025-01-01T00:00:00Z", - "verifiedAt": "2025-01-01T00:15:00Z" - } - ] + "domains": [] } ``` @@ -692,10 +740,12 @@ Remove a custom domain from a deployment. Authorization: Bearer ``` -**Response (200 OK):** +**Response (500 Internal Server Error):** ```json { - "message": "Custom domain removed successfully" + "error": { + "message": "Domain management not yet implemented" + } } ``` @@ -717,8 +767,6 @@ Authorization: Bearer **Query Parameters:** - `tail` (optional): Number of lines to return from the end (default: 100) - `follow` (optional): Stream logs in real-time (default: false) -- `since` (optional): Return logs since timestamp (ISO 8601) -- `until` (optional): Return logs until timestamp (ISO 8601) **Response (200 OK):** ```json @@ -726,7 +774,7 @@ Authorization: Bearer "logs": [ { "timestamp": "2025-01-01T12:00:00Z", - "level": "info", + "level": "info", "message": "Application started successfully", "source": "app" } @@ -736,7 +784,7 @@ Authorization: Bearer **WebSocket Endpoint for Real-time Logs:** ``` -wss://api.container-engine.app/v1/deployments/{deploymentId}/logs/stream +ws://localhost:3000/v1/deployments/{deploymentId}/logs/stream ``` --- @@ -752,37 +800,15 @@ Get performance metrics for a deployment. Authorization: Bearer ``` -**Query Parameters:** -- `from` (optional): Start time for metrics (ISO 8601) -- `to` (optional): End time for metrics (ISO 8601) -- `resolution` (optional): Metric resolution (1m, 5m, 1h, 1d) - **Response (200 OK):** ```json { - "metrics": { - "cpu": [ - { - "timestamp": "2025-01-01T12:00:00Z", - "value": 0.25 - } - ], - "memory": [ - { - "timestamp": "2025-01-01T12:00:00Z", - "value": 134217728 - } - ], - "requests": [ - { - "timestamp": "2025-01-01T12:00:00Z", - "value": 100 - } - ] - } + "metrics": {} } ``` +Note: Metrics implementation is currently a stub. + --- ### Get Deployment Status @@ -806,14 +832,89 @@ Authorization: Bearer "ready": 2, "available": 2 }, - "lastHealthCheck": "2025-01-01T12:00:00Z", - "uptime": "2h 30m 45s", - "restartCount": 0 + "last_health_check": "2025-01-01T12:00:00Z", + "uptime": "0s", + "restart_count": 0 +} +``` + +--- + +## Health Check + +Check the overall health of the Container Engine API. + +**Endpoint:** `GET /health` + +**Response (200 OK):** +```json +{ + "status": "healthy", + "service": "container-engine", + "version": "0.1.0" +} +``` + +--- + +## WebSocket Notifications + +Real-time notifications are available via WebSocket connection: + +**Connection URL:** +``` +ws://localhost:3000/v1/ws/notifications +``` + +**Health Check URL:** +``` +ws://localhost:3000/v1/ws/health +``` + +**Authentication:** +Include Authorization header with Bearer token when connecting. + +**Event Format:** +```json +{ + "type": "deployment_status_changed", + "deployment_id": "uuid", + "timestamp": "2025-01-01T12:00:00Z", + "data": { + "status": "running", + "url": "https://app-name.vinhomes.co.uk" + } } ``` --- +## Testing Endpoints + +### Send Test Notification + +Trigger a test notification (for development). + +**Endpoint:** `GET /v1/notifications/test` + +**Headers:** +```http +Authorization: Bearer +``` + +### Get Notification Stats + +Get notification system statistics. + +**Endpoint:** `GET /v1/notifications/stats` + +**Headers:** +```http +Authorization: Bearer +``` + +--- + ## Error Handling All API endpoints return consistent error responses: @@ -822,79 +923,64 @@ All API endpoints return consistent error responses: ```json { "error": { - "code": "string", "message": "string", - "details": {} // optional additional error details + "details": "string" } } ``` **Common HTTP Status Codes:** - `400 Bad Request`: Invalid request parameters -- `401 Unauthorized`: Missing or invalid authentication +- `401 Unauthorized`: Missing or invalid authentication - `403 Forbidden`: Insufficient permissions - `404 Not Found`: Resource not found -- `409 Conflict`: Resource conflict (e.g., duplicate name) +- `409 Conflict`: Resource conflict (e.g., duplicate app name) - `422 Unprocessable Entity`: Invalid data format -- `429 Too Many Requests`: Rate limit exceeded - `500 Internal Server Error`: Server error --- -## Rate Limiting +## Environment Configuration -API requests are rate-limited per API key: +The API uses the following environment variables: -- **Authentication endpoints**: 10 requests per minute -- **Deployment operations**: 30 requests per minute -- **Read operations**: 100 requests per minute -- **Logs and metrics**: 60 requests per minute +**Required:** +- `DATABASE_URL`: PostgreSQL connection string +- `REDIS_URL`: Redis connection string +- `JWT_SECRET`: Secret key for JWT tokens -Rate limit headers are included in all responses: -```http -X-RateLimit-Limit: 100 -X-RateLimit-Remaining: 99 -X-RateLimit-Reset: 1641024000 -``` +**Optional:** +- `PORT`: Server port (default: 3000) +- `DOMAIN_SUFFIX`: Domain suffix for deployments (default: vinhomes.co.uk) +- `WEBHOOK_URL`: Webhook URL for deployment events +- `KUBECONFIG_PATH`: Path to Kubernetes config file +- `KUBERNETES_NAMESPACE`: Default Kubernetes namespace ---- +**Email Configuration (Optional):** +- `AWS_SES_SMTP_HOST`: SMTP host for email service +- `AWS_SES_SMTP_PORT`: SMTP port (default: 587) +- `AWS_SES_USERNAME`: SMTP username +- `AWS_SES_PASSWORD`: SMTP password +- `EMAIL_FROM`: From email address +- `EMAIL_FROM_NAME`: From email name -## WebSocket Events +--- -Real-time events are available via WebSocket connection: +## Webhook Integration -**Connection URL:** -``` -wss://api.container-engine.app/v1/events -``` - -**Authentication:** -Include API key in connection query parameter: -``` -wss://api.container-engine.app/v1/events?token= -``` +When enabled, the API sends webhook notifications for deployment events to the configured `WEBHOOK_URL`. -**Event Format:** +**Webhook Payload:** ```json { - "type": "deployment.status.changed", - "deploymentId": "dpl-a1b2c3d4e5", + "deployment_id": "uuid", + "status": "completed|failed|started", + "type": "deployment_completed|deployment_failed|deployment_started", "timestamp": "2025-01-01T12:00:00Z", - "data": { - "status": "running", - "previousStatus": "pending" - } + "app_name": "string", + "user_id": "uuid", + "url": "https://app-name.vinhomes.co.uk" } ``` -**Available Event Types:** -- `deployment.created` -- `deployment.status.changed` -- `deployment.updated` -- `deployment.deleted` -- `domain.verified` -- `domain.failed` - ---- - -This API documentation covers all the essential endpoints for a complete container engine platform with user management, authentication, deployment management, and monitoring capabilities. \ No newline at end of file +--- \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index d473b2c..913b4eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ license = "MIT" repository = "https://github.com/ngocbd/Open-Container-Engine" [dependencies] +lettre = "0.11" serde_yaml = "0.9" bytes = "1.5" futures-util = "0.3" diff --git a/apps/container-engine-frontend/src/App.tsx b/apps/container-engine-frontend/src/App.tsx index 1dda07d..5fbd01b 100644 --- a/apps/container-engine-frontend/src/App.tsx +++ b/apps/container-engine-frontend/src/App.tsx @@ -14,6 +14,7 @@ import FeaturesPage from './pages/FeaturesPage'; import DocumentationPage from './pages/DocumentationPage'; import PrivacyPolicyPage from './pages/PrivacyPolicyPage'; import TermsOfServicePage from './pages/TermsOfServicePage'; +import WebhooksPage from './pages/WebhooksPage'; import ProtectedRoute from './components/ProtectedRoute'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; @@ -40,6 +41,7 @@ function App() { } /> } /> } /> + } /> } /> {/* Default route */} diff --git a/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx b/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx index 3706dd2..4c9028f 100644 --- a/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx +++ b/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx @@ -49,6 +49,12 @@ const BellIcon = () => ( ); +const WebhookIcon = () => ( + + + +); + const MenuIcon = () => ( @@ -60,6 +66,7 @@ const navItems = [ { path: '/deployments', label: 'Deployments', icon: DeploymentIcon }, { path: '/deployments/new', label: 'New Deployment', icon: PlusIcon }, { path: '/api-keys', label: 'API Keys', icon: KeyIcon }, + { path: '/webhooks', label: 'Webhooks', icon: WebhookIcon }, { path: '/settings', label: 'Account Settings', icon: SettingsIcon }, ]; diff --git a/apps/container-engine-frontend/src/components/icons.tsx b/apps/container-engine-frontend/src/components/icons.tsx new file mode 100644 index 0000000..02f1fed --- /dev/null +++ b/apps/container-engine-frontend/src/components/icons.tsx @@ -0,0 +1,47 @@ +export const Plus = ({ className }: { className?: string }) => ( + + + +); + +export const Edit = ({ className }: { className?: string }) => ( + + + +); + +export const Trash2 = ({ className }: { className?: string }) => ( + + + +); + +export const TestTube = ({ className }: { className?: string }) => ( + + + +); + +export const Globe = ({ className }: { className?: string }) => ( + + + +); + +export const CheckCircle = ({ className }: { className?: string }) => ( + + + +); + +export const XCircle = ({ className }: { className?: string }) => ( + + + +); + +export const AlertCircle = ({ className }: { className?: string }) => ( + + + +); diff --git a/apps/container-engine-frontend/src/pages/AuthPage.tsx b/apps/container-engine-frontend/src/pages/AuthPage.tsx index cb40b2a..f4b09f1 100644 --- a/apps/container-engine-frontend/src/pages/AuthPage.tsx +++ b/apps/container-engine-frontend/src/pages/AuthPage.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'; const AuthPage: React.FC = () => { const [isRegister, setIsRegister] = useState(false); + const [isForgotPassword, setIsForgotPassword] = useState(false); const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -25,7 +26,12 @@ const AuthPage: React.FC = () => { setIsLoading(true); try { - if (isRegister) { + if (isForgotPassword) { + // Forgot password process + await api.post('/v1/auth/forgot-password', { email }); + setSuccess('If an account with that email exists, a password reset link has been sent to your email.'); + return; + } else if (isRegister) { if (password !== confirm_password) { setError('Passwords do not match.'); return; @@ -113,7 +119,9 @@ const AuthPage: React.FC = () => {
- {isRegister ? ( + {isForgotPassword ? ( + + ) : isRegister ? ( ) : ( @@ -121,10 +129,15 @@ const AuthPage: React.FC = () => {

- {isRegister ? 'Create Account' : 'Welcome Back'} + {isForgotPassword ? 'Reset Password' : isRegister ? 'Create Account' : 'Welcome Back'}

- {isRegister ? 'Join us and start your journey today' : 'Please sign in to your account'} + {isForgotPassword + ? 'Enter your email address and we\'ll send you a link to reset your password' + : isRegister + ? 'Join us and start your journey today' + : 'Please sign in to your account' + }

@@ -179,31 +192,33 @@ const AuthPage: React.FC = () => { -
- -
- setPassword(e.target.value)} - /> - + {!isForgotPassword && ( +
+ +
+ setPassword(e.target.value)} + /> + +
-
+ )} {isRegister && (
@@ -262,7 +277,14 @@ const AuthPage: React.FC = () => {
) : ( - {isRegister ? ( + {isForgotPassword ? ( + <> + + + + Send Reset Link + + ) : isRegister ? ( <> @@ -297,19 +319,69 @@ const AuthPage: React.FC = () => { {/* Toggle */}

- {isRegister ? 'Already have an account?' : "Don't have an account?"}{' '} - + {isForgotPassword ? ( + <> + Remember your password?{' '} + + + ) : isRegister ? ( + <> + Already have an account?{' '} + + + ) : ( + <> + Don't have an account?{' '} + + + )}

+ {!isForgotPassword && !isRegister && ( +

+ Forgot your password?{' '} + +

+ )}
diff --git a/apps/container-engine-frontend/src/pages/DocumentationPage.tsx b/apps/container-engine-frontend/src/pages/DocumentationPage.tsx index 7f1fb78..5485e18 100644 --- a/apps/container-engine-frontend/src/pages/DocumentationPage.tsx +++ b/apps/container-engine-frontend/src/pages/DocumentationPage.tsx @@ -185,13 +185,13 @@ const DocumentationPage: React.FC = () => {

User Registration

-{`curl -X POST https://api.container-engine.app/v1/auth/register \\
+{`curl -X POST https://decenter.run/v1/auth/register \\
   -H "Content-Type: application/json" \\
   -d '{
     "username": "your_username",
     "email": "your@email.com",
     "password": "secure_password",
-    "confirmPassword": "secure_password"
+    "confirm_password": "secure_password"
   }'`}
                         
@@ -218,7 +218,7 @@ const DocumentationPage: React.FC = () => {

API Key Generation

-{`curl -X POST https://api.container-engine.app/v1/api-keys \\
+{`curl -X POST https://decenter.run/v1/api-keys \\
   -H "Authorization: Bearer " \\
   -H "Content-Type: application/json" \\
   -d '{
@@ -258,7 +258,7 @@ const DocumentationPage: React.FC = () => {
                     

Base URL

- https://api.container-engine.app + https://decenter.run/
@@ -314,14 +314,14 @@ const DocumentationPage: React.FC = () => {

Deploy Your First Container

-{`curl -X POST https://api.container-engine.app/v1/deployments \\
+{`curl -X POST https://decenter.run/v1/deployments \\
   -H "Authorization: Bearer " \\
   -H "Content-Type: application/json" \\
   -d '{
-    "appName": "hello-world",
+    "app_name": "hello-world",
     "image": "nginx:latest",
     "port": 80,
-    "envVars": {
+    "env_vars": {
       "ENVIRONMENT": "production"
     },
     "replicas": 1
@@ -355,9 +355,9 @@ const DocumentationPage: React.FC = () => {
                       
 {`{
   "id": "dpl-a1b2c3d4e5",
-  "appName": "hello-world",
+  "app_name": "hello-world",
   "status": "pending",
-  "url": "https://hello-world.container-engine.app",
+  "url": "https://hello-world.vinhomes.co.uk",
   "message": "Deployment is being processed"
 }`}
                       
@@ -379,10 +379,10 @@ const DocumentationPage: React.FC = () => {
 {`# Deploy a Python Flask app
 {
-  "appName": "my-python-app",
+  "app_name": "my-python-app",
   "image": "python:3.9-slim",
   "port": 5000,
-  "envVars": {
+  "env_vars": {
     "FLASK_ENV": "production",
     "DATABASE_URL": "postgresql://..."
   }
@@ -397,10 +397,10 @@ const DocumentationPage: React.FC = () => {
                         
 {`# Deploy a Node.js Express app
 {
-  "appName": "my-node-app",
+  "app_name": "my-node-app",
   "image": "node:16-alpine",
   "port": 3000,
-  "envVars": {
+  "env_vars": {
     "NODE_ENV": "production",
     "API_KEY": "your-api-key"
   },
@@ -441,12 +441,12 @@ const DocumentationPage: React.FC = () => {
                       
 {`{
-  "healthCheck": {
+  "health_check": {
     "path": "/health",
-    "initialDelaySeconds": 30,
-    "periodSeconds": 10,
-    "timeoutSeconds": 5,
-    "failureThreshold": 3
+    "initial_delay_seconds": 30,
+    "period_seconds": 10,
+    "timeout_seconds": 5,
+    "failure_threshold": 3
   }
 }`}
                         
diff --git a/apps/container-engine-frontend/src/pages/WebhooksPage.tsx b/apps/container-engine-frontend/src/pages/WebhooksPage.tsx new file mode 100644 index 0000000..6d75a7f --- /dev/null +++ b/apps/container-engine-frontend/src/pages/WebhooksPage.tsx @@ -0,0 +1,430 @@ +import { useState, useEffect } from 'react'; +import { Plus, Edit, Trash2, TestTube, Globe, CheckCircle, XCircle, AlertCircle } from '../components/icons'; +import { webhookService } from '../services/webhookService'; +import DashboardLayout from '../components/Layout/DashboardLayout'; + +interface Webhook { + id: string; + name: string; + url: string; + events: string[]; + is_active: boolean; + created_at: string; + updated_at: string; +} + +interface CreateWebhookRequest { + name: string; + url: string; + events: string[]; + secret?: string; + is_active: boolean; +} + +const WEBHOOK_EVENTS = [ + { value: 'deployment_started', label: 'Deployment Started', description: 'When a deployment begins' }, + { value: 'deployment_completed', label: 'Deployment Completed', description: 'When a deployment finishes successfully' }, + { value: 'deployment_failed', label: 'Deployment Failed', description: 'When a deployment fails' }, + { value: 'deployment_deleted', label: 'Deployment Deleted', description: 'When a deployment is deleted' }, + { value: 'all', label: 'All Events', description: 'Subscribe to all webhook events' }, +]; + +export default function WebhooksPage() { + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [validationErrors, setValidationErrors] = useState>({}); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingWebhook, setEditingWebhook] = useState(null); + const [testingWebhook, setTestingWebhook] = useState(null); + + // Create/Edit form state + const [formData, setFormData] = useState({ + name: '', + url: '', + events: [], + secret: '', + is_active: true, + }); + + useEffect(() => { + loadWebhooks(); + }, []); + + const loadWebhooks = async () => { + try { + setLoading(true); + const response = await webhookService.listWebhooks(); + setWebhooks(response.data || []); + setError(null); + } catch (err: any) { + setError(err?.response?.data?.message || err.message || 'Failed to load webhooks'); + } finally { + setLoading(false); + } + }; + + const handleCreateWebhook = async () => { + setValidationErrors({}); + try { + await webhookService.createWebhook(formData); + await loadWebhooks(); + setShowCreateModal(false); + resetForm(); + } catch (err: any) { + if (err?.response?.data?.errors) { + setValidationErrors(err.response.data.errors); + } else { + setError(err?.response?.data?.message || err.message || 'Failed to create webhook'); + } + } + }; + + const handleUpdateWebhook = async () => { + if (!editingWebhook) return; + setValidationErrors({}); + try { + await webhookService.updateWebhook(editingWebhook.id, formData); + await loadWebhooks(); + setEditingWebhook(null); + resetForm(); + } catch (err: any) { + if (err?.response?.data?.errors) { + setValidationErrors(err.response.data.errors); + } else { + setError(err?.response?.data?.message || err.message || 'Failed to update webhook'); + } + } + }; + + const handleDeleteWebhook = async (id: string) => { + if (!confirm('Are you sure you want to delete this webhook?')) return; + + try { + await webhookService.deleteWebhook(id); + await loadWebhooks(); + } catch (err: any) { + setError(err.message || 'Failed to delete webhook'); + } + }; + + const handleTestWebhook = async (id: string) => { + try { + setTestingWebhook(id); + await webhookService.testWebhook(id); + alert('Test webhook sent successfully!'); + } catch (err: any) { + alert('Failed to send test webhook: ' + (err.message || 'Unknown error')); + } finally { + setTestingWebhook(null); + } + }; + + const resetForm = () => { + setFormData({ + name: '', + url: '', + events: [], + secret: '', + is_active: true, + }); + }; + + const openEditModal = (webhook: Webhook) => { + setEditingWebhook(webhook); + setFormData({ + name: webhook.name, + url: webhook.url, + events: webhook.events, + secret: '', // Don't pre-fill secret for security + is_active: webhook.is_active, + }); + }; + + const handleEventToggle = (eventValue: string) => { + if (eventValue === 'all') { + // If selecting "All Events", clear other events + setFormData(prev => ({ + ...prev, + events: prev.events.includes('all') ? [] : ['all'] + })); + } else { + // If selecting specific event, remove "All Events" if present + setFormData(prev => ({ + ...prev, + events: prev.events.includes(eventValue) + ? prev.events.filter(e => e !== eventValue) + : [...prev.events.filter(e => e !== 'all'), eventValue] + })); + } + }; + + const getEventLabel = (event: string) => { + const eventInfo = WEBHOOK_EVENTS.find(e => e.value === event); + return eventInfo?.label || event; + }; + + if (loading) { + return ( +
+
+
+

Loading webhooks...

+
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+

Webhooks

+

Manage your deployment notification webhooks

+
+ +
+ + {/* Error Alert */} + {error && ( +
+
+ +
+

{error}

+ +
+
+
+ )} + + {/* Webhooks List */} +
+ {webhooks.length === 0 ? ( +
+ +

No webhooks

+

Get started by creating a new webhook.

+
+ +
+
+ ) : ( +
    + {webhooks.map((webhook) => ( +
  • +
    +
    +
    +
    + {webhook.is_active ? ( + + ) : ( + + )} +
    +
    +
    +

    + {webhook.name} +

    + + {webhook.is_active ? 'Active' : 'Inactive'} + +
    +
    +

    + {webhook.url} +

    +

    + Events: {webhook.events.map(getEventLabel).join(', ')} +

    +

    + Created: {new Date(webhook.created_at).toLocaleDateString()} +

    +
    +
    +
    +
    +
    + + + +
    +
    +
  • + ))} +
+ )} +
+ + {/* Create/Edit Modal */} + {(showCreateModal || editingWebhook) && ( +
+
+
+

+ {editingWebhook ? 'Edit Webhook' : 'Create Webhook'} +

+
+ +
+ {/* Name Input */} +
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="My Webhook" + className={`w-full px-3 py-2 border ${validationErrors.name ? 'border-red-500' : 'border-gray-300'} rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500`} + required + /> + {validationErrors.name && ( +

{validationErrors.name}

+ )} +
+ + {/* URL Input */} +
+ + setFormData(prev => ({ ...prev, url: e.target.value }))} + placeholder="https://your-domain.com/webhook" + className={`w-full px-3 py-2 border ${validationErrors.url ? 'border-red-500' : 'border-gray-300'} rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500`} + required + /> + {validationErrors.url && ( +

{validationErrors.url}

+ )} +
+ + {/* Secret Input */} +
+ + setFormData(prev => ({ ...prev, secret: e.target.value }))} + placeholder="Enter webhook secret for HMAC validation" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* Events Selection */} +
+ +
+ {WEBHOOK_EVENTS.map((event) => ( + + ))} +
+ {validationErrors.events && ( +

{validationErrors.events}

+ )} +
+ + {/* Active Status */} +
+ setFormData(prev => ({ ...prev, is_active: e.target.checked }))} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+
+ +
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/apps/container-engine-frontend/src/services/webhookService.ts b/apps/container-engine-frontend/src/services/webhookService.ts new file mode 100644 index 0000000..4d0b0b4 --- /dev/null +++ b/apps/container-engine-frontend/src/services/webhookService.ts @@ -0,0 +1,66 @@ +import api from '../api/api'; + +export interface WebhookResponse { + id: string; + name: string; + url: string; + events: string[]; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateWebhookRequest { + name: string; + url: string; + events: string[]; + secret?: string; + is_active: boolean; +} + +export interface UpdateWebhookRequest { + name?: string; + url?: string; + events?: string[]; + secret?: string; + is_active?: boolean; +} + +export interface WebhookListResponse { + webhooks: WebhookResponse[]; + total: number; +} + +class WebhookService { + async listWebhooks() { + const res = await api.get('/v1/webhooks'); + return { data: res.data.webhooks, total: res.data.total }; + } + + async getWebhook(id: string) { + const res = await api.get(`/v1/webhooks/${id}`); + return res.data; + } + + async createWebhook(webhook: CreateWebhookRequest) { + const res = await api.post('/v1/webhooks', webhook); + return res.data; + } + + async updateWebhook(id: string, webhook: UpdateWebhookRequest) { + const res = await api.put(`/v1/webhooks/${id}`, webhook); + return res.data; + } + + async deleteWebhook(id: string) { + const res = await api.delete(`/v1/webhooks/${id}`); + return res.data; + } + + async testWebhook(id: string) { + const res = await api.post(`/v1/webhooks/${id}/test`); + return res.data; + } +} + +export const webhookService = new WebhookService(); diff --git a/migrations/20240202000001_password_reset_tokens.sql b/migrations/20240202000001_password_reset_tokens.sql new file mode 100644 index 0000000..b9ffcd2 --- /dev/null +++ b/migrations/20240202000001_password_reset_tokens.sql @@ -0,0 +1,19 @@ +-- Create password reset tokens table +CREATE TABLE password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create index on token for fast lookups +CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token); + +-- Create index on user_id for cleanup +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); + +-- Create index on expires_at for cleanup of expired tokens +CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at); diff --git a/migrations/20240301000001_user_webhooks.sql b/migrations/20240301000001_user_webhooks.sql new file mode 100644 index 0000000..d46271c --- /dev/null +++ b/migrations/20240301000001_user_webhooks.sql @@ -0,0 +1,22 @@ +-- Create user_webhooks table +CREATE TABLE user_webhooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + url VARCHAR(500) NOT NULL, + secret VARCHAR(255), + is_active BOOLEAN NOT NULL DEFAULT true, + events TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(user_id, name) +); + +-- Create indexes +CREATE INDEX idx_user_webhooks_user_id ON user_webhooks(user_id); +CREATE INDEX idx_user_webhooks_active ON user_webhooks(is_active); + +-- Add trigger for updated_at +CREATE TRIGGER update_user_webhooks_updated_at BEFORE UPDATE ON user_webhooks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 6e9d39f..3dea28c 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{FromRequestParts, State}, + extract::FromRequestParts, http::{request::Parts, HeaderMap}, async_trait, }; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 6d3b14a..9d51c22 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -2,6 +2,4 @@ pub mod models; pub mod jwt; pub mod middleware; -pub use models::*; -pub use jwt::*; pub use middleware::*; \ No newline at end of file diff --git a/src/auth/models.rs b/src/auth/models.rs index d6681c3..e1240ff 100644 --- a/src/auth/models.rs +++ b/src/auth/models.rs @@ -33,6 +33,17 @@ pub struct ApiKey { pub is_active: bool, } +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct PasswordResetToken { + pub id: Uuid, + pub user_id: Uuid, + pub token: String, + pub expires_at: DateTime, + pub used_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct RegisterRequest { /// Username must be between 3 and 50 characters @@ -130,6 +141,34 @@ pub struct ApiKeyListResponse { pub pagination: PaginationInfo, } +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct ForgotPasswordRequest { + /// Valid email address + #[validate(email)] + pub email: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ForgotPasswordResponse { + pub message: String, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct ResetPasswordRequest { + /// Password reset token + pub token: String, + /// New password must be at least 8 characters + #[validate(length(min = 8))] + pub new_password: String, + /// Must match the new password field + pub confirm_password: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ResetPasswordResponse { + pub message: String, +} + #[derive(Debug, Serialize, ToSchema)] pub struct PaginationInfo { pub page: u32, diff --git a/src/config.rs b/src/config.rs index 752f669..aab7b7e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,7 +26,9 @@ impl Config { // Try to load the environment-specific file, fall back to default .env if let Err(_) = dotenv::from_filename(env_file) { - dotenv::dotenv().ok(); + if let Err(_) = dotenv::from_filename(".env.local") { + dotenv::dotenv().ok(); + } } let config = Config { diff --git a/src/database.rs b/src/database.rs index 10c0555..4a07bde 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,6 +1,4 @@ use sqlx::{Pool, Postgres, PgPool, migrate::MigrateDatabase}; -use std::time::Duration; -use std::str::FromStr; #[derive(Clone)] pub struct Database { diff --git a/src/deployment/mod.rs b/src/deployment/mod.rs index 19c042b..88b9624 100644 --- a/src/deployment/mod.rs +++ b/src/deployment/mod.rs @@ -1,3 +1,2 @@ pub mod models; -pub use models::*; \ No newline at end of file diff --git a/src/deployment/models.rs b/src/deployment/models.rs index db5df9a..7137d34 100644 --- a/src/deployment/models.rs +++ b/src/deployment/models.rs @@ -1,5 +1,4 @@ use chrono::{DateTime, Utc}; -use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::FromRow; diff --git a/src/email/mod.rs b/src/email/mod.rs new file mode 100644 index 0000000..371f837 --- /dev/null +++ b/src/email/mod.rs @@ -0,0 +1,3 @@ +pub mod service; +pub mod templates; +pub use service::EmailService; \ No newline at end of file diff --git a/src/email/service.rs b/src/email/service.rs new file mode 100644 index 0000000..953eb4a --- /dev/null +++ b/src/email/service.rs @@ -0,0 +1,186 @@ +use lettre::{ + message::{header::ContentType, Mailbox}, + transport::smtp::{authentication::Credentials, PoolConfig}, + SmtpTransport, Transport, Message, +}; +use std::time::Duration; + +use crate::error::AppError; + +#[derive(Clone)] +pub struct EmailService { + mailer: SmtpTransport, + from_email: String, + from_name: String, +} + +impl EmailService { + pub fn new( + smtp_host: &str, + smtp_port: u16, + username: &str, + password: &str, + from_email: &str, + from_name: &str, + ) -> Result { + let creds = Credentials::new(username.to_string(), password.to_string()); + + let mailer = if smtp_port == 465 { + // Use TLS wrapper for port 465 + SmtpTransport::relay(smtp_host) + .map_err(|e| AppError::internal(&format!("Failed to create SMTP transport: {}", e)))? + .port(smtp_port) + .credentials(creds) + .pool_config(PoolConfig::new().max_size(20)) + .timeout(Some(Duration::from_secs(30))) + .tls(lettre::transport::smtp::client::Tls::Wrapper( + lettre::transport::smtp::client::TlsParameters::builder(smtp_host.to_string()) + .build() + .map_err(|e| AppError::internal(&format!("Failed to create TLS parameters: {}", e)))? + )) + .build() + } else if smtp_host.contains("sandbox.smtp.mailtrap.io") { + // Mailtrap Sandbox SMTP - no TLS, plain connection + SmtpTransport::builder_dangerous(smtp_host) + .port(smtp_port) + .credentials(creds) + .pool_config(PoolConfig::new().max_size(20)) + .timeout(Some(Duration::from_secs(30))) + .build() + } else if smtp_host.contains("live.smtp.mailtrap.io") { + // Special configuration for Mailtrap Live SMTP - use STARTTLS with default auth + SmtpTransport::starttls_relay(smtp_host) + .map_err(|e| AppError::internal(&format!("Failed to create SMTP transport: {}", e)))? + .port(smtp_port) + .credentials(creds) + .pool_config(PoolConfig::new().max_size(20)) + .timeout(Some(Duration::from_secs(30))) + .build() + } else { + // Use STARTTLS for other ports (587) + SmtpTransport::relay(smtp_host) + .map_err(|e| AppError::internal(&format!("Failed to create SMTP transport: {}", e)))? + .port(smtp_port) + .credentials(creds) + .pool_config(PoolConfig::new().max_size(20)) + .timeout(Some(Duration::from_secs(30))) + .build() + }; + + Ok(Self { + mailer, + from_email: from_email.to_string(), + from_name: from_name.to_string(), + }) + } + + pub fn send_email( + &self, + to_email: &str, + to_name: Option<&str>, + subject: &str, + html_body: &str, + text_body: Option<&str>, + ) -> Result<(), AppError> { + let from = format!("{} <{}>", self.from_name, self.from_email) + .parse::() + .map_err(|e| AppError::internal(&format!("Invalid from email: {}", e)))?; + + let to = if let Some(name) = to_name { + format!("{} <{}>", name, to_email) + } else { + to_email.to_string() + } + .parse::() + .map_err(|e| AppError::internal(&format!("Invalid to email: {}", e)))?; + + // If text body is provided, create multipart + let email = if let Some(text) = text_body { + Message::builder() + .from(from) + .to(to) + .subject(subject) + .multipart( + lettre::message::MultiPart::alternative() + .singlepart( + lettre::message::SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text.to_string()), + ) + .singlepart( + lettre::message::SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body.to_string()), + ), + ) + .map_err(|e| AppError::internal(&format!("Failed to build multipart email: {}", e)))? + } else { + Message::builder() + .from(from) + .to(to) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(html_body.to_string()) + .map_err(|e| AppError::internal(&format!("Failed to build email: {}", e)))? + }; + + // Send the email + self.mailer.send(&email) + .map_err(|e| AppError::internal(&format!("Failed to send email: {}", e)))?; + + Ok(()) + } + + // Convenience methods for common email types + pub fn send_welcome_email( + &self, + to_email: &str, + username: &str, + ) -> Result<(), AppError> { + let subject = "Welcome to Container Engine"; + let html_body = self.welcome_email_template(username); + let text_body = format!("Welcome to Container Engine, {}! Your account has been created successfully.", username); + + self.send_email(to_email, Some(username), &subject, &html_body, Some(&text_body)) + } + + pub fn send_password_reset_email( + &self, + to_email: &str, + username: &str, + reset_token: &str, + reset_url: &str, + ) -> Result<(), AppError> { + let subject = "Password Reset Request"; + let html_body = self.password_reset_email_template(username, reset_token, reset_url); + let text_body = format!( + "Hi {},\n\nYou requested a password reset. Click the link below to reset your password:\n{}\n\nIf you didn't request this, please ignore this email.", + username, reset_url + ); + + self.send_email(to_email, Some(username), &subject, &html_body, Some(&text_body)) + } + + pub fn send_deployment_notification( + &self, + to_email: &str, + username: &str, + app_name: &str, + status: &str, + deployment_url: Option<&str>, + ) -> Result<(), AppError> { + let subject = format!("Deployment Update: {} - {}", app_name, status); + let html_body = self.deployment_notification_template(username, app_name, status, deployment_url); + let text_body = format!( + "Hi {},\n\nYour deployment '{}' status has changed to: {}\n{}", + username, + app_name, + status, + deployment_url.map_or(String::new(), |url| format!("URL: {}", url)) + ); + + self.send_email(to_email, Some(username), &subject, &html_body, Some(&text_body)) + } + + +} \ No newline at end of file diff --git a/src/email/templates.rs b/src/email/templates.rs new file mode 100644 index 0000000..3276f6d --- /dev/null +++ b/src/email/templates.rs @@ -0,0 +1,184 @@ +use crate::email::service::EmailService; + +impl EmailService { + pub fn welcome_email_template(&self, username: &str) -> String { + format!( + r#" + + + + + + Welcome to Container Engine + + +
+
+

Container Engine

+
+ +
+

Welcome, {}!

+ +

+ Thank you for joining Container Engine! Your account has been created successfully. +

+ +

+ You can now start deploying your applications with ease. Here's what you can do: +

+ +
    +
  • Deploy Docker containers
  • +
  • Scale your applications
  • +
  • Monitor deployment status
  • +
  • Manage environment variables
  • +
+ + +
+ +
+

Container Engine Team

+
+
+ + + "#, + username + ) + } + + pub fn password_reset_email_template(&self, username: &str, _reset_token: &str, reset_url: &str) -> String { + format!( + r#" + + + + + + Password Reset + + +
+
+

Container Engine

+
+ +
+

Password Reset Request

+ +

+ Hi {}, +

+ +

+ You requested a password reset for your Container Engine account. Click the button below to reset your password: +

+ + + +

+ If you didn't request this password reset, please ignore this email. This link will expire in 1 hour. +

+ +

+ Or copy and paste this URL: {} +

+
+ +
+

Container Engine Team

+
+
+ + + "#, + username, reset_url, reset_url + ) + } + + pub fn deployment_notification_template(&self, username: &str, app_name: &str, status: &str, deployment_url: Option<&str>) -> String { + let status_color = match status { + "running" => "#10b981", + "failed" => "#ef4444", + "stopped" => "#f59e0b", + _ => "#6b7280", + }; + + let status_message = match status { + "running" => "is now running successfully!", + "failed" => "has failed. Please check the logs.", + "stopped" => "has been stopped.", + "pending" => "is being deployed...", + _ => &format!("status has changed to {}", status), + }; + + format!( + r#" + + + + + + Deployment Update + + +
+
+

Container Engine

+
+ +
+

Deployment Update

+ +

+ Hi {}, +

+ +
+

+ Your deployment {} {} +

+
+ + {} + + +
+ +
+

Container Engine Team

+
+
+ + + "#, + username, + status_color, + app_name, + status_message, + deployment_url.map_or(String::new(), |url| format!( + r#"

+ Deployment URL: {} +

"#, + url, url + )) + ) + } +} \ No newline at end of file diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 2d5b08e..6f491cb 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -4,9 +4,8 @@ use axum::{ }; use bcrypt::{hash, verify, DEFAULT_COST}; use chrono::Utc; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{json, Value}; -use utoipa::path; use uuid::Uuid; use validator::Validate; @@ -364,4 +363,159 @@ pub async fn revoke_api_key( Ok(Json(json!({ "message": "API key revoked successfully" }))) +} + +#[utoipa::path( + post, + path = "/v1/auth/forgot-password", + request_body = ForgotPasswordRequest, + responses( + (status = 200, description = "Password reset email sent successfully", body = ForgotPasswordResponse), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 404, description = "User not found", body = ErrorResponse), + ), + tag = "auth" +)] +pub async fn forgot_password( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + payload.validate()?; + + // Check if user exists + let user = sqlx::query_as!( + User, + "SELECT * FROM users WHERE email = $1 AND is_active = true", + payload.email + ) + .fetch_optional(&state.db.pool) + .await?; + + let user = match user { + Some(u) => u, + None => { + // Don't reveal that the user doesn't exist for security reasons + return Ok(Json(ForgotPasswordResponse { + message: "If an account with that email exists, a password reset link has been sent.".to_string(), + })); + } + }; + + // Generate reset token + let reset_token = Uuid::new_v4().to_string().replace("-", ""); + let expires_at = Utc::now() + chrono::Duration::hours(1); // Token expires in 1 hour + + // Delete any existing reset tokens for this user + sqlx::query!( + "DELETE FROM password_reset_tokens WHERE user_id = $1", + user.id + ) + .execute(&state.db.pool) + .await?; + + // Insert new reset token + sqlx::query!( + r#" + INSERT INTO password_reset_tokens (user_id, token, expires_at, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + "#, + user.id, + reset_token, + expires_at + ) + .execute(&state.db.pool) + .await?; + + // Send password reset email + let reset_url = format!("https://your-domain.com/reset-password?token={}", reset_token); + + // Temporarily use console logging instead of real email for demo + tracing::info!("=== PASSWORD RESET EMAIL (DEMO) ==="); + tracing::info!("To: {} <{}>", user.username, user.email); + tracing::info!("Subject: Password Reset Request"); + tracing::info!("Reset URL: {}", reset_url); + tracing::info!("Reset Token: {}", reset_token); + tracing::info!("================================"); + + if let Err(e) = state.email_service.send_password_reset_email(&user.email, &user.username, &reset_token, &reset_url) { + tracing::error!("Failed to send password reset email: {}", e); + return Err(AppError::internal(&format!("Failed to send password reset email: {}", e))); + } + + Ok(Json(ForgotPasswordResponse { + message: "If an account with that email exists, a password reset link has been sent.".to_string(), + })) +} + +#[utoipa::path( + post, + path = "/v1/auth/reset-password", + request_body = ResetPasswordRequest, + responses( + (status = 200, description = "Password reset successfully", body = ResetPasswordResponse), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 404, description = "Invalid or expired token", body = ErrorResponse), + ), + tag = "auth" +)] +pub async fn reset_password( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + payload.validate()?; + + if payload.new_password != payload.confirm_password { + return Err(AppError::bad_request("Passwords do not match")); + } + + // Find valid reset token + let reset_token = sqlx::query!( + r#" + SELECT user_id, expires_at, used_at + FROM password_reset_tokens + WHERE token = $1 + "#, + payload.token + ) + .fetch_optional(&state.db.pool) + .await?; + + let reset_token = match reset_token { + Some(token) => token, + None => return Err(AppError::bad_request("Invalid or expired reset token")), + }; + + // Check if token is expired + if reset_token.expires_at < Utc::now() { + return Err(AppError::bad_request("Reset token has expired")); + } + + // Check if token has already been used + if reset_token.used_at.is_some() { + return Err(AppError::bad_request("Reset token has already been used")); + } + + // Hash new password + let password_hash = hash(&payload.new_password, DEFAULT_COST)?; + + // Update user password + sqlx::query!( + "UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2", + password_hash, + reset_token.user_id + ) + .execute(&state.db.pool) + .await?; + + // Mark token as used + sqlx::query!( + "UPDATE password_reset_tokens SET used_at = NOW(), updated_at = NOW() WHERE token = $1", + payload.token + ) + .execute(&state.db.pool) + .await?; + + Ok(Json(ResetPasswordResponse { + message: "Password has been reset successfully".to_string(), + })) } \ No newline at end of file diff --git a/src/handlers/logs.rs b/src/handlers/logs.rs index 6c8130b..0d7eaec 100644 --- a/src/handlers/logs.rs +++ b/src/handlers/logs.rs @@ -10,7 +10,7 @@ use axum::{ use futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tracing::{error, info, warn}; +use tracing::{error, info}; use uuid::Uuid; use crate::{AppError, AppState}; diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index b04ed89..7404aad 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -3,10 +3,6 @@ pub mod user; pub mod deployment; pub mod logs; pub mod notifications; +pub mod webhooks; -pub use auth::*; -pub use user::*; -pub use deployment::*; -pub use logs::*; -pub use notifications::*; diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 5e8f02e..ee6754d 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -4,7 +4,6 @@ use axum::{ }; use bcrypt::{hash, verify, DEFAULT_COST}; use serde_json::{json, Value}; -use utoipa::path; use validator::Validate; use crate::{ diff --git a/src/handlers/webhooks.rs b/src/handlers/webhooks.rs new file mode 100644 index 0000000..757e26f --- /dev/null +++ b/src/handlers/webhooks.rs @@ -0,0 +1,318 @@ +use axum::{ + extract::{Path, State}, + response::Json, +}; +use chrono::Utc; +use serde_json::{json, Value}; +use std::time::Instant; +use tracing::{error, info}; +use uuid::Uuid; +use validator::Validate; + +use crate::{ + auth::AuthUser, + error::AppError, + user::webhook_models::*, + AppState, +}; + +pub async fn create_webhook( + State(state): State, + user: AuthUser, + Json(payload): Json, +) -> Result, AppError> { + tracing::info!("Creating webhook for user {}: {:?}", user.user_id, payload); + + // Validate the payload + let validation_result = payload.validate(); + if let Err(e) = validation_result { + tracing::error!("Validation failed: {:?}", e); + return Err(AppError::bad_request(&format!("Validation error: {}", e))); + } + tracing::info!("Validation passed for webhook creation"); + + // Check if webhook name already exists for this user + let existing = sqlx::query!( + "SELECT id FROM user_webhooks WHERE user_id = $1 AND name = $2", + user.user_id, + payload.name + ) + .fetch_optional(&state.db.pool) + .await?; + + if existing.is_some() { + return Err(AppError::conflict("Webhook name")); + } + + let webhook_id = Uuid::new_v4(); + let now = Utc::now(); + let events: Vec = payload.events.iter().map(|e| e.as_str().to_string()).collect(); + + sqlx::query!( + r#" + INSERT INTO user_webhooks ( + id, user_id, name, url, secret, is_active, events, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + webhook_id, + user.user_id, + payload.name, + payload.url, + payload.secret, + true, + &events, + now, + now + ) + .execute(&state.db.pool) + .await?; + + info!( + "User {} created webhook: {} -> {}", + user.user_id, payload.name, payload.url + ); + + Ok(Json(WebhookResponse { + id: webhook_id, + name: payload.name, + url: payload.url, + is_active: true, + events, + created_at: now, + updated_at: now, + })) +} + +pub async fn list_webhooks( + State(state): State, + user: AuthUser, +) -> Result, AppError> { + let webhooks = sqlx::query_as!( + UserWebhook, + "SELECT * FROM user_webhooks WHERE user_id = $1 ORDER BY created_at DESC", + user.user_id + ) + .fetch_all(&state.db.pool) + .await?; + + let total = webhooks.len() as u64; + let webhook_responses: Vec = webhooks + .into_iter() + .map(|w| WebhookResponse { + id: w.id, + name: w.name, + url: w.url, + is_active: w.is_active, + events: w.events, + created_at: w.created_at, + updated_at: w.updated_at, + }) + .collect(); + + Ok(Json(WebhookListResponse { + webhooks: webhook_responses, + total, + })) +} + +pub async fn get_webhook( + State(state): State, + user: AuthUser, + Path(webhook_id): Path, +) -> Result, AppError> { + let webhook = sqlx::query_as!( + UserWebhook, + "SELECT * FROM user_webhooks WHERE id = $1 AND user_id = $2", + webhook_id, + user.user_id + ) + .fetch_optional(&state.db.pool) + .await? + .ok_or_else(|| AppError::not_found("Webhook"))?; + + Ok(Json(WebhookResponse { + id: webhook.id, + name: webhook.name, + url: webhook.url, + is_active: webhook.is_active, + events: webhook.events, + created_at: webhook.created_at, + updated_at: webhook.updated_at, + })) +} + +pub async fn update_webhook( + State(state): State, + user: AuthUser, + Path(webhook_id): Path, + Json(payload): Json, +) -> Result, AppError> { + payload.validate()?; + + // Check if webhook exists + let existing = sqlx::query!( + "SELECT id FROM user_webhooks WHERE id = $1 AND user_id = $2", + webhook_id, + user.user_id + ) + .fetch_optional(&state.db.pool) + .await? + .ok_or_else(|| AppError::not_found("Webhook"))?; + + // Check if new name conflicts + if let Some(ref name) = payload.name { + let name_conflict = sqlx::query!( + "SELECT id FROM user_webhooks WHERE user_id = $1 AND name = $2 AND id != $3", + user.user_id, + name, + webhook_id + ) + .fetch_optional(&state.db.pool) + .await?; + + if name_conflict.is_some() { + return Err(AppError::conflict("Webhook name")); + } + } + + let events = payload.events.as_ref().map(|e| { + e.iter().map(|evt| evt.as_str().to_string()).collect::>() + }); + + sqlx::query!( + r#" + UPDATE user_webhooks + SET + name = COALESCE($1, name), + url = COALESCE($2, url), + secret = COALESCE($3, secret), + is_active = COALESCE($4, is_active), + events = COALESCE($5, events), + updated_at = NOW() + WHERE id = $6 + "#, + payload.name, + payload.url, + payload.secret, + payload.is_active, + events.as_ref().map(|e| &e[..]), + webhook_id + ) + .execute(&state.db.pool) + .await?; + + // Fetch updated webhook + let webhook = sqlx::query_as!( + UserWebhook, + "SELECT * FROM user_webhooks WHERE id = $1", + webhook_id + ) + .fetch_one(&state.db.pool) + .await?; + + Ok(Json(WebhookResponse { + id: webhook.id, + name: webhook.name, + url: webhook.url, + is_active: webhook.is_active, + events: webhook.events, + created_at: webhook.created_at, + updated_at: webhook.updated_at, + })) +} + +pub async fn delete_webhook( + State(state): State, + user: AuthUser, + Path(webhook_id): Path, +) -> Result, AppError> { + let result = sqlx::query!( + "DELETE FROM user_webhooks WHERE id = $1 AND user_id = $2", + webhook_id, + user.user_id + ) + .execute(&state.db.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::not_found("Webhook")); + } + + info!("User {} deleted webhook: {}", user.user_id, webhook_id); + + Ok(Json(json!({ + "message": "Webhook deleted successfully", + "webhook_id": webhook_id + }))) +} + +pub async fn test_webhook( + State(state): State, + user: AuthUser, + Path(webhook_id): Path, +) -> Result, AppError> { + let webhook = sqlx::query!( + "SELECT url FROM user_webhooks WHERE id = $1 AND user_id = $2 AND is_active = true", + webhook_id, + user.user_id + ) + .fetch_optional(&state.db.pool) + .await? + .ok_or_else(|| AppError::not_found("Active webhook"))?; + + let start_time = Instant::now(); + + // Create test payload + let test_payload = json!({ + "event": "webhook.test", + "timestamp": Utc::now(), + "data": { + "message": "This is a test webhook from Container Engine", + "user_id": user.user_id, + "webhook_id": webhook_id + } + }); + + // Send test request + let client = reqwest::Client::new(); + match client + .post(&webhook.url) + .header("Content-Type", "application/json") + .header("User-Agent", "Container-Engine-Webhook/1.0") + .json(&test_payload) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + { + Ok(response) => { + let status_code = response.status().as_u16(); + let response_time = start_time.elapsed().as_millis() as u64; + + if response.status().is_success() { + Ok(Json(WebhookTestResponse { + success: true, + status_code: Some(status_code), + message: "Webhook test successful".to_string(), + response_time_ms: response_time, + })) + } else { + Ok(Json(WebhookTestResponse { + success: false, + status_code: Some(status_code), + message: format!("Webhook returned status {}", status_code), + response_time_ms: response_time, + })) + } + } + Err(e) => { + error!("Failed to test webhook {}: {}", webhook_id, e); + Ok(Json(WebhookTestResponse { + success: false, + status_code: None, + message: format!("Failed to send request: {}", e), + response_time_ms: start_time.elapsed().as_millis() as u64, + })) + } + } +} diff --git a/src/jobs/deployment_worker.rs b/src/jobs/deployment_worker.rs index 0ee1624..db5ae1a 100644 --- a/src/jobs/deployment_worker.rs +++ b/src/jobs/deployment_worker.rs @@ -7,19 +7,27 @@ use uuid::Uuid; use crate::jobs::deployment_job::DeploymentJob; use crate::notifications::{NotificationManager, NotificationType}; use crate::services::kubernetes::KubernetesService; +use crate::services::webhook::WebhookService; pub struct DeploymentWorker { receiver: mpsc::Receiver, db_pool: PgPool, notification_manager: NotificationManager, + webhook_service: WebhookService, } impl DeploymentWorker { - pub fn new(receiver: mpsc::Receiver, db_pool: PgPool, notification_manager: NotificationManager) -> Self { + pub fn new( + receiver: mpsc::Receiver, + db_pool: PgPool, + notification_manager: NotificationManager, + webhook_service: WebhookService, + ) -> Self { Self { receiver, db_pool, - notification_manager + notification_manager, + webhook_service, } } @@ -77,6 +85,9 @@ impl DeploymentWorker { return; } + // Call user webhooks for deployment started + self.call_user_webhooks(job.user_id, crate::user::webhook_models::WebhookEvent::DeploymentStarted, &job).await; + // Send notification that deployment is being processed self.notification_manager .send_to_user( @@ -120,6 +131,9 @@ impl DeploymentWorker { info!("Application accessible at: {}", url); } + // Call user webhooks for successful deployment + self.call_user_webhooks_with_url(job.user_id, crate::user::webhook_models::WebhookEvent::DeploymentCompleted, &job, ingress_url.clone()).await; + // Send success notification self.notification_manager .send_to_user( @@ -148,6 +162,9 @@ impl DeploymentWorker { { error!("Failed to update deployment status: {}", e); } else { + // Call user webhooks for completed deployment + self.call_user_webhooks(job.user_id, crate::user::webhook_models::WebhookEvent::DeploymentCompleted, &job).await; + // Send partial success notification self.notification_manager .send_to_user( @@ -184,6 +201,9 @@ impl DeploymentWorker { { error!("Failed to update deployment status to failed: {}", db_err); } else { + // Call user webhooks for failed deployment + self.call_user_webhooks(job.user_id, crate::user::webhook_models::WebhookEvent::DeploymentFailed, &job).await; + // Send failure notification self.notification_manager .send_to_user( @@ -261,4 +281,105 @@ impl DeploymentWorker { ); None } + + // Call user-configured webhooks instead of system webhook + async fn call_user_webhooks( + &self, + user_id: Uuid, + event: crate::user::webhook_models::WebhookEvent, + deployment_job: &DeploymentJob, + ) { + self.call_user_webhooks_with_url(user_id, event, deployment_job, None).await; + } + + // Call user-configured webhooks with optional URL + async fn call_user_webhooks_with_url( + &self, + user_id: Uuid, + event: crate::user::webhook_models::WebhookEvent, + deployment_job: &DeploymentJob, + app_url: Option, + ) { + // Get all active webhooks for this user that subscribe to this event + let event_str = event.as_str(); + let webhooks = match sqlx::query!( + r#" + SELECT id, url, secret + FROM user_webhooks + WHERE user_id = $1 AND is_active = true + AND ($2 = ANY(events) OR 'all' = ANY(events)) + "#, + user_id, + event_str + ) + .fetch_all(&self.db_pool) + .await + { + Ok(webhooks) => webhooks, + Err(e) => { + error!("Failed to fetch user webhooks for user {}: {}", user_id, e); + return; + } + }; + + if webhooks.is_empty() { + info!("No active webhooks found for user {} and event {}", user_id, event_str); + return; + } + + let client = reqwest::Client::new(); + + // Determine status and URL based on event + let (status, url): (&str, Option) = match event { + crate::user::webhook_models::WebhookEvent::DeploymentStarted => ("started", None), + crate::user::webhook_models::WebhookEvent::DeploymentCompleted => ("completed", app_url), + crate::user::webhook_models::WebhookEvent::DeploymentFailed => ("failed", None), + crate::user::webhook_models::WebhookEvent::DeploymentDeleted => ("deleted", None), + crate::user::webhook_models::WebhookEvent::All => ("unknown", None), // This shouldn't happen in practice + }; + + for webhook in webhooks { + // Use the same payload format as the old webhook service + let webhook_payload = serde_json::json!({ + "deployment_id": deployment_job.deployment_id, + "status": status, + "type": event_str, + "timestamp": chrono::Utc::now(), + "app_name": deployment_job.app_name, + "user_id": deployment_job.user_id, + "url": url + }); + + let mut request = client + .post(&webhook.url) + .header("Content-Type", "application/json") + .header("User-Agent", "Container-Engine-Webhook/1.0") + .json(&webhook_payload) + .timeout(Duration::from_secs(10)); + + // Add signature if secret is provided + if let Some(secret) = webhook.secret { + // TODO: Implement HMAC signature + // let signature = create_hmac_signature(&webhook_payload, &secret); + // request = request.header("X-Webhook-Signature", signature); + } + + match request.send().await { + Ok(response) => { + if response.status().is_success() { + info!("Successfully sent webhook to {} for event {}", webhook.url, event_str); + } else { + warn!( + "Webhook call failed: {} returned status {}", + webhook.url, + response.status() + ); + } + } + Err(e) => { + error!("Failed to send webhook to {}: {}", webhook.url, e); + } + } + } + } } diff --git a/src/main.rs b/src/main.rs index 4eeccd9..24dee07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod auth; mod config; mod database; mod deployment; +mod email; mod error; mod handlers; mod jobs; @@ -24,8 +25,11 @@ mod notifications; mod services; mod user; +use crate::email::EmailService; use crate::jobs::{deployment_job::DeploymentJob, deployment_worker::DeploymentWorker}; use crate::notifications::NotificationManager; +use crate::services::webhook::WebhookService; + use config::Config; use database::Database; use error::AppError; @@ -58,6 +62,10 @@ use tokio::sync::mpsc; auth::models::ApiKeyListItem, auth::models::ApiKeyListResponse, auth::models::PaginationInfo, + auth::models::ForgotPasswordRequest, + auth::models::ForgotPasswordResponse, + auth::models::ResetPasswordRequest, + auth::models::ResetPasswordResponse, user::models::UserProfile, user::models::UpdateProfileRequest, user::models::ChangePasswordRequest, @@ -91,17 +99,20 @@ pub struct AppState { pub config: Config, pub deployment_sender: mpsc::Sender, pub notification_manager: NotificationManager, + pub email_service: EmailService, + pub webhook_service: WebhookService, } // Setup function in main.rs pub async fn setup_deployment_system( db_pool: sqlx::PgPool, notification_manager: NotificationManager, + webhook_service: WebhookService, ) -> Result, Box> { // Create channel for deployment jobs let (deployment_sender, deployment_receiver) = mpsc::channel::(100); // Start deployment worker - let worker = DeploymentWorker::new(deployment_receiver, db_pool, notification_manager); + let worker = DeploymentWorker::new(deployment_receiver, db_pool, notification_manager, webhook_service); tokio::spawn(async move { worker.start().await; @@ -134,8 +145,10 @@ async fn open_browser_on_startup(port: u16) { }); } #[tokio::main] - async fn main() -> Result<(), Box> { + // Load environment variables from .env.local file + dotenv::from_filename(".env.local").ok(); + // Initialize tracing tracing_subscriber::registry() .with( @@ -168,9 +181,64 @@ async fn main() -> Result<(), Box> { // Setup notification manager let notification_manager = NotificationManager::new(); - // Setup deployment system - let deployment_sender = setup_deployment_system(db.pool.clone(), notification_manager.clone()).await?; + // Setup webhook service + let webhook_service = WebhookService::new(); + // Setup deployment system + let deployment_sender = + setup_deployment_system(db.pool.clone(), notification_manager.clone(), webhook_service.clone()).await?; + // Setup email service with better error handling + let email_service = match ( + std::env::var("MAILTRAP_USERNAME"), + std::env::var("MAILTRAP_PASSWORD") + ) { + (Ok(username), Ok(password)) => { + match EmailService::new( + &std::env::var("MAILTRAP_SMTP_HOST") + .unwrap_or_else(|_| "live.smtp.mailtrap.io".to_string()), + std::env::var("MAILTRAP_SMTP_PORT") + .unwrap_or_else(|_| "587".to_string()) + .parse() + .unwrap_or(587), + &username, + &password, + &std::env::var("EMAIL_FROM") + .unwrap_or_else(|_| "noreply@vinhomes.co.uk".to_string()), + &std::env::var("EMAIL_FROM_NAME") + .unwrap_or_else(|_| "Container Engine".to_string()), + ) { + Ok(service) => { + tracing::info!("Email service initialized successfully with Mailtrap"); + service + }, + Err(e) => { + tracing::warn!("Failed to initialize email service: {}", e); + tracing::warn!("Email functionality will be disabled"); + // Create a dummy email service that doesn't send emails + EmailService::new( + "localhost", + 587, + "dummy", + "dummy", + "noreply@vinhomes.co.uk", + "Container Engine", + ).unwrap_or_else(|_| panic!("Failed to create dummy email service")) + } + } + }, + _ => { + tracing::warn!("Email credentials not provided. Email functionality will be disabled"); + EmailService::new( + "localhost", + 587, + "dummy", + "dummy", + "noreply@vinhomes.co.uk", + "Container Engine", + ).unwrap_or_else(|_| panic!("Failed to create dummy email service")) + } + }; + // Create app state let state = AppState { db, @@ -178,6 +246,8 @@ async fn main() -> Result<(), Box> { config: config.clone(), deployment_sender, notification_manager, + email_service, + webhook_service, }; // Build our application with routes @@ -238,6 +308,8 @@ fn create_app(state: AppState) -> Router { .route("/v1/auth/login", post(handlers::auth::login)) .route("/v1/auth/refresh", post(handlers::auth::refresh_token)) .route("/v1/auth/logout", post(handlers::auth::logout)) + .route("/v1/auth/forgot-password", post(handlers::auth::forgot_password)) + .route("/v1/auth/reset-password", post(handlers::auth::reset_password)) // API Key management .route("/v1/api-keys", get(handlers::auth::list_api_keys)) .route("/v1/api-keys", post(handlers::auth::create_api_key)) @@ -288,7 +360,6 @@ fn create_app(state: AppState) -> Router { "/v1/deployments/:deployment_id/stop", post(handlers::deployment::stop_deployment), ) - .route( "/v1/deployments/:deployment_id/metrics", get(handlers::deployment::get_metrics), @@ -336,6 +407,25 @@ fn create_app(state: AppState) -> Router { "/v1/notifications/stats", get(handlers::notifications::get_notification_stats), ) + // Webhook management + .route("/v1/webhooks", get(handlers::webhooks::list_webhooks)) + .route("/v1/webhooks", post(handlers::webhooks::create_webhook)) + .route( + "/v1/webhooks/:webhook_id", + get(handlers::webhooks::get_webhook), + ) + .route( + "/v1/webhooks/:webhook_id", + axum::routing::put(handlers::webhooks::update_webhook), + ) + .route( + "/v1/webhooks/:webhook_id", + axum::routing::delete(handlers::webhooks::delete_webhook), + ) + .route( + "/v1/webhooks/:webhook_id/test", + post(handlers::webhooks::test_webhook), + ) // Serve static files .fallback_service(serve_dir) // Add middleware diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index da94023..98c83ac 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -5,4 +5,3 @@ pub mod manager; pub use manager::NotificationManager; pub use models::*; -pub use websocket::*; diff --git a/src/services/kubernetes.rs b/src/services/kubernetes.rs index c46901c..34556a0 100644 --- a/src/services/kubernetes.rs +++ b/src/services/kubernetes.rs @@ -112,7 +112,7 @@ impl KubernetesService { let namespace = Self::generate_deployment_namespace_static(deployment_id); - let mut service = Self { + let service = Self { client, namespace: namespace.clone(), }; diff --git a/src/services/mod.rs b/src/services/mod.rs index ac73fff..3f81666 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1 +1,2 @@ -pub mod kubernetes; \ No newline at end of file +pub mod kubernetes; +pub mod webhook; \ No newline at end of file diff --git a/src/services/webhook.rs b/src/services/webhook.rs new file mode 100644 index 0000000..a457f35 --- /dev/null +++ b/src/services/webhook.rs @@ -0,0 +1,118 @@ +use reqwest; +use serde::{Deserialize, Serialize}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +#[derive(Debug, Serialize)] +pub struct WebhookPayload { + pub deployment_id: Uuid, + pub status: String, + #[serde(rename = "type")] + pub event_type: String, + pub timestamp: chrono::DateTime, + pub app_name: Option, + pub user_id: Option, + pub url: Option, +} + +#[derive(Clone)] +pub struct WebhookService { + client: reqwest::Client, + webhook_url: Option, +} + +impl WebhookService { + pub fn new() -> Self { + let webhook_url = std::env::var("WEBHOOK_URL").ok(); + + if webhook_url.is_none() { + warn!("WEBHOOK_URL not configured, webhook notifications will be disabled"); + } else { + info!("Webhook service initialized with URL: {}", webhook_url.as_ref().unwrap()); + } + + Self { + client: reqwest::Client::new(), + webhook_url, + } + } + + pub async fn send_deployment_event( + &self, + deployment_id: Uuid, + status: &str, + event_type: &str, + app_name: Option, + user_id: Option, + url: Option, + ) { + let Some(webhook_url) = &self.webhook_url else { + warn!("Webhook URL not configured, skipping webhook call"); + return; + }; + + let payload = WebhookPayload { + deployment_id, + status: status.to_string(), + event_type: event_type.to_string(), + timestamp: chrono::Utc::now(), + app_name, + user_id, + url, + }; + + info!( + "Sending webhook for deployment {} with status {} and type {}", + deployment_id, status, event_type + ); + + match self + .client + .post(webhook_url) + .json(&payload) + .header("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + info!( + "Webhook sent successfully for deployment {}: {}", + deployment_id, + response.status() + ); + } else { + warn!( + "Webhook request failed for deployment {}: {} - {}", + deployment_id, + response.status(), + response.text().await.unwrap_or_default() + ); + } + } + Err(e) => { + error!( + "Failed to send webhook for deployment {}: {}", + deployment_id, e + ); + } + } + } + + pub async fn send_deployment_completed(&self, deployment_id: Uuid, app_name: Option, user_id: Option, url: Option) { + self.send_deployment_event(deployment_id, "completed", "deployment_completed", app_name, user_id, url).await; + } + + pub async fn send_deployment_failed(&self, deployment_id: Uuid, app_name: Option, user_id: Option) { + self.send_deployment_event(deployment_id, "failed", "deployment_failed", app_name, user_id, None).await; + } + + pub async fn send_deployment_started(&self, deployment_id: Uuid, app_name: Option, user_id: Option) { + self.send_deployment_event(deployment_id, "started", "deployment_started", app_name, user_id, None).await; + } + + pub async fn send_deployment_updated(&self, deployment_id: Uuid, app_name: Option, user_id: Option) { + self.send_deployment_event(deployment_id, "updated", "deployment_updated", app_name, user_id, None).await; + } +} diff --git a/src/user/mod.rs b/src/user/mod.rs index 19c042b..6cbbd7c 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -1,3 +1,3 @@ pub mod models; +pub mod webhook_models; -pub use models::*; \ No newline at end of file diff --git a/src/user/webhook_models.rs b/src/user/webhook_models.rs new file mode 100644 index 0000000..23c4c58 --- /dev/null +++ b/src/user/webhook_models.rs @@ -0,0 +1,98 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +pub struct UserWebhook { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub url: String, + pub secret: Option, + pub is_active: bool, + pub events: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateWebhookRequest { + /// Webhook name (1-100 characters) + #[validate(length(min = 1, max = 100))] + pub name: String, + /// Webhook URL + #[validate(url)] + pub url: String, + /// Optional secret for webhook signature + #[validate(length(max = 255))] + pub secret: Option, + /// Events to subscribe to + pub events: Vec, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateWebhookRequest { + /// Webhook name + #[validate(length(min = 1, max = 100))] + pub name: Option, + /// Webhook URL + #[validate(url)] + pub url: Option, + /// Secret for webhook signature + #[validate(length(max = 255))] + pub secret: Option, + /// Whether webhook is active + pub is_active: Option, + /// Events to subscribe to + pub events: Option>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum WebhookEvent { + DeploymentStarted, + DeploymentCompleted, + DeploymentFailed, + DeploymentDeleted, + All, +} + +impl WebhookEvent { + pub fn as_str(&self) -> &'static str { + match self { + WebhookEvent::DeploymentStarted => "deployment_started", + WebhookEvent::DeploymentCompleted => "deployment_completed", + WebhookEvent::DeploymentFailed => "deployment_failed", + WebhookEvent::DeploymentDeleted => "deployment_deleted", + WebhookEvent::All => "all", + } + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct WebhookResponse { + pub id: Uuid, + pub name: String, + pub url: String, + pub is_active: bool, + pub events: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct WebhookListResponse { + pub webhooks: Vec, + pub total: u64, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct WebhookTestResponse { + pub success: bool, + pub status_code: Option, + pub message: String, + pub response_time_ms: u64, +} From 44930fb77c76b45f3425801ebe02b5fb272f81e2 Mon Sep 17 00:00:00 2001 From: secus Date: Tue, 23 Sep 2025 15:48:05 +0700 Subject: [PATCH 02/11] Update webhook pages styles and error handling --- .../src/components/Layout/DashboardLayout.tsx | 2 +- .../src/pages/ApiKeysPage.tsx | 85 +++++++++---------- .../src/pages/DashboardPage.tsx | 74 ++++++++-------- .../src/pages/WebhooksPage.tsx | 84 +++++++++--------- 4 files changed, 125 insertions(+), 120 deletions(-) diff --git a/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx b/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx index 4c9028f..1ea8dc4 100644 --- a/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx +++ b/apps/container-engine-frontend/src/components/Layout/DashboardLayout.tsx @@ -160,7 +160,7 @@ const Sidebar: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, o
{/* Logout Button */} -
+
@@ -423,8 +423,8 @@ const ApiKeysPage: React.FC = () => { {!loading && !error && ( <> {/* Stats Cards */} -
-
+
+
@@ -436,7 +436,7 @@ const ApiKeysPage: React.FC = () => {
-
+
@@ -448,7 +448,7 @@ const ApiKeysPage: React.FC = () => {
-
+
@@ -464,33 +464,33 @@ const ApiKeysPage: React.FC = () => { {/* Controls */} { apiKeys && apiKeys.length > 0 && ( -
-
- +
+
+ Showing {apiKeys.length > 0 ? ((currentPage - 1) * limit + 1) : 0} to {Math.min(currentPage * limit, total)} of {total} entries
-
- +
+ - entries + entries
) } {/* API Keys Table */} -
+
{total === 0 ? (
@@ -508,57 +508,54 @@ const ApiKeysPage: React.FC = () => {
) : ( <> -
-

Your API Keys

+
+

Your API Keys

- +
- - - - - - - + + + + + + + {apiKeys && apiKeys.length > 0 && apiKeys.map((key:any) => ( - - - -
NameKey IDDescriptionCreatedLast UsedExpiresActionsNameKey IDDescriptionCreatedLast UsedExpiresActions
-
-
+
+
+
- {key.name} + {key.name}
-
- - {maskKeyId(key.id)} - +
+
+ {maskKeyId(key.id)}
- + + {key.description || No description} +
{formatDate(key.created_at)} diff --git a/apps/container-engine-frontend/src/pages/DashboardPage.tsx b/apps/container-engine-frontend/src/pages/DashboardPage.tsx index 226f07d..0f24043 100644 --- a/apps/container-engine-frontend/src/pages/DashboardPage.tsx +++ b/apps/container-engine-frontend/src/pages/DashboardPage.tsx @@ -36,50 +36,50 @@ const DashboardPage: React.FC = () => { return ( -
-

Dashboard

- +
+

Dashboard

+ {loading && ( -
+
-

Loading stats...

+

Loading stats...

)} - + {error && ( -
-
+
+
-
-

{error}

+
+

{error}

)} {stats && ( -
-
-

Total Deployments

-

{stats.deploymentCount}

+
+
+

Total Deployments

+

{stats.deploymentCount}

Active containers

-
-

API Keys

-

{stats.apiKeyCount}

+
+

API Keys

+

{stats.apiKeyCount}

Authentication tokens

-
-

System Status

-
-
-

All Systems Operational

+
+

System Status

+
+
+

All Systems Operational

Last checked: {new Date().toLocaleTimeString()}

@@ -88,45 +88,45 @@ const DashboardPage: React.FC = () => { {/* Quick Actions */} {stats && ( -
-

Quick Actions

-
+ diff --git a/apps/container-engine-frontend/src/pages/WebhooksPage.tsx b/apps/container-engine-frontend/src/pages/WebhooksPage.tsx index 6d75a7f..a7b2ba9 100644 --- a/apps/container-engine-frontend/src/pages/WebhooksPage.tsx +++ b/apps/container-engine-frontend/src/pages/WebhooksPage.tsx @@ -37,7 +37,7 @@ export default function WebhooksPage() { const [showCreateModal, setShowCreateModal] = useState(false); const [editingWebhook, setEditingWebhook] = useState(null); const [testingWebhook, setTestingWebhook] = useState(null); - + // Create/Edit form state const [formData, setFormData] = useState({ name: '', @@ -77,9 +77,9 @@ export default function WebhooksPage() { } else { setError(err?.response?.data?.message || err.message || 'Failed to create webhook'); } + setShowCreateModal(false); // Always close modal on error } }; - const handleUpdateWebhook = async () => { if (!editingWebhook) return; setValidationErrors({}); @@ -89,11 +89,20 @@ export default function WebhooksPage() { setEditingWebhook(null); resetForm(); } catch (err: any) { - if (err?.response?.data?.errors) { - setValidationErrors(err.response.data.errors); + const errors = err?.response?.data?.errors || err?.response?.data?.error || {}; + if (typeof errors === 'object' && Object.keys(errors).length > 0) { + // Show all validation errors as a single string in the global error alert + setError(Object.values(errors).join(' | ')); } else { - setError(err?.response?.data?.message || err.message || 'Failed to update webhook'); + setError( + err?.response?.data?.message || + err?.response?.data?.error?.message || + err.message || + 'Failed to update webhook' + ); } + setEditingWebhook(null); // Always close modal on error + setValidationErrors({}); // Clear modal field errors } }; @@ -177,19 +186,19 @@ export default function WebhooksPage() { return ( -
+
{/* Header */} -
-
-

Webhooks

-

Manage your deployment notification webhooks

+
+
+

Webhooks

+

Manage your deployment notification webhooks

@@ -212,7 +221,7 @@ export default function WebhooksPage() { )} {/* Webhooks List */} -
+
{webhooks.length === 0 ? (
@@ -229,25 +238,25 @@ export default function WebhooksPage() {
) : ( -
    +
      {webhooks.map((webhook) => ( -
    • -
      -
      -
      +
    • +
      +
      +
      {webhook.is_active ? ( - + ) : ( - + )}
      -
      -
      -

      +
      +
      +

      {webhook.name}

      -
      -

      +

      {webhook.url}

      -

      +

      Events: {webhook.events.map(getEventLabel).join(', ')}

      -

      +

      Created: {new Date(webhook.created_at).toLocaleDateString()}

      -
      +
      + {pods.map((pod: any) => ( + + ))} + {isLoadingPods && ( +
      Loading pods...
      + )} +
      +
      + {error && (
      {error} @@ -319,7 +432,7 @@ export default function LogsPage() {
      {logs.length > 0 ? ( <> - {logs.map((log) => ( + {logs.map(log => (
      {new Date(log.timestamp).toLocaleTimeString()} @@ -327,6 +440,11 @@ export default function LogsPage() { {log.isHistorical ? '◦' : '│'} + {selectedPod === 'all' && log.podName !== 'merged' && ( + + [{log.podName}] + + )} {log.message}
      ))} @@ -339,7 +457,10 @@ export default function LogsPage() { {isLoadingHistory || isConnecting ? 'Loading logs...' : 'No logs available at the moment.'}

      - Logs will appear here once your application starts generating them. + {selectedPod === 'all' + ? 'Logs from all pods will appear here once your application starts generating them.' + : `Logs from pod "${selectedPod}" will appear here once it starts generating them.` + }

      )} @@ -363,6 +484,7 @@ export default function LogsPage() {
      {logs.filter(log => log.isHistorical).length} historical + {logs.filter(log => !log.isHistorical).length} live logs + {selectedPod !== 'all' && ` from ${selectedPod}`} Total: {logs.length} lines
      @@ -386,4 +508,4 @@ export default function LogsPage() { `}
      ); -} +} \ No newline at end of file diff --git a/apps/container-engine-frontend/src/pages/DocumentationPage.tsx b/apps/container-engine-frontend/src/pages/DocumentationPage.tsx index 5485e18..dd37a56 100644 --- a/apps/container-engine-frontend/src/pages/DocumentationPage.tsx +++ b/apps/container-engine-frontend/src/pages/DocumentationPage.tsx @@ -1,7 +1,7 @@ // src/pages/DocumentationPage.tsx import React, { useState } from 'react'; import { Link } from 'react-router-dom'; -import { +import { BookOpenIcon, RocketLaunchIcon, CodeBracketIcon, @@ -69,7 +69,7 @@ const DocumentationPage: React.FC = () => {