diff --git a/Cargo.lock b/Cargo.lock index 300df514b..af7f65834 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3239,6 +3239,7 @@ dependencies = [ "flate2", "futures", "handlebars 5.1.2", + "hmac", "hyper", "indexmap 2.5.0", "infer", diff --git a/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json b/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json new file mode 100644 index 000000000..fdd295fed --- /dev/null +++ b/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE webhook_deliveries\n SET status = $2, error = $3, request_headers = $4, request_body = $5, response_http_code = null, response_headers = null, response_body = null\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "webhook_delivery_status", + "kind": { + "Enum": [ + "pending", + "success", + "failure", + "retrying" + ] + } + } + }, + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd" +} diff --git a/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json b/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json new file mode 100644 index 000000000..23906f801 --- /dev/null +++ b/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json @@ -0,0 +1,118 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2) ORDER BY created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f" +} diff --git a/api/.sqlx/query-2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9.json b/api/.sqlx/query-2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9.json new file mode 100644 index 000000000..7235ec9ab --- /dev/null +++ b/api/.sqlx/query-2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO webhook_events (scope, package, event, payload) VALUES ($1, $2, $3, $4) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + }, + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2741c92898bfa9147c1ec3a23f346082c2ef7e1ef2b6694ce6e64a7859da7ff9" +} diff --git a/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json b/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json new file mode 100644 index 000000000..abb55ae57 --- /dev/null +++ b/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE webhook_deliveries\n SET status = $2, request_headers = $3, request_body = $4, response_http_code = $5, response_headers = $6, response_body = $7, error = null\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "webhook_delivery_status", + "kind": { + "Enum": [ + "pending", + "success", + "failure", + "retrying" + ] + } + } + }, + "Jsonb", + "Jsonb", + "Int4", + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058" +} diff --git a/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json b/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json new file mode 100644 index 000000000..282e43361 --- /dev/null +++ b/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json @@ -0,0 +1,155 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND endpoint_id = $3 ORDER BY webhook_deliveries.created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "webhook_delivery_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "webhook_delivery_endpoint_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "webhook_delivery_event_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "webhook_delivery_status: WebhookDeliveryStatus", + "type_info": { + "Custom": { + "name": "webhook_delivery_status", + "kind": { + "Enum": [ + "pending", + "success", + "failure", + "retrying" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "webhook_delivery_request_headers", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "webhook_delivery_request_body", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "webhook_delivery_response_http_code", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "webhook_delivery_response_headers", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "webhook_delivery_response_body", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "webhook_delivery_error", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "webhook_delivery_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "webhook_delivery_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "webhook_event_id", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "webhook_event_scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "webhook_event_package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "webhook_event_event: WebhookEventKind", + "type_info": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + }, + { + "ordinal": 16, + "name": "webhook_event_payload: WebhookPayload", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "webhook_event_created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a" +} diff --git a/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json new file mode 100644 index 000000000..6948175ee --- /dev/null +++ b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json @@ -0,0 +1,119 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166" +} diff --git a/api/.sqlx/query-8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff.json b/api/.sqlx/query-8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff.json new file mode 100644 index 000000000..48a88872c --- /dev/null +++ b/api/.sqlx/query-8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO webhook_deliveries (endpoint_id, event_id)\n SELECT webhook_endpoints.id, $1 FROM webhook_endpoints\n WHERE webhook_endpoints.scope = $2 AND (webhook_endpoints.package IS NULL OR webhook_endpoints.package = $3) AND $4 = ANY(webhook_endpoints.events) AND webhook_endpoints.is_active = TRUE\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + ] + }, + "nullable": [ + false + ] + }, + "hash": "8197bee3c5fe91b17dcfa40a7f5e608dbd3b65e43086a172151e0eba0ef1ffff" +} diff --git a/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json b/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json new file mode 100644 index 000000000..516392d58 --- /dev/null +++ b/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json @@ -0,0 +1,160 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE webhook_endpoints SET url = COALESCE($4, url), description = COALESCE($5, description), secret = COALESCE($6, secret), events = COALESCE($7, events), payload_format = COALESCE($8, payload_format), is_active = COALESCE($9, is_active) WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid", + "Text", + "Text", + "Varchar", + { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + }, + { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + }, + "Bool" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a" +} diff --git a/api/.sqlx/query-a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135.json b/api/.sqlx/query-a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135.json new file mode 100644 index 000000000..e060d279e --- /dev/null +++ b/api/.sqlx/query-a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n webhook_endpoints.url as \"url\", webhook_events.event as \"event: _\", webhook_events.id as \"event_id\", webhook_endpoints.secret as \"secret\", webhook_endpoints.payload_format AS \"payload_format: _\", webhook_events.payload as \"payload: WebhookPayload\"\n FROM webhook_endpoints\n LEFT JOIN webhook_deliveries ON webhook_endpoints.id = webhook_deliveries.endpoint_id\n LEFT JOIN webhook_events ON webhook_events.id = webhook_deliveries.event_id\n WHERE webhook_deliveries.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "event: _", + "type_info": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "event_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "payload: WebhookPayload", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false + ] + }, + "hash": "a951281e9bd7c33e5068faeaab8423231bb548d6558610b649455941a5e0b135" +} diff --git a/api/.sqlx/query-bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec.json b/api/.sqlx/query-bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec.json new file mode 100644 index 000000000..53059c972 --- /dev/null +++ b/api/.sqlx/query-bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM webhook_endpoints WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "bed5298f4d6221999e4ff4b6a06f8338ccd5d8ce730eb0d0fc6ec1a9e2aa71ec" +} diff --git a/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json b/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json new file mode 100644 index 000000000..26ccb23fa --- /dev/null +++ b/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json @@ -0,0 +1,156 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND webhook_deliveries.endpoint_id = $3 AND webhook_deliveries.id = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "webhook_delivery_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "webhook_delivery_endpoint_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "webhook_delivery_event_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "webhook_delivery_status: WebhookDeliveryStatus", + "type_info": { + "Custom": { + "name": "webhook_delivery_status", + "kind": { + "Enum": [ + "pending", + "success", + "failure", + "retrying" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "webhook_delivery_request_headers", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "webhook_delivery_request_body", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "webhook_delivery_response_http_code", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "webhook_delivery_response_headers", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "webhook_delivery_response_body", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "webhook_delivery_error", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "webhook_delivery_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "webhook_delivery_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "webhook_event_id", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "webhook_event_scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "webhook_event_package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "webhook_event_event: WebhookEventKind", + "type_info": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + }, + { + "ordinal": 16, + "name": "webhook_event_payload: WebhookPayload", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "webhook_event_created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9" +} diff --git a/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json b/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json new file mode 100644 index 000000000..649ef2852 --- /dev/null +++ b/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json @@ -0,0 +1,159 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format, is_active)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Varchar", + { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_npm_tarball_ready", + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + }, + { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + }, + "Bool" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf" +} diff --git a/api/Cargo.toml b/api/Cargo.toml index c1e666bc7..5de66a500 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -34,6 +34,7 @@ once_cell = "1" percent-encoding = "2" rand = "0.8" sha2 = "0.10.7" +hmac = "0.12.1" crc32fast = "1.3.2" routerify = "3" routerify-query = "3" diff --git a/api/migrations/20251221141049_webhook.sql b/api/migrations/20251221141049_webhook.sql new file mode 100644 index 000000000..92e9d0e15 --- /dev/null +++ b/api/migrations/20251221141049_webhook.sql @@ -0,0 +1,70 @@ +CREATE TYPE webhook_event_kind AS ENUM ( + 'package_version_npm_tarball_ready', + 'package_version_published', + 'package_version_yanked', + 'package_version_deleted', + 'scope_package_created', + 'scope_package_deleted', + 'scope_package_archived', + 'scope_member_added', + 'scope_member_removed' +); + +CREATE TYPE webhook_payload_format AS ENUM ( + 'json', + 'discord', + 'slack' +); + +CREATE TABLE webhook_endpoints ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + scope TEXT NOT NULL references scopes (scope) ON DELETE CASCADE, + package TEXT, + url TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + secret VARCHAR(255), + events webhook_event_kind[] NOT NULL, + payload_format webhook_payload_format NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + updated_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + + FOREIGN KEY (scope, package) REFERENCES packages (scope, name) ON DELETE CASCADE +); + +SELECT manage_updated_at('webhook_endpoints'); + +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + scope TEXT NOT NULL references scopes (scope) ON DELETE CASCADE, + package TEXT, + event webhook_event_kind NOT NULL, + payload JSONB NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + + FOREIGN KEY (scope, package) REFERENCES packages (scope, name) ON DELETE CASCADE +); + +CREATE TYPE webhook_delivery_status AS ENUM ( + 'pending', 'success', 'failure', 'retrying' +); + +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + endpoint_id UUID NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE, + event_id UUID NOT NULL REFERENCES webhook_events(id) ON DELETE CASCADE, + status webhook_delivery_status NOT NULL DEFAULT 'pending', + + request_headers JSONB, + request_body JSONB, + + response_http_code INT, + response_headers JSONB, + response_body TEXT, + + error TEXT, + + updated_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now() +); +SELECT manage_updated_at('webhook_deliveries'); diff --git a/api/src/api/admin.rs b/api/src/api/admin.rs index 1323eb71d..285aa203e 100644 --- a/api/src/api/admin.rs +++ b/api/src/api/admin.rs @@ -17,6 +17,7 @@ use crate::db::*; use crate::iam::ReqIamExt; use crate::ids::ScopeDescription; use crate::publish::publish_task; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::LicenseStore; @@ -297,6 +298,8 @@ pub async fn requeue_publishing_tasks(req: Request) -> ApiResult<()> { } let publish_queue = req.data::().unwrap().0.clone(); + let webhook_dispatch_queue = + req.data::().unwrap().clone(); let orama_client = req.data::>().unwrap().clone(); if let Some(queue) = publish_queue { @@ -316,6 +319,7 @@ pub async fn requeue_publishing_tasks(req: Request) -> ApiResult<()> { registry, npm_url, db, + webhook_dispatch_queue, orama_client, ) .instrument(span); diff --git a/api/src/api/errors.rs b/api/src/api/errors.rs index 52affa89b..918c07dc6 100644 --- a/api/src/api/errors.rs +++ b/api/src/api/errors.rs @@ -253,6 +253,11 @@ errors!( status: BAD_REQUEST, "The metadata for the ticket is not in a valid format, should be a key-value of strings.", }, + WebhookResponseFailure { + status: BAD_REQUEST, + fields: { status: reqwest::StatusCode }, + ({ status }) => "The webhook target responded with status {status}.", + }, ); pub fn map_unique_violation(err: sqlx::Error, new_err: ApiError) -> ApiError { diff --git a/api/src/api/package.rs b/api/src/api/package.rs index 7da33794c..81d721663 100644 --- a/api/src/api/package.rs +++ b/api/src/api/package.rs @@ -55,14 +55,15 @@ use crate::auth::GithubOauth2Client; use crate::auth::access_token; use crate::buckets::Buckets; use crate::buckets::UploadTaskBody; -use crate::db::CreatePackageResult; use crate::db::CreatePublishingTaskResult; use crate::db::Database; use crate::db::NewGithubRepository; use crate::db::NewPublishingTask; +use crate::db::NewWebhookEndpoint; use crate::db::Package; use crate::db::RuntimeCompat; use crate::db::User; +use crate::db::{CreatePackageResult, UpdateWebhookEndpoint}; use crate::docs::DocNodesByUrl; use crate::docs::DocsRequest; use crate::docs::GeneratedDocsOutput; @@ -81,6 +82,7 @@ use crate::orama::OramaClient; use crate::provenance; use crate::publish::publish_task; use crate::tarball::gcs_tarball_path; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::CacheDuration; @@ -91,7 +93,6 @@ use crate::util::decode_json; use crate::util::pagination; use crate::util::search; -use super::ApiCreatePackageRequest; use super::ApiDependency; use super::ApiDependencyGraphItem; use super::ApiDependent; @@ -116,6 +117,9 @@ use super::ApiStats; use super::ApiUpdatePackageGithubRepositoryRequest; use super::ApiUpdatePackageRequest; use super::ApiUpdatePackageVersionRequest; +use super::ApiWebhookEndpoint; +use super::{ApiCreatePackageRequest, ApiUpdateWebhookEndpointRequest}; +use super::{ApiCreateWebhookEndpointRequest, ApiWebhookDelivery}; const MAX_PUBLISH_TARBALL_SIZE: u64 = 20 * 1024 * 1024; // 20mb @@ -192,6 +196,34 @@ pub fn package_router() -> Router { util::json(list_publishing_tasks_handler), ) .get("/:package/score", util::json(get_score_handler)) + .post( + "/:package/webhooks", + util::auth(util::json(create_webhook_handler)), + ) + .get( + " /:package/webhooks/:webhook", + util::auth(util::json(get_webhook_handler)), + ) + .get( + "/:package/webhooks", + util::auth(util::json(list_webhooks_handler)), + ) + .patch( + "/:package/webhooks/:webhook", + util::auth(util::json(update_webhook_handler)), + ) + .delete( + "/:package/webhooks/:webhook", + util::auth(delete_webhook_handler), + ) + .get( + "/:package/webhooks/:webhook/deliveries", + util::auth(util::json(list_webhook_deliveries_handler)), + ) + .get( + "/:package/webhooks/:webhook/deliveries/:delivery", + util::auth(util::json(get_webhook_delivery_handler)), + ) .build() .unwrap() } @@ -298,14 +330,19 @@ pub async fn create_handler(mut req: Request) -> ApiResult { iam.check_scope_write_access(&scope).await?; let db = req.data::().unwrap(); + let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); if db.check_is_bad_word(&package_name.to_string()).await? { return Err(ApiError::PackageNameNotAllowed); } let res = db.create_package(&scope, &package_name).await?; - let package = match res { - CreatePackageResult::Ok(package) => package, + let (package, webhook_deliveries) = match res { + CreatePackageResult::Ok { + package, + webhook_deliveries, + } => (package, webhook_deliveries), CreatePackageResult::AlreadyExists => { return Err(ApiError::PackageAlreadyExists); } @@ -317,6 +354,14 @@ pub async fn create_handler(mut req: Request) -> ApiResult { } }; + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + registry_url, + webhook_deliveries, + ) + .await?; + let orama_client = req.data::>().unwrap(); if let Some(orama_client) = orama_client { orama_client.upsert_package(&package, &Default::default()); @@ -471,7 +516,7 @@ pub async fn update_handler(mut req: Request) -> ApiResult { Ok(ApiPackage::from((package, repo, meta))) } ApiUpdatePackageRequest::IsArchived(is_archived) => { - let package = db + let (package, webhook_deliveries) = db .update_package_is_archived( &user.id, sudo, @@ -481,6 +526,17 @@ pub async fn update_handler(mut req: Request) -> ApiResult { ) .await?; + let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); + + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + registry_url, + webhook_deliveries, + ) + .await?; + if let Some(orama_client) = orama_client { if package.is_archived { orama_client.delete_package(&scope, &package.name); @@ -682,6 +738,8 @@ pub async fn delete_handler(req: Request) -> ApiResult> { let package = req.param_package()?; let db: &Database = req.data::().unwrap(); + let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); let _ = db .get_package(&scope, &package) @@ -691,11 +749,20 @@ pub async fn delete_handler(req: Request) -> ApiResult> { let iam = req.iam(); let (user, sudo) = iam.check_scope_admin_access(&scope).await?; - let deleted = db.delete_package(&user.id, sudo, &scope, &package).await?; + let (deleted, webhook_deliveries) = + db.delete_package(&user.id, sudo, &scope, &package).await?; if !deleted { return Err(ApiError::PackageNotEmpty); } + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + registry_url, + webhook_deliveries, + ) + .await?; + let orama_client = req.data::>().unwrap(); if let Some(orama_client) = orama_client { orama_client.delete_package(&scope, &package); @@ -797,6 +864,8 @@ pub async fn version_publish_handler( let registry_url = req.data::().unwrap().0.clone(); let npm_url = req.data::().unwrap().0.clone(); let publish_queue = req.data::().unwrap().0.clone(); + let webhook_dispatch_queue = + req.data::().unwrap().clone(); let orama_client = req.data::>().unwrap().clone(); let iam = req.iam(); @@ -905,6 +974,7 @@ pub async fn version_publish_handler( registry_url, npm_url, db, + webhook_dispatch_queue, orama_client, ) .instrument(span); @@ -984,17 +1054,28 @@ pub async fn version_update_handler( let db = req.data::().unwrap(); let buckets = req.data::().unwrap().clone(); let npm_url = &req.data::().unwrap().0; + let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); let iam = req.iam(); let (user, sudo) = iam.check_scope_admin_access(&scope).await?; - db.yank_package_version( - &user.id, - sudo, - &scope, - &package, - &version, - body.yanked, + let (_, webhook_deliveries) = db + .yank_package_version( + &user.id, + sudo, + &scope, + &package, + &version, + body.yanked, + ) + .await?; + + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + registry_url, + webhook_deliveries, ) .await?; @@ -1076,9 +1157,20 @@ pub async fn version_delete_handler( return Err(ApiError::DeleteVersionHasDependents); } - db.delete_package_version(&staff.id, &scope, &package, &version) + let webhook_deliveries = db + .delete_package_version(&staff.id, &scope, &package, &version) .await?; + let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + registry_url, + webhook_deliveries, + ) + .await?; + let path = crate::gcs_paths::docs_v1_path(&scope, &package, &version); buckets.docs_bucket.delete_file(path.into()).await?; @@ -2384,6 +2476,240 @@ pub async fn get_score_handler( Ok(ApiPackageScore::from((&meta, &pkg))) } +#[instrument( + name = "POST /api/scopes/:scope/packages/:package/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn create_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + Span::current().record("scope", field::display(&scope)); + + let ApiCreateWebhookEndpointRequest { + url, + description, + secret, + events, + payload_format, + is_active, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .create_webhook_endpoint( + NewWebhookEndpoint { + scope: &scope, + package: Some(&package), + url: &url, + description: &description, + secret: secret.as_deref(), + events, + payload_format, + is_active, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn get_webhook_handler( + req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .get_webhook_endpoint(&scope, Some(&package), webhook_id) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "PATCH /api/scopes/:scope/packages/:package/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn update_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let ApiUpdateWebhookEndpointRequest { + url, + description, + secret, + events, + payload_format, + is_active, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .update_webhook_endpoint( + &scope, + Some(&package), + webhook_id, + UpdateWebhookEndpoint { + url, + description, + secret, + events, + payload_format, + is_active, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn list_webhooks_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let package = req.param_package()?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoints = + db.list_webhook_endpoints(&scope, Some(&package)).await?; + + Ok(webhook_endpoints.into_iter().map(Into::into).collect()) +} + +#[instrument( + name = "DELETE /api/scopes/:scope/packages/:package/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn delete_webhook_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + db.delete_webhook_endpoint( + &user.id, + sudo, + &scope, + Some(&package), + webhook_id, + ) + .await?; + + let res = Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap(); + Ok(res) +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks/:webhook/deliveries", + skip(req), + err, + fields(scope) +)] +pub async fn list_webhook_deliveries_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoints = db + .list_webhook_deliveries(&scope, Some(&package), webhook_id) + .await?; + + Ok(webhook_endpoints.into_iter().map(Into::into).collect()) +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks/:webhook/deliveries/:delivery", + skip(req), + err, + fields(scope) +)] +pub async fn get_webhook_delivery_handler( + req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + let delivery_id = req.param_uuid("delivery")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .get_webhook_delivery(&scope, Some(&package), webhook_id, delivery_id) + .await?; + + Ok(webhook_endpoint.into()) +} + #[cfg(test)] mod test { use hyper::Body; @@ -2455,7 +2781,7 @@ mod test { ) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); } let mut resp = t.http().get("/api/packages").call().await.unwrap(); @@ -2652,7 +2978,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -2706,7 +3032,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -2764,7 +3090,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -2819,7 +3145,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); t.ephemeral_database .create_package_version_for_test(NewPackageVersion { @@ -2989,7 +3315,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); t.ephemeral_database .create_package_version_for_test(NewPackageVersion { @@ -3058,7 +3384,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3154,7 +3480,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3246,7 +3572,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3312,7 +3638,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3382,7 +3708,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let res = t.ephemeral_database.create_package(&scope, &name).await; if i < 11 { - assert!(matches!(res.unwrap(), CreatePackageResult::Ok(_))); + assert!(matches!(res.unwrap(), CreatePackageResult::Ok { .. })); } else { assert!(matches!( res.unwrap(), @@ -3413,7 +3739,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let res = t.ephemeral_database.create_package(&scope, &name).await; if i < 11 { - assert!(matches!(res.unwrap(), CreatePackageResult::Ok(_))); + assert!(matches!(res.unwrap(), CreatePackageResult::Ok { .. })); } else { assert!(matches!( res.unwrap(), @@ -3442,7 +3768,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let name = PackageName::new("foo".to_owned()).unwrap(); let config_file = PackagePath::try_from("/jsr.json").unwrap(); - let CreatePackageResult::Ok(package) = + let CreatePackageResult::Ok { package, .. } = t.db().create_package(&scope, &name).await.unwrap() else { unreachable!(); @@ -3507,7 +3833,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let name = PackageName::new("foo".to_owned()).unwrap(); - let CreatePackageResult::Ok(_) = + let CreatePackageResult::Ok { .. } = t.db().create_package(&scope, &name).await.unwrap() else { unreachable!(); @@ -4008,7 +4334,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let url = format!("/api/scopes/{}/packages/{}", scope, name); let mut resp = t.http().delete(url).call().await.unwrap(); @@ -4044,7 +4370,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let url = format!("/api/scopes/{}/packages/{}", scope, name); let token = t.user2.token.clone(); @@ -4072,7 +4398,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let url = format!("/api/scopes/{}/packages/{}", scope, name); let token = t.user3.token.clone(); @@ -4125,7 +4451,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let version = Version::try_from("1.2.3").unwrap(); let config_file = PackagePath::try_from("/jsr.json").unwrap(); @@ -4159,7 +4485,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() diff --git a/api/src/api/scope.rs b/api/src/api/scope.rs index 5bf48446c..d866ff537 100644 --- a/api/src/api/scope.rs +++ b/api/src/api/scope.rs @@ -25,6 +25,7 @@ use super::types::*; use crate::auth::GithubOauth2Client; use crate::auth::lookup_user_by_github_login; use crate::db::*; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::RequestIdExt; @@ -55,6 +56,34 @@ pub fn scope_router() -> Router { "/:scope/invites/:user_id", util::auth(delete_invite_handler), ) + .post( + "/:scope/webhooks", + util::auth(util::json(create_webhook_handler)), + ) + .get( + "/:scope/webhooks", + util::auth(util::json(list_webhooks_handler)), + ) + .get( + "/:scope/webhooks/:webhook", + util::auth(util::json(get_webhook_handler)), + ) + .patch( + "/:scope/webhooks/:webhook", + util::auth(util::json(update_webhook_handler)), + ) + .delete( + "/:scope/webhooks/:webhook", + util::auth(delete_webhook_handler), + ) + .get( + "/:scope/webhooks/:webhook/deliveries", + util::auth(util::json(list_webhook_deliveries_handler)), + ) + .get( + "/:scope/webhooks/:webhook/deliveries/:delivery", + util::auth(util::json(get_webhook_delivery_handler)), + ) .build() .unwrap() } @@ -348,7 +377,7 @@ async fn update_member_handler( .await?; let scope_member = match res { - ScopeMemberUpdateResult::Ok(scope_member) => scope_member, + ScopeMemberUpdateResult::Ok { scope_member, .. } => scope_member, ScopeMemberUpdateResult::TargetIsLastTransferableAdmin => { return Err(ApiError::NoScopeOwnerAvailable); } @@ -386,6 +415,8 @@ pub async fn delete_member_handler( Span::current().record("member", field::display(&member_id)); let db = req.data::().unwrap(); + let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); db.get_scope(&scope).await?.ok_or(ApiError::ScopeNotFound)?; @@ -396,7 +427,17 @@ pub async fn delete_member_handler( let res = db.delete_scope_member(&scope, member_id).await?; match res { - ScopeMemberUpdateResult::Ok(_) => {} + ScopeMemberUpdateResult::Ok { + webhook_deliveries, .. + } => { + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + registry_url, + webhook_deliveries.unwrap(), + ) + .await?; + } ScopeMemberUpdateResult::TargetIsLastTransferableAdmin => { return Err(ApiError::NoScopeOwnerAvailable); } @@ -475,6 +516,224 @@ pub async fn delete_invite_handler( Ok(resp) } +#[instrument( + name = "POST /api/scopes/:scope/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn create_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + Span::current().record("scope", field::display(&scope)); + + let ApiCreateWebhookEndpointRequest { + url, + description, + secret, + events, + payload_format, + is_active, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .create_webhook_endpoint( + NewWebhookEndpoint { + scope: &scope, + package: None, + url: &url, + description: &description, + secret: secret.as_deref(), + events, + payload_format, + is_active, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn get_webhook_handler( + req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = + db.get_webhook_endpoint(&scope, None, webhook_id).await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "PATCH /api/scopes/:scope/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn update_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let ApiUpdateWebhookEndpointRequest { + url, + description, + secret, + events, + payload_format, + is_active, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .update_webhook_endpoint( + &scope, + None, + webhook_id, + UpdateWebhookEndpoint { + url, + description, + secret, + events, + payload_format, + is_active, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn list_webhooks_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoints = db.list_webhook_endpoints(&scope, None).await?; + + Ok(webhook_endpoints.into_iter().map(Into::into).collect()) +} + +#[instrument( + name = "DELETE /api/scopes/:scope/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn delete_webhook_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + db.delete_webhook_endpoint(&user.id, sudo, &scope, None, webhook_id) + .await?; + + let res = Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap(); + Ok(res) +} + +#[instrument( + name = "GET /api/scopes/:scope/webhooks/:webhook/deliveries", + skip(req), + err, + fields(scope) +)] +pub async fn list_webhook_deliveries_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoints = + db.list_webhook_deliveries(&scope, None, webhook_id).await?; + + Ok(webhook_endpoints.into_iter().map(Into::into).collect()) +} + +#[instrument( + name = "GET /api/scopes/:scope/webhooks/:webhook/deliveries/:delivery", + skip(req), + err, + fields(scope) +)] +pub async fn get_webhook_delivery_handler( + req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + let delivery_id = req.param_uuid("delivery")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .get_webhook_delivery(&scope, None, webhook_id, delivery_id) + .await?; + + Ok(webhook_endpoint.into()) +} + #[cfg(test)] pub mod tests { use super::*; diff --git a/api/src/api/self_user.rs b/api/src/api/self_user.rs index b7244fc39..2c40dfb1a 100644 --- a/api/src/api/self_user.rs +++ b/api/src/api/self_user.rs @@ -20,6 +20,7 @@ use crate::db::UserPublic; use crate::emails::EmailArgs; use crate::emails::EmailSender; use crate::iam::ReqIamExt; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::RequestIdExt; @@ -134,12 +135,22 @@ pub async fn accept_invite_handler( let current_user = iam.check_current_user_access()?.to_owned(); let db = req.data::().unwrap(); + let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); - let member = db + let (member, webhook_deliveries) = db .accept_scope_invite(¤t_user.id, &scope) .await? .ok_or(ApiError::ScopeInviteNotFound)?; + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + registry_url, + webhook_deliveries, + ) + .await?; + Ok((member, UserPublic::from(current_user)).into()) } diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 3d6723f68..31a84efbd 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -1173,3 +1173,93 @@ impl From<(AuditLog, UserPublic)> for ApiAuditLog { } } } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiCreateWebhookEndpointRequest { + pub url: String, + pub description: String, + pub secret: Option, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, + pub is_active: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiUpdateWebhookEndpointRequest { + pub url: Option, + pub description: Option, + pub secret: Option, // TODO: it already is an option, how to distinguish between clearing and not changing it? + pub events: Option>, + pub payload_format: Option, + pub is_active: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiWebhookEndpoint { + pub id: Uuid, + pub scope: ScopeName, + pub package: Option, + pub url: String, + pub description: String, + pub has_secret: bool, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, + pub is_active: bool, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +impl From for ApiWebhookEndpoint { + fn from(value: WebhookEndpoint) -> Self { + Self { + id: value.id, + scope: value.scope, + package: value.package, + url: value.url, + description: value.description, + has_secret: value.secret.is_some(), + events: value.events, + payload_format: value.payload_format, + is_active: value.is_active, + updated_at: value.updated_at, + created_at: value.created_at, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiWebhookDelivery { + pub id: Uuid, + pub status: WebhookDeliveryStatus, + pub event: WebhookEventKind, + pub request_headers: Option, + pub request_body: Option, + pub response_http_code: Option, + pub response_headers: Option, + pub response_body: Option, + pub error: Option, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +impl From<(WebhookDelivery, WebhookEvent)> for ApiWebhookDelivery { + fn from((delivery, event): (WebhookDelivery, WebhookEvent)) -> Self { + Self { + id: delivery.id, + status: delivery.status, + event: event.event, + request_headers: delivery.request_headers, + request_body: delivery.request_body, + response_http_code: delivery.response_http_code, + response_headers: delivery.response_headers, + response_body: delivery.response_body, + error: delivery.error, + updated_at: delivery.updated_at, + created_at: delivery.created_at, + } + } +} diff --git a/api/src/config.rs b/api/src/config.rs index ca75aa9ae..b32c664a5 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -134,6 +134,13 @@ pub struct Config { /// The ID of the npm tarball build queue. pub npm_tarball_build_queue_id: Option, + #[clap( + long = "webhook_dispatch_queue_id", + env = "WEBHOOK_DISPATCH_QUEUE_ID" + )] + /// The ID of the npm tarball build queue. + pub webhook_dispatch_queue_id: Option, + #[clap(long = "logs_bigquery_table_id", env = "LOGS_BIGQUERY_TABLE_ID")] /// The ID of the logs table in BigQuery that is used for download analysis. pub logs_bigquery_table_id: Option, diff --git a/api/src/db/database.rs b/api/src/db/database.rs index d7f6cf0ee..790a040fb 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -567,11 +567,7 @@ impl Database { } }; - if let Some(res) = finalize_package_creation(tx, scope).await? { - return Ok(res); - }; - - Ok(CreatePackageResult::Ok(package)) + finalize_package_creation(tx, scope, package).await } #[instrument( @@ -888,7 +884,7 @@ impl Database { scope: &ScopeName, name: &PackageName, is_archived: bool, - ) -> Result { + ) -> Result<(Package, Vec)> { let mut tx = self.pool.begin().await?; audit_log( @@ -919,9 +915,22 @@ impl Database { .fetch_one(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopePackageArchived, + WebhookPayload::ScopePackageArchived { + scope: scope.clone(), + package: name.clone(), + archived: is_archived, + }, + ) + .await?; + tx.commit().await?; - Ok(package) + Ok((package, webhook_deliveries)) } #[instrument(name = "Database::update_package_source", skip(self), err)] @@ -1955,8 +1964,8 @@ impl Database { scope as _, name as _, ) - .fetch_all(&self.pool) - .await + .fetch_all(&self.pool) + .await } #[instrument( @@ -2231,7 +2240,7 @@ impl Database { name: &PackageName, version: &Version, yank: bool, - ) -> Result { + ) -> Result<(PackageVersion, Vec)> { let mut tx = self.pool.begin().await?; audit_log( @@ -2274,9 +2283,23 @@ impl Database { .fetch_one(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + Some(name), + WebhookEventKind::PackageVersionYanked, + WebhookPayload::PackageVersionYanked { + scope: scope.clone(), + package: name.clone(), + version: version.clone(), + yanked: yank, + }, + ) + .await?; + tx.commit().await?; - Ok(package_version) + Ok((package_version, webhook_deliveries)) } #[instrument(name = "Database::delete_package_version", skip(self), err)] @@ -2286,7 +2309,7 @@ impl Database { scope: &ScopeName, name: &PackageName, version: &Version, - ) -> Result<()> { + ) -> Result> { let mut tx = self.pool.begin().await?; audit_log( @@ -2302,8 +2325,7 @@ impl Database { ) .await?; - sqlx::query_as!( - PackageVersion, + sqlx::query!( r#"DELETE FROM package_versions WHERE scope = $1 AND name = $2 AND version = $3"#, scope as _, name as _, @@ -2312,9 +2334,22 @@ impl Database { .execute(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + Some(name), + WebhookEventKind::PackageVersionDeleted, + WebhookPayload::PackageVersionDeleted { + scope: scope.clone(), + package: name.clone(), + version: version.clone(), + }, + ) + .await?; + tx.commit().await?; - Ok(()) + Ok(webhook_deliveries) } #[instrument(name = "Database::list_package_files", skip(self), err)] @@ -2388,6 +2423,36 @@ impl Database { .fetch_one(&self.pool) .await } + #[instrument( + name = "Database::process_webhooks_for_npm_tarball", + skip(self), + err + )] + pub async fn process_webhooks_for_npm_tarball( + &self, + package_scope: &ScopeName, + package_name: &PackageName, + package_version: &Version, + ) -> Result> { + let mut tx = self.pool.begin().await?; + + let webhook_deliveries = insert_webhook_event( + &mut tx, + package_scope, + Some(package_name), + WebhookEventKind::PackageVersionNpmTarballReady, + WebhookPayload::PackageVersionNpmTarballReady { + scope: package_scope.clone(), + package: package_name.clone(), + version: package_version.clone(), + }, + ) + .await?; + + tx.commit().await?; + + Ok(webhook_deliveries) + } #[instrument(name = "Database::get_scope_member", skip(self), err)] pub async fn get_scope_member( @@ -2625,7 +2690,7 @@ impl Database { &self, target_user_id: &Uuid, scope: &ScopeName, - ) -> Result> { + ) -> Result)>> { let mut tx = self.pool.begin().await?; let res = sqlx::query!( @@ -2649,9 +2714,21 @@ impl Database { .fetch_one(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopeMemberAdded, + WebhookPayload::ScopeMemberAdded { + scope: scope.clone(), + user_id: *target_user_id, + }, + ) + .await?; + tx.commit().await?; - Ok(Some(member)) + Ok(Some((member, webhook_deliveries))) } #[instrument(name = "Database::delete_scope_invite", skip(self), err)] @@ -2696,7 +2773,7 @@ impl Database { is_sudo: bool, scope: &ScopeName, name: &PackageName, - ) -> Result { + ) -> Result<(bool, Vec)> { let mut tx = self.pool.begin().await?; audit_log( @@ -2718,7 +2795,7 @@ impl Database { .fetch_one(&mut *tx) .await?; if status.count.unwrap() > 0 { - return Ok(false); + return Ok((false, vec![])); } let res = sqlx::query!( @@ -2732,16 +2809,33 @@ impl Database { match res { Ok(res) => { let success = res.rows_affected() > 0; - if success { + + let webhook_deliveries = if success { + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopePackageDeleted, + WebhookPayload::ScopePackageDeleted { + scope: scope.clone(), + package: name.clone(), + }, + ) + .await?; + tx.commit().await?; - } - Ok(success) + + webhook_deliveries + } else { + vec![] + }; + Ok((success, webhook_deliveries)) } Err(err) => { if let Some(dberr) = err.as_database_error() && dberr.is_foreign_key_violation() { - return Ok(false); + return Ok((false, vec![])); } Err(err) } @@ -2912,7 +3006,10 @@ impl Database { tx.commit().await?; - Ok(ScopeMemberUpdateResult::Ok(scope_member)) + Ok(ScopeMemberUpdateResult::Ok { + scope_member, + webhook_deliveries: None, + }) } #[instrument(name = "Database::delete_scope_member", skip(self), err)] @@ -2954,9 +3051,24 @@ impl Database { return Ok(result); } + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopeMemberRemoved, + WebhookPayload::ScopeMemberRemoved { + scope: scope.clone(), + user_id, + }, + ) + .await?; + tx.commit().await?; - Ok(ScopeMemberUpdateResult::Ok(scope_member)) + Ok(ScopeMemberUpdateResult::Ok { + scope_member, + webhook_deliveries: Some(webhook_deliveries), + }) } #[instrument( @@ -3396,6 +3508,36 @@ impl Database { Ok(task) } + #[instrument( + name = "Database::process_webhooks_for_publish", + skip(self), + err + )] + pub async fn process_webhooks_for_publish( + &self, + task: &PublishingTask, + ) -> Result> { + let mut tx = self.pool.begin().await?; + + let webhook_deliveries = insert_webhook_event( + &mut tx, + &task.package_scope, + Some(&task.package_name), + WebhookEventKind::PackageVersionPublished, + WebhookPayload::PackageVersionPublished { + scope: task.package_scope.clone(), + package: task.package_name.clone(), + version: task.package_version.clone(), + user_id: task.user_id, + }, + ) + .await?; + + tx.commit().await?; + + Ok(webhook_deliveries) + } + #[instrument(name = "Database::get_oauth_state", skip(self), err)] pub async fn get_oauth_state( &self, @@ -4905,12 +5047,373 @@ impl Database { Ok((total_scopes as usize, scopes)) } + + #[instrument( + name = "Database::create_webhook_endpoint", + skip(self, new_webhook), + err + )] + pub async fn create_webhook_endpoint( + &self, + new_webhook: NewWebhookEndpoint<'_>, + actor_id: &Uuid, + is_sudo: bool, + ) -> Result { + let mut tx = self.pool.begin().await?; + + audit_log( + &mut tx, + actor_id, + is_sudo, + "create_webhook_endpoint", + json!({ + "scope": new_webhook.scope, + "package": new_webhook.package, + "url": new_webhook.url + }), + ) + .await?; + + let res = sqlx::query_as!( + WebhookEndpoint, + r#"INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at"#, + new_webhook.scope, + new_webhook.package as _, + new_webhook.url, + new_webhook.description, + new_webhook.secret, + new_webhook.events as _, + new_webhook.payload_format as _, + new_webhook.is_active, + ) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(res) + } + + #[instrument( + name = "Database::update_webhook_endpoint", + skip(self, webhook), + err + )] + pub async fn update_webhook_endpoint( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + webhook_id: Uuid, + webhook: UpdateWebhookEndpoint, + actor_id: &Uuid, + is_sudo: bool, + ) -> Result { + let mut tx = self.pool.begin().await?; + + audit_log( + &mut tx, + actor_id, + is_sudo, + "update_webhook_endpoint", + json!({ + "scope": scope, + "package": package, + "webhook": webhook_id, + "url": webhook.url, + "description": webhook.description, + "secret": webhook.secret, + "events": webhook.events, + "payload_format": webhook.payload_format, + "is_active": webhook.is_active, + }), + ) + .await?; + + let webhook_endpoint = sqlx::query_as!( + WebhookEndpoint, + r#"UPDATE webhook_endpoints SET url = COALESCE($4, url), description = COALESCE($5, description), secret = COALESCE($6, secret), events = COALESCE($7, events), payload_format = COALESCE($8, payload_format), is_active = COALESCE($9, is_active) WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3 + RETURNING id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at"#, + scope as _, + package as _, + webhook_id, + webhook.url, + webhook.description, + webhook.secret, // TODO + webhook.events as _, + webhook.payload_format as _, + webhook.is_active, + ) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(webhook_endpoint) + } + + #[instrument(name = "Database::get_webhook_endpoint", skip(self), err)] + pub async fn get_webhook_endpoint( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + id: Uuid, + ) -> Result { + sqlx::query_as!( + WebhookEndpoint, + r#"SELECT id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at + FROM webhook_endpoints + WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3"#, + scope as _, + package as _, + id, + ) + .fetch_one(&self.pool) + .await + } + + #[instrument(name = "Database::list_webhook_endpoints", skip(self), err)] + pub async fn list_webhook_endpoints( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + ) -> Result> { + sqlx::query_as!( + WebhookEndpoint, + r#"SELECT id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at + FROM webhook_endpoints + WHERE scope = $1 AND ($2::text IS NULL OR package = $2) ORDER BY created_at DESC"#, + scope as _, + package as _, + ) + .fetch_all(&self.pool) + .await + } + + #[instrument(name = "Database::delete_webhook_endpoint", skip(self), err)] + pub async fn delete_webhook_endpoint( + &self, + actor_id: &Uuid, + is_sudo: bool, + scope: &ScopeName, + package: Option<&PackageName>, + id: Uuid, + ) -> Result<()> { + let mut tx = self.pool.begin().await?; + + audit_log( + &mut tx, + actor_id, + is_sudo, + "delete_webhook_endpoint", + json!({ + "scope": scope, + "package": package, + "id": id + }), + ) + .await?; + + sqlx::query!( + r#"DELETE FROM webhook_endpoints WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3"#, + scope as _, + package as _, + id, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) + } + + #[instrument(name = "Database::get_webhook_delivery", skip(self), err)] + pub async fn get_webhook_for_dispatch( + &self, + id: Uuid, + ) -> Result { + sqlx::query_as!( + WebhookForDispatch, + r#"SELECT + webhook_endpoints.url as "url", webhook_events.event as "event: _", webhook_events.id as "event_id", webhook_endpoints.secret as "secret", webhook_endpoints.payload_format AS "payload_format: _", webhook_events.payload as "payload: WebhookPayload" + FROM webhook_endpoints + LEFT JOIN webhook_deliveries ON webhook_endpoints.id = webhook_deliveries.endpoint_id + LEFT JOIN webhook_events ON webhook_events.id = webhook_deliveries.event_id + WHERE webhook_deliveries.id = $1"#, + id, + ) + .fetch_one(&self.pool) + .await + } + + #[allow(clippy::too_many_arguments)] + #[instrument( + name = "Database::update_webhook_delivery", + skip(self, request_headers, request_body, response_headers, response_body), + err + )] + pub async fn update_webhook_delivery( + &self, + id: Uuid, + status: WebhookDeliveryStatus, + request_headers: serde_json::Value, + request_body: serde_json::Value, + response_http_code: i32, + response_headers: serde_json::Value, + response_body: String, + ) -> Result<()> { + sqlx::query!( + r#"UPDATE webhook_deliveries + SET status = $2, request_headers = $3, request_body = $4, response_http_code = $5, response_headers = $6, response_body = $7, error = null + WHERE id = $1"#, + id, + status as _, + request_headers, + request_body, + response_http_code, + response_headers, + response_body, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + #[instrument( + name = "Database::update_webhook_delivery", + skip(self, error, request_headers, request_body), + err + )] + pub async fn update_webhook_delivery_for_error( + &self, + id: Uuid, + status: WebhookDeliveryStatus, + error: String, + request_headers: serde_json::Value, + request_body: serde_json::Value, + ) -> Result<()> { + sqlx::query!( + r#"UPDATE webhook_deliveries + SET status = $2, error = $3, request_headers = $4, request_body = $5, response_http_code = null, response_headers = null, response_body = null + WHERE id = $1"#, + id, + status as _, + error, + request_headers, + request_body, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + #[instrument(name = "Database::list_webhook_deliveries", skip(self), err)] + pub async fn list_webhook_deliveries( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + webhook_endpoint_id: Uuid, + ) -> Result> { + sqlx::query!( + r#"SELECT + webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.error as "webhook_delivery_error", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", + webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" + FROM webhook_deliveries + INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id + WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND endpoint_id = $3 ORDER BY webhook_deliveries.created_at DESC"#, + scope as _, + package as _, + webhook_endpoint_id, + ) + .try_map(|r| { + let delivery = WebhookDelivery { + id: r.webhook_delivery_id, + endpoint_id: r.webhook_delivery_endpoint_id, + event_id: r.webhook_delivery_event_id, + status: r.webhook_delivery_status, + request_headers: r.webhook_delivery_request_headers, + request_body: r.webhook_delivery_request_body, + response_http_code: r.webhook_delivery_response_http_code, + response_headers: r.webhook_delivery_response_headers, + response_body: r.webhook_delivery_response_body, + error: r.webhook_delivery_error, + updated_at: r.webhook_delivery_updated_at, + created_at: r.webhook_delivery_created_at, + }; + let event = WebhookEvent { + id: r.webhook_event_id, + scope: r.webhook_event_scope, + package: r.webhook_event_package, + event: r.webhook_event_event, + payload: r.webhook_event_payload, + created_at: r.webhook_event_created_at, + }; + + Ok((delivery, event)) + }) + .fetch_all(&self.pool) + .await + } + + #[instrument(name = "Database::get_webhook_deliveries", skip(self), err)] + pub async fn get_webhook_delivery( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + webhook_endpoint_id: Uuid, + webhook_delivery_id: Uuid, + ) -> Result<(WebhookDelivery, WebhookEvent)> { + sqlx::query!( + r#"SELECT + webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.error as "webhook_delivery_error", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", + webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" + FROM webhook_deliveries + INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id + WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND webhook_deliveries.endpoint_id = $3 AND webhook_deliveries.id = $4"#, + scope as _, + package as _, + webhook_endpoint_id, + webhook_delivery_id, + ) + .try_map(|r| { + let delivery = WebhookDelivery { + id: r.webhook_delivery_id, + endpoint_id: r.webhook_delivery_endpoint_id, + event_id: r.webhook_delivery_event_id, + status: r.webhook_delivery_status, + request_headers: r.webhook_delivery_request_headers, + request_body: r.webhook_delivery_request_body, + response_http_code: r.webhook_delivery_response_http_code, + response_headers: r.webhook_delivery_response_headers, + response_body: r.webhook_delivery_response_body, + error: r.webhook_delivery_error, + updated_at: r.webhook_delivery_updated_at, + created_at: r.webhook_delivery_created_at, + }; + let event = WebhookEvent { + id: r.webhook_event_id, + scope: r.webhook_event_scope, + package: r.webhook_event_package, + event: r.webhook_event_event, + payload: r.webhook_event_payload, + created_at: r.webhook_event_created_at, + }; + + Ok((delivery, event)) + }) + .fetch_one(&self.pool) + .await + } } async fn finalize_package_creation( mut tx: sqlx::Transaction<'_, sqlx::Postgres>, scope: &ScopeName, -) -> Result, sqlx::Error> { + package: Package, +) -> Result { let (package_limit, new_package_per_week_limit) = sqlx::query!( r#" SELECT package_limit, new_package_per_week_limit FROM scopes WHERE scope = $1; @@ -4937,9 +5440,9 @@ async fn finalize_package_creation( if packages_from_last_week > new_package_per_week_limit as i64 { tx.rollback().await?; - return Ok(Some(CreatePackageResult::WeeklyPackageLimitExceeded( + return Ok(CreatePackageResult::WeeklyPackageLimitExceeded( new_package_per_week_limit, - ))); + )); } let total_packages = sqlx::query!( @@ -4954,13 +5457,26 @@ async fn finalize_package_creation( if total_packages > package_limit as i64 { tx.rollback().await?; - return Ok(Some(CreatePackageResult::PackageLimitExceeded( - package_limit, - ))); + return Ok(CreatePackageResult::PackageLimitExceeded(package_limit)); } + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopePackageCreated, + WebhookPayload::ScopePackageCreated { + scope: scope.clone(), + package: package.name.clone(), + }, + ) + .await?; + tx.commit().await?; - Ok(None) + Ok(CreatePackageResult::Ok { + package, + webhook_deliveries, + }) } async fn audit_log( @@ -4983,9 +5499,47 @@ async fn audit_log( Ok(()) } +async fn insert_webhook_event( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + scope: &ScopeName, + package: Option<&PackageName>, + event: WebhookEventKind, + payload: WebhookPayload, +) -> Result> { + let event_id = sqlx::query!( + r#"INSERT INTO webhook_events (scope, package, event, payload) VALUES ($1, $2, $3, $4) RETURNING id"#, + scope as _, + package as _, + event as _, + payload as _, + ) + .map(|r| r.id) + .fetch_one(&mut **tx) + .await?; + + let webhook_deliveries = sqlx::query!( + r#"INSERT INTO webhook_deliveries (endpoint_id, event_id) + SELECT webhook_endpoints.id, $1 FROM webhook_endpoints + WHERE webhook_endpoints.scope = $2 AND (webhook_endpoints.package IS NULL OR webhook_endpoints.package = $3) AND $4 = ANY(webhook_endpoints.events) AND webhook_endpoints.is_active = TRUE + RETURNING id"#, + event_id, + scope as _, + package as _, + event as _, + ) + .map(|r| r.id) + .fetch_all(&mut **tx) + .await?; + + Ok(webhook_deliveries) +} + #[derive(Debug)] pub enum ScopeMemberUpdateResult { - Ok(ScopeMember), + Ok { + scope_member: ScopeMember, + webhook_deliveries: Option>, + }, TargetIsLastAdmin, TargetIsLastTransferableAdmin, TargetNotMember, @@ -4993,7 +5547,10 @@ pub enum ScopeMemberUpdateResult { #[derive(Debug)] pub enum CreatePackageResult { - Ok(Package), + Ok { + package: Package, + webhook_deliveries: Vec, + }, AlreadyExists, WeeklyPackageLimitExceeded(i32), PackageLimitExceeded(i32), diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 9aaf7a7c0..f4e3d855d 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -490,8 +490,7 @@ impl<'q> sqlx::Encode<'q, sqlx::Postgres> for PackageVersionMeta { impl sqlx::Type for PackageVersionMeta { fn type_info() -> ::TypeInfo { - as sqlx::Type>::type_info( - ) + as sqlx::Type>::type_info() } } @@ -1044,3 +1043,196 @@ impl FromRow<'_, sqlx::postgres::PgRow> for AuditLog { }) } } + +#[derive(Debug, Clone, PartialEq, sqlx::Type, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[sqlx(type_name = "webhook_event_kind", rename_all = "snake_case")] +pub enum WebhookEventKind { + PackageVersionNpmTarballReady, + PackageVersionPublished, + PackageVersionYanked, + PackageVersionDeleted, + ScopePackageCreated, + ScopePackageDeleted, + ScopePackageArchived, + ScopeMemberAdded, + ScopeMemberRemoved, +} + +impl sqlx::postgres::PgHasArrayType for WebhookEventKind { + fn array_type_info() -> sqlx::postgres::PgTypeInfo { + // Postgres creates array types with an underscore prefix by default + sqlx::postgres::PgTypeInfo::with_name("_webhook_event_kind") + } +} + +#[derive(Debug, Clone, PartialEq, sqlx::Type, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[sqlx(type_name = "webhook_payload_format", rename_all = "snake_case")] +pub enum WebhookPayloadFormat { + Json, + Discord, + Slack, +} + +#[derive(Debug, Clone)] +pub struct WebhookEndpoint { + pub id: Uuid, + pub scope: ScopeName, + pub package: Option, + pub url: String, + pub description: String, + pub secret: Option, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, + pub is_active: bool, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +pub struct NewWebhookEndpoint<'s> { + pub scope: &'s ScopeName, + pub package: Option<&'s PackageName>, + pub url: &'s str, + pub description: &'s str, + pub secret: Option<&'s str>, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, + pub is_active: bool, +} + +pub struct UpdateWebhookEndpoint { + pub url: Option, + pub description: Option, + pub secret: Option, + pub events: Option>, + pub payload_format: Option, + pub is_active: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum WebhookPayload { + PackageVersionNpmTarballReady { + scope: ScopeName, + package: PackageName, + version: Version, + }, + PackageVersionPublished { + version: Version, + scope: ScopeName, + package: PackageName, + user_id: Option, + }, + PackageVersionYanked { + scope: ScopeName, + package: PackageName, + version: Version, + yanked: bool, + }, + PackageVersionDeleted { + scope: ScopeName, + package: PackageName, + version: Version, + }, + ScopePackageCreated { + scope: ScopeName, + package: PackageName, + }, + ScopePackageDeleted { + scope: ScopeName, + package: PackageName, + }, + ScopePackageArchived { + scope: ScopeName, + package: PackageName, + archived: bool, + }, + ScopeMemberAdded { + scope: ScopeName, + user_id: Uuid, + }, + ScopeMemberRemoved { + scope: ScopeName, + user_id: Uuid, + }, +} + +impl sqlx::Decode<'_, sqlx::Postgres> for WebhookPayload { + fn decode( + value: sqlx::postgres::PgValueRef<'_>, + ) -> Result> { + let s: sqlx::types::Json = + sqlx::Decode::<'_, sqlx::Postgres>::decode(value)?; + Ok(s.0) + } +} + +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for WebhookPayload { + fn encode_by_ref( + &self, + buf: &mut >::ArgumentBuffer, + ) -> sqlx::encode::IsNull { + as sqlx::Encode< + '_, + sqlx::Postgres, + >>::encode_by_ref(&Json(self), buf) + } +} + +impl sqlx::Type for WebhookPayload { + fn type_info() -> ::TypeInfo { + as sqlx::Type>::type_info( + ) + } +} + +#[derive(Debug, Clone)] +pub struct WebhookEvent { + pub id: Uuid, + pub scope: ScopeName, + pub package: Option, + pub event: WebhookEventKind, + pub payload: WebhookPayload, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "webhook_delivery_status", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum WebhookDeliveryStatus { + Pending, + Success, + Failure, + Retrying, +} + +#[derive(Debug, Clone)] +pub struct WebhookDelivery { + pub id: Uuid, + pub endpoint_id: Uuid, + pub event_id: Uuid, + pub status: WebhookDeliveryStatus, + + pub request_headers: Option, + pub request_body: Option, + + pub response_http_code: Option, + pub response_headers: Option, + pub response_body: Option, + + pub error: Option, + + pub updated_at: DateTime, + pub created_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct WebhookForDispatch { + pub url: String, + pub event: WebhookEventKind, + pub event_id: Uuid, + pub payload_format: WebhookPayloadFormat, + pub secret: Option, + pub payload: WebhookPayload, +} diff --git a/api/src/db/tests.rs b/api/src/db/tests.rs index 2516b6c44..553f3865c 100644 --- a/api/src/db/tests.rs +++ b/api/src/db/tests.rs @@ -30,7 +30,7 @@ async fn publishing_tasks() { .await .unwrap(); let res = db.create_package(&scope_name, &package_name).await.unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let CreatePublishingTaskResult::Created((pt, _)) = db .create_publishing_task(NewPublishingTask { @@ -244,7 +244,7 @@ async fn packages() { .is_some() ); - let CreatePackageResult::Ok(package) = + let CreatePackageResult::Ok { package, .. } = db.create_package(&scope_name, &package_name).await.unwrap() else { unreachable!() @@ -374,7 +374,7 @@ async fn create_package_version_and_finalize_publishing_task() { .await .unwrap(); - let CreatePackageResult::Ok(_package) = + let CreatePackageResult::Ok { .. } = db.create_package(&scope, &package_name).await.unwrap() else { unreachable!() @@ -492,7 +492,7 @@ async fn package_files() { .await .unwrap(); - let CreatePackageResult::Ok(package) = + let CreatePackageResult::Ok { package, .. } = db.create_package(&scope_name, &package_name).await.unwrap() else { unreachable!() diff --git a/api/src/main.rs b/api/src/main.rs index 3519051a8..ce80ce8f9 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -45,6 +45,7 @@ use crate::sitemap::packages_sitemap_handler; use crate::sitemap::scopes_sitemap_handler; use crate::sitemap::sitemap_index_handler; use crate::tasks::NpmTarballBuildQueue; +use crate::tasks::WebhookDispatchQueue; use crate::tasks::tasks_router; use crate::traced_router::TracedRouterService; use crate::tracing::TracingExportTarget; @@ -71,6 +72,7 @@ pub struct MainRouterOptions { npm_url: Url, publish_queue: Option, npm_tarball_build_queue: Option, + webhook_dispatch_queue: Option, logs_bigquery_table: Option<(gcp::BigQuery, /* logs_table_id */ String)>, analytics_engine_config: Option<( cloudflare::AnalyticsEngineClient, @@ -95,6 +97,7 @@ pub(crate) fn main_router( npm_url, publish_queue, npm_tarball_build_queue, + webhook_dispatch_queue, logs_bigquery_table, analytics_engine_config, expose_api, @@ -112,6 +115,7 @@ pub(crate) fn main_router( .data(NpmUrl(npm_url)) .data(PublishQueue(publish_queue)) .data(NpmTarballBuildQueue(npm_tarball_build_queue)) + .data(WebhookDispatchQueue(webhook_dispatch_queue)) .data(LogsBigQueryTable(logs_bigquery_table)) .data(AnalyticsEngineConfig(analytics_engine_config)) .middleware(routerify_query::query_parser()) @@ -198,6 +202,10 @@ async fn main() { .npm_tarball_build_queue_id .map(|id: String| Queue::new(gcp_client.clone(), id, None)); + let webhook_dispatch_queue = config + .webhook_dispatch_queue_id + .map(|id: String| Queue::new(gcp_client.clone(), id, None)); + let logs_bigquery_table = config.logs_bigquery_table_id.map(|logs_table_id| { ( @@ -282,6 +290,7 @@ async fn main() { npm_url: config.npm_url, publish_queue, npm_tarball_build_queue, + webhook_dispatch_queue, logs_bigquery_table, analytics_engine_config, expose_api: config.api, diff --git a/api/src/publish.rs b/api/src/publish.rs index 6cd415d9e..bc282b6fe 100644 --- a/api/src/publish.rs +++ b/api/src/publish.rs @@ -31,6 +31,7 @@ use crate::orama::OramaClient; use crate::tarball::NpmTarballInfo; use crate::tarball::ProcessTarballOutput; use crate::tarball::process_tarball; +use crate::tasks::WebhookDispatchQueue; use crate::util::ApiResult; use crate::util::LicenseStore; use crate::util::decode_json; @@ -59,6 +60,8 @@ pub async fn publish_handler(mut req: Request) -> ApiResult<()> { let orama_client = req.data::>().unwrap().clone(); let registry_url = req.data::().unwrap().0.clone(); let npm_url = req.data::().unwrap().0.clone(); + let webhook_dispatch_queue = + req.data::().unwrap().clone(); publish_task( publishing_task_id, @@ -67,6 +70,7 @@ pub async fn publish_handler(mut req: Request) -> ApiResult<()> { registry_url, npm_url, db, + webhook_dispatch_queue, orama_client, ) .await?; @@ -74,9 +78,17 @@ pub async fn publish_handler(mut req: Request) -> ApiResult<()> { Ok(()) } +#[allow(clippy::too_many_arguments)] #[instrument( name = "publish_task", - skip(buckets, db, license_store, registry_url, orama_client), + skip( + buckets, + db, + license_store, + registry_url, + webhook_dispatch_queue, + orama_client + ), err )] pub async fn publish_task( @@ -86,6 +98,7 @@ pub async fn publish_task( registry_url: Url, npm_url: Url, db: Database, + webhook_dispatch_queue: WebhookDispatchQueue, orama_client: Option, ) -> Result<(), ApiError> { let (mut publishing_task, _) = db @@ -142,6 +155,17 @@ pub async fn publish_task( } PublishingTaskStatus::Failure => return Ok(()), PublishingTaskStatus::Success => { + let webhook_deliveries = + db.process_webhooks_for_publish(&publishing_task).await?; + + crate::tasks::enqueue_webhook_dispatches( + &webhook_dispatch_queue, + &db, + &RegistryUrl(registry_url.clone()), + webhook_deliveries, + ) + .await?; + if let Some(orama_client) = orama_client { let (package, _, meta) = db .get_package( @@ -500,7 +524,7 @@ pub mod tests { .await .unwrap(); assert!( - matches!(res, CreatePackageResult::Ok(_)) + matches!(res, CreatePackageResult::Ok { .. }) || matches!(res, CreatePackageResult::AlreadyExists) ); @@ -545,6 +569,7 @@ pub mod tests { t.registry_url(), t.npm_url(), t.db(), + WebhookDispatchQueue(None), None, ) .await diff --git a/api/src/tasks.rs b/api/src/tasks.rs index 3df3a88a9..34dc0aa66 100644 --- a/api/src/tasks.rs +++ b/api/src/tasks.rs @@ -1,29 +1,4 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. -use bytes::Bytes; -use chrono::DateTime; -use chrono::Utc; -use deno_semver::StackString; -use deno_semver::VersionReq; -use deno_semver::package::PackageReq; -use deno_semver::package::PackageReqReference; -use deno_semver::package::PackageSubPath; -use futures::StreamExt; -use futures::stream; -use hyper::Body; -use hyper::Request; -use routerify::Router; -use routerify::ext::RequestExt; -use routerify_query::RequestQueryExt; -use serde::Deserialize; -use serde::Serialize; -use serde_json::json; -use std::collections::HashSet; -use std::str::FromStr; -use tracing::Span; -use tracing::error; -use tracing::field; -use tracing::instrument; - use crate::NpmUrl; use crate::RegistryUrl; use crate::analysis::RebuildNpmTarballData; @@ -36,6 +11,8 @@ use crate::db::Database; use crate::db::DownloadKind; use crate::db::NewNpmTarball; use crate::db::VersionDownloadCount; +use crate::db::WebhookPayload; +use crate::db::WebhookPayloadFormat; use crate::gcp; use crate::gcp::CACHE_CONTROL_DO_NOT_CACHE; use crate::gcp::CACHE_CONTROL_IMMUTABLE; @@ -49,9 +26,44 @@ use crate::npm::generate_npm_version_manifest; use crate::publish; use crate::util; use crate::util::ApiResult; +use crate::util::USER_AGENT; use crate::util::decode_json; +use bytes::Bytes; +use chrono::DateTime; +use chrono::Utc; +use deno_semver::StackString; +use deno_semver::VersionReq; +use deno_semver::package::PackageReq; +use deno_semver::package::PackageReqReference; +use deno_semver::package::PackageSubPath; +use futures::StreamExt; +use futures::TryFutureExt; +use futures::future::FutureExt; +use futures::stream; +use hmac::Mac; +use hyper::Body; +use hyper::Request; +use opentelemetry::trace::TraceContextExt; +use reqwest::header::HeaderValue; +use routerify::Router; +use routerify::ext::RequestExt; +use routerify_query::RequestQueryExt; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use std::collections::HashSet; +use std::str::FromStr; +use tracing::Span; +use tracing::error; +use tracing::field; +use tracing::instrument; +use tracing_futures::Instrument; +use tracing_opentelemetry::OpenTelemetrySpanExt; +use uuid::Uuid; pub struct NpmTarballBuildQueue(pub Option); +#[derive(Clone)] +pub struct WebhookDispatchQueue(pub Option); pub struct AnalyticsEngineConfig( pub Option<( cloudflare::AnalyticsEngineClient, @@ -74,6 +86,7 @@ pub fn tasks_router() -> Router { "/scrape_download_counts", util::json(scrape_download_counts_handler), ) + .post("/webhook_dispatch", util::json(webhook_dispatch_handler)) .build() .unwrap() } @@ -210,6 +223,9 @@ pub async fn npm_tarball_build_handler( ) .await?; + db.process_webhooks_for_npm_tarball(&job.scope, &job.name, &job.version) + .await?; + Ok(()) } @@ -568,6 +584,337 @@ fn deserialize_version_download_count_from_bigquery( })) } +#[instrument(name = "POST /tasks/webhook_dispatch", skip(req), err)] +pub async fn webhook_dispatch_handler(mut req: Request) -> ApiResult<()> { + let webhook_dispatch_id: Uuid = decode_json(&mut req).await?; + let db = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); + + let retry_count = req + .headers() + .get("x-cloudtasks-taskretrycount") + .unwrap() + .to_str() + .unwrap() + .parse::() + .unwrap() + + 1; + + // sync retry count value with terraform + dispatch_webhook(db, registry_url, webhook_dispatch_id, 3 - retry_count) + .await?; + + Ok(()) +} + +const WEBHOOK_DISPATCH_ENQUEUE_PARALLELISM: usize = 32; + +#[instrument( + name = "enqueue_webhook_dispatches", + skip(queue, db, registry_url), + err +)] +pub async fn enqueue_webhook_dispatches( + queue: &WebhookDispatchQueue, + db: &Database, + registry_url: &RegistryUrl, + webhook_dispatch_ids: Vec, +) -> ApiResult<()> { + let mut futs = stream::iter(webhook_dispatch_ids) + .map(|webhook_dispatch_id| { + if let Some(queue) = &queue.0 { + let body = serde_json::to_vec(&webhook_dispatch_id).unwrap(); + queue.task_buffer(None, Some(body.into())).boxed() + } else { + let span = Span::current(); + let fut = dispatch_webhook(db, registry_url, webhook_dispatch_id, 0) + .instrument(span) + .map_err(anyhow::Error::from); + fut.boxed() + } + }) + .buffer_unordered(WEBHOOK_DISPATCH_ENQUEUE_PARALLELISM); + + while let Some(result) = futs.next().await { + result?; + } + + Ok(()) +} + +#[instrument(name = "dispatch_webhook", skip(db, registry_url), err)] +async fn dispatch_webhook( + db: &Database, + registry_url: &RegistryUrl, + webhook_dispatch_id: Uuid, + retries_left: usize, +) -> ApiResult<()> { + #[derive(Serialize)] + struct ProviderEmbed { + color: u32, + title: &'static str, + url: String, + description: String, + } + + fn payload_to_embed_data( + payload: WebhookPayload, + registry_url: &RegistryUrl, + ) -> ProviderEmbed { + const GREEN: u32 = 0x22c55e; + const YELLOW: u32 = 0xf7df1e; + const RED: u32 = 0xef4444; + + let url = ®istry_url.0; + + match payload { + WebhookPayload::PackageVersionNpmTarballReady { + scope, + package, + version, + } => ProviderEmbed { + color: GREEN, + title: "Package version NPM tarball ready", + url: format!("{url}@{scope}/{package}/{version}"), + description: format!( + "NPM tarball for @{scope}/{package}/{version} is ready" + ), + }, + WebhookPayload::PackageVersionPublished { + scope, + package, + version, + user_id: _, + } => ProviderEmbed { + color: GREEN, + title: "Package version published", + url: format!("{url}@{scope}/{package}/{version}"), + description: format!("@{scope}/{package}/{version} has been published"), + }, + WebhookPayload::PackageVersionYanked { + scope, + package, + version, + yanked, + } => ProviderEmbed { + color: if yanked { RED } else { GREEN }, + title: if yanked { + "Package version yanked" + } else { + "Package version unyanked" + }, + url: format!("{url}@{scope}/{package}/{version}"), + description: format!( + "@{scope}/{package}/{version} has been {}", + if yanked { "yanked" } else { "unyanked" } + ), + }, + WebhookPayload::PackageVersionDeleted { + scope, + package, + version, + } => ProviderEmbed { + color: RED, + title: "Package version deleted", + url: format!("{url}@{scope}/{package}/{version}"), + description: format!("@{scope}/{package}/{version} has been deleted"), + }, + WebhookPayload::ScopePackageCreated { scope, package } => ProviderEmbed { + color: GREEN, + title: "Package created", + url: format!("{url}@{scope}/{package}"), + description: format!("@{scope}/{package} has been created"), + }, + WebhookPayload::ScopePackageDeleted { scope, package } => ProviderEmbed { + color: RED, + title: "Package deleted", + url: format!("{url}@{scope}"), + description: format!("@{scope}/{package} has been deleted"), + }, + WebhookPayload::ScopePackageArchived { + scope, + package, + archived, + } => ProviderEmbed { + color: YELLOW, + title: if archived { + "Package archived" + } else { + "Package unarchived" + }, + url: format!("{url}@{scope}"), + description: format!( + "@{scope}/{package} has been {}", + if archived { "archived" } else { "unarchived" } + ), + }, + WebhookPayload::ScopeMemberAdded { scope, user_id } => ProviderEmbed { + color: GREEN, + title: "Scope member added", + url: format!("{url}@{scope}"), + description: format!("{user_id} has been added to @{scope}"), + }, + WebhookPayload::ScopeMemberRemoved { scope, user_id } => ProviderEmbed { + color: RED, + title: "Scope member removed", + url: format!("{url}@{scope}"), + description: format!("{user_id} has been removed from @{scope}"), + }, + } + } + + let webhook = db.get_webhook_for_dispatch(webhook_dispatch_id).await?; + + let (json, signature) = match webhook.payload_format { + WebhookPayloadFormat::Json => { + let json = serde_json::to_value(webhook.payload)?; + let signature = if let Some(secret) = webhook.secret { + let mut hmac = + hmac::Hmac::::new_from_slice(secret.as_bytes()) + .unwrap(); + hmac.update(&serde_json::to_vec(&json)?); + let hash = hmac.finalize().into_bytes(); + Some(format!("sha256={:02x}", hash)) + } else { + None + }; + + (json, signature) + } + WebhookPayloadFormat::Discord => ( + json!({ + "username": "JSR", + "avatar_url": format!("{}logo-square.png", registry_url.0), + "embeds": [payload_to_embed_data(webhook.payload, registry_url)], + }), + None, + ), + WebhookPayloadFormat::Slack => { + let embed = payload_to_embed_data(webhook.payload, registry_url); + + ( + json!({ + "attachments": [ + { + "fallback": embed.description, + "color": format!("#{:x}", embed.color), + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!("*<{}|{}>*\n{}", embed.url, embed.title, embed.description), + } + } + ] + } + ] + }), + None, + ) + } + }; + + let mut headers = reqwest::header::HeaderMap::new(); + + headers.insert( + "X-JSR-Event", + serde_json::to_string(&webhook.event)?.parse().unwrap(), + ); + headers.insert( + "X-JSR-Event-Id", + webhook.event_id.to_string().parse().unwrap(), + ); + headers.insert( + reqwest::header::USER_AGENT, + HeaderValue::from_static(USER_AGENT), + ); + headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + + let span = Span::current(); + let ctx = span.context(); + let span_ref = ctx.span(); + let span_ctx = span_ref.span_context(); + let trace_id = span_ctx.trace_id().to_string(); + headers.insert("x-deno-ray", trace_id.parse().unwrap()); + + if let Some(signature) = signature { + headers.insert("X-JSR-Signature", signature.parse().unwrap()); + } + + let request_headers = serde_json::to_value(headers_to_map(&headers))?; + + let response = match reqwest::Client::new() + .post(webhook.url) + .headers(headers) + .json(&json) + .send() + .await + { + Ok(response) => response, + Err(err) => { + db.update_webhook_delivery_for_error( + webhook_dispatch_id, + if retries_left != 0 { + crate::db::models::WebhookDeliveryStatus::Retrying + } else { + crate::db::models::WebhookDeliveryStatus::Failure + }, + err.to_string(), + request_headers, + json, + ) + .await?; + return Err(anyhow::Error::from(err).into()); + } + }; + + let status = response.status(); + let success = status.is_success(); + let response_http_status = status.as_u16() as i32; + let response_headers = + serde_json::to_value(headers_to_map(response.headers()))?; + let response_body = response.text().await.map_err(anyhow::Error::from)?; + + db.update_webhook_delivery( + webhook_dispatch_id, + if success { + crate::db::models::WebhookDeliveryStatus::Success + } else if retries_left != 0 { + crate::db::models::WebhookDeliveryStatus::Retrying + } else { + crate::db::models::WebhookDeliveryStatus::Failure + }, + request_headers, + json, + response_http_status, + response_headers, + response_body, + ) + .await?; + + if !success { + Err(ApiError::WebhookResponseFailure { status }) + } else { + Ok(()) + } +} + +fn headers_to_map( + headers: &reqwest::header::HeaderMap, +) -> std::collections::HashMap> { + let mut header_hashmap = std::collections::HashMap::new(); + for (k, v) in headers { + let k = k.as_str().to_owned(); + let v = String::from_utf8_lossy(v.as_bytes()).into_owned(); + header_hashmap.entry(k).or_insert_with(Vec::new).push(v) + } + header_hashmap +} + #[cfg(test)] mod tests { use chrono::DateTime; diff --git a/api/src/util.rs b/api/src/util.rs index bcf313f5f..425fc2f0c 100644 --- a/api/src/util.rs +++ b/api/src/util.rs @@ -607,6 +607,7 @@ pub mod test { npm_url: "http://npm.jsr-tests.test".parse().unwrap(), publish_queue: None, // no queue locally npm_tarball_build_queue: None, // no queue locally + webhook_dispatch_queue: None, // no queue locally logs_bigquery_table: None, // no bigquery locally analytics_engine_config: None, // no analytics engine locally expose_api: true, // api enabled diff --git a/frontend/components/Help.tsx b/frontend/components/Help.tsx new file mode 100644 index 000000000..588af0231 --- /dev/null +++ b/frontend/components/Help.tsx @@ -0,0 +1,11 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. + +import HelpIcon from "tb-icons/TbHelp"; + +export function Help({ href }: { href: string }) { + return ( + + + + ); +} diff --git a/frontend/components/List.tsx b/frontend/components/List.tsx index ffd6652fa..4a0342414 100644 --- a/frontend/components/List.tsx +++ b/frontend/components/List.tsx @@ -42,7 +42,7 @@ export function ListDisplay( > {item.content} - + ))} diff --git a/frontend/components/WebhookDeliveries.tsx b/frontend/components/WebhookDeliveries.tsx new file mode 100644 index 000000000..4ea9467dd --- /dev/null +++ b/frontend/components/WebhookDeliveries.tsx @@ -0,0 +1,55 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import type { + WebhookDelivery, + WebhookDeliveryStatus, + WebhookEndpoint, +} from "../utils/api_types.ts"; +import { ListDisplay } from "./List.tsx"; +import { TbAlertCircle, TbCheck, TbClockHour3, TbRefresh } from "tb-icons"; +import { WEBHOOK_EVENTS } from "../islands/WebhookEdit.tsx"; + +export function WebhookDeliveries( + { webhook, deliveries }: { + webhook: WebhookEndpoint; + deliveries: WebhookDelivery[]; + }, +) { + return ( +
+

Deliveries

+ + {deliveries.map((entry) => ({ + href: `./${webhook.id}/deliveries/${entry.id}`, + content: ( +
+ {StatusToIcon(entry.status)} + +
+
+ {entry.id} +
+
+ +
+ {WEBHOOK_EVENTS.find((event) => event.id === entry.event)!.name} +
+
+ ), + }))} +
+
+ ); +} + +export function StatusToIcon(status: WebhookDeliveryStatus) { + switch (status) { + case "pending": + return ; + case "success": + return ; + case "failure": + return ; + case "retrying": + return ; + } +} diff --git a/frontend/components/WebhookDelivery.tsx b/frontend/components/WebhookDelivery.tsx new file mode 100644 index 000000000..f4c1264d4 --- /dev/null +++ b/frontend/components/WebhookDelivery.tsx @@ -0,0 +1,150 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import type { ComponentChildren } from "preact"; +import twas from "twas"; +import type { + WebhookDelivery, + WebhookDeliveryStatus, +} from "../utils/api_types.ts"; +import { TbAlertCircle, TbCheck, TbClockHour3, TbRefresh } from "tb-icons"; +import { WEBHOOK_EVENTS } from "../islands/WebhookEdit.tsx"; + +export function WebhookDelivery( + { delivery }: { + delivery: WebhookDelivery; + }, +) { + return ( +
+
+ {delivery.id} +
+ +
+ {WEBHOOK_EVENTS.find((event) => event.id === delivery.event)!.name} +
+ +
+ {StatusToIcon(delivery.status)} +
+ {delivery.status} +
+
+ +
+ {twas(new Date(delivery.updatedAt).getTime())} +
+ +
+

Request

+ {delivery.requestHeaders && ( +
+

Headers

+ + {Object.entries(delivery.requestHeaders) + .map(([k, vs]) => + vs.map((v) => ( +
+ {k}: {v} +
+ )) + ) + .flat()} +
+
+ )} + + {delivery.requestBody && ( +
+

Payload

+ + {JSON.stringify(delivery.requestBody, null, 2)} + +
+ )} +
+ +
+
+

Response

+
+ {delivery.responseHttpCode && ( +
+

HTTP Status:

+ = 200 && + delivery.responseHttpCode <= 299) + ? "text-green-500" + : "text-red-500"} + > + {delivery.responseHttpCode} + +
+ )} +
+
+ + {delivery.error + ? ( +
+

Error

+ + {delivery.error} + +
+ ) + : ( + <> + {delivery.responseHeaders && ( +
+

Headers

+ + {Object.entries(delivery.responseHeaders) + .map(([k, vs]) => + vs.map((v) => ( +
+ {k}: {v} +
+ )) + ) + .flat()} +
+
+ )} + + {delivery.responseBody && ( +
+

Body

+ + {delivery.responseBody} + +
+ )} + + )} +
+
+ ); +} + +function Code({ children }: { children: ComponentChildren }) { + return ( +
+      
+        {children}
+      
+    
+ ); +} + +export function StatusToIcon(status: WebhookDeliveryStatus) { + switch (status) { + case "pending": + return ; + case "success": + return ; + case "failure": + return ; + case "retrying": + return ; + } +} diff --git a/frontend/docs/toc.ts b/frontend/docs/toc.ts index 42d59f1e1..617647930 100644 --- a/frontend/docs/toc.ts +++ b/frontend/docs/toc.ts @@ -108,6 +108,11 @@ export default [ id: "licenses", group: "Reference", }, + { + title: "Webhooks", + id: "webhooks", + group: "Reference", + }, { title: "Badges", id: "badges", diff --git a/frontend/docs/webhooks.md b/frontend/docs/webhooks.md new file mode 100644 index 000000000..4efd4556b --- /dev/null +++ b/frontend/docs/webhooks.md @@ -0,0 +1,215 @@ +--- +title: Webhooks +description: Webhooks allow you to receive HTTP notifications when events occur in your scope or packages. +--- + +Webhooks allow you to receive real-time HTTP notifications when events occur in +your JSR scope or packages. You can use webhooks to trigger CI/CD pipelines, +send notifications to chat services, or integrate with other tools. + +## Creating a webhook + +Webhooks can be created at two levels: + +- **Scope-level webhooks**: Receive notifications for all events in a scope and + its packages. +- **Package-level webhooks**: Receive notifications only for events related to a + specific package. + +To create a webhook: + +1. Navigate to your scope or package settings +2. Go to the "Webhooks" section +3. Click "Create webhook" +4. Configure the webhook URL, events, and optional secret + +## Events + +### Package events + +These events are triggered for specific packages. For scope-level webhooks, +these events are triggered for all packages in the scope. + +| Event | Description | +| ----------------------------------- | ------------------------------------------------- | +| `package_version_published` | A new version of a package was published | +| `package_version_yanked` | A package version was yanked or unyanked | +| `package_version_deleted` | A package version was deleted | +| `package_version_npm_tarball_ready` | The npm-compatible tarball for a version is ready | + +### Scope events + +These events are triggered at the scope level and are only available for +scope-level webhooks. + +| Event | Description | +| ------------------------ | -------------------------------------- | +| `scope_package_created` | A new package was created in the scope | +| `scope_package_deleted` | A package was deleted from the scope | +| `scope_package_archived` | A package was archived or unarchived | +| `scope_member_added` | A new member was added to the scope | +| `scope_member_removed` | A member was removed from the scope | + +## Payload format + +Webhooks support three payload formats: + +- **JSON**: Standard JSON payload +- **Discord**: Pre-formatted for Discord webhook endpoints +- **Slack**: Pre-formatted for Slack webhook endpoints + +### JSON payloads + +All JSON payloads include an `event` field that identifies the event type. The +remaining fields vary by event. + +#### `package_version_published` + +```json +{ + "event": "package_version_published", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0", + "user_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +The `user_id` field contains the UUID of the user who published the version, or +`null` if published via CI without user context. + +#### `package_version_yanked` + +```json +{ + "event": "package_version_yanked", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0", + "yanked": true +} +``` + +The `yanked` field is `true` when a version is yanked, and `false` when a +version is unyanked. + +#### `package_version_deleted` + +```json +{ + "event": "package_version_deleted", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0" +} +``` + +#### `package_version_npm_tarball_ready` + +```json +{ + "event": "package_version_npm_tarball_ready", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0" +} +``` + +This event is triggered after a version is published and the npm-compatible +tarball has been built. Use this event if you need to wait for npm compatibility +before taking action. + +#### `scope_package_created` + +```json +{ + "event": "scope_package_created", + "scope": "myorg", + "package": "mylib" +} +``` + +#### `scope_package_deleted` + +```json +{ + "event": "scope_package_deleted", + "scope": "myorg", + "package": "mylib" +} +``` + +#### `scope_package_archived` + +```json +{ + "event": "scope_package_archived", + "scope": "myorg", + "package": "mylib", + "archived": true +} +``` + +The `archived` field is `true` when a package is archived, and `false` when a +package is unarchived. + +#### `scope_member_added` + +```json +{ + "event": "scope_member_added", + "scope": "myorg", + "user_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +#### `scope_member_removed` + +```json +{ + "event": "scope_member_removed", + "scope": "myorg", + "user_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +## HTTP headers + +Each webhook request includes the following HTTP headers: + +| Header | Description | +| ----------------- | ----------------------------------------------------- | +| `X-JSR-Event` | The event type (e.g., `"package_version_published"`) | +| `X-JSR-Event-Id` | Unique identifier for this event | +| `X-JSR-Signature` | HMAC signature of the request body (if secret is set) | + +## Secrets and signature verification + +Webhook secrets allow you to verify that incoming webhook requests genuinely +originate from JSR and have not been tampered with in transit. + +### How secrets work + +When you configure a secret for your webhook, JSR computes an HMAC-SHA256 +signature of the request body using your secret which in the `X-JSR-Signature` +header. Your server can verify this signature to validate that the request is +genuine. + +The signature format is: `sha256=` + +### Verifying signatures + +To verify a webhook signature: + +1. Extract the signature from the `X-JSR-Signature` header +2. Compute an HMAC-SHA256 hash of the raw request body using your secret +3. Compare the computed hash with the signature from the header + +## Delivery and retries + +JSR delivers webhooks with the following behavior: + +- Webhooks are delivered asynchronously after events occur +- Failed deliveries (non-2xx responses) are retried up to 3 times +- You can view delivery history and debug failed deliveries in the webhook + settings diff --git a/frontend/islands/WebhookEdit.tsx b/frontend/islands/WebhookEdit.tsx new file mode 100644 index 000000000..656141860 --- /dev/null +++ b/frontend/islands/WebhookEdit.tsx @@ -0,0 +1,320 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import { WebhookEndpoint, WebhookEventKind } from "../utils/api_types.ts"; +import { useSignal } from "@preact/signals"; +import { api, path } from "../utils/api.ts"; +import { Help } from "../components/Help.tsx"; + +export const WEBHOOK_EVENTS: Array<{ + id: WebhookEventKind; + name: string; + description: string; + packageLevel: boolean; +}> = [ + { + id: "package_version_npm_tarball_ready", + name: "Package version NPM tarball ready", + description: "A NPM tarball for a published version is available.", + packageLevel: true, + }, + { + id: "package_version_published", + name: "Package version published", + description: "A new version of a package is published.", + packageLevel: true, + }, + { + id: "package_version_yanked", + name: "Package version yanked", + description: "A version of a package is yanked or unyanked.", + packageLevel: true, + }, + { + id: "package_version_deleted", + name: "Package version deleted", + description: "A version of a package is deleted.", + packageLevel: true, + }, + { + id: "scope_package_created", + name: "Package created", + description: "A new package is created in the scope.", + packageLevel: false, + }, + { + id: "scope_package_deleted", + name: "Package deleted", + description: "A package is deleted in the scope.", + packageLevel: false, + }, + { + id: "scope_package_archived", + name: "Package archived", + description: "A package in the scope is archived or unarchived.", + packageLevel: false, + }, + { + id: "scope_member_added", + name: "Scope member added", + description: "A new member is added to the scope.", + packageLevel: false, + }, + { + id: "scope_member_removed", + name: "Scope member removed", + description: "A member is removed from the scope.", + packageLevel: false, + }, +]; + +function Required() { + return *; +} + +export function WebhookEdit( + { webhook, scope, package: pkg }: { + scope: string; + package?: string; + webhook: WebhookEndpoint | null; + }, +) { + const description = useSignal(webhook?.description ?? ""); + const url = useSignal(webhook?.url ?? ""); + const payloadFormat = useSignal(webhook?.payloadFormat ?? "json"); + const secret = useSignal(""); + const events = useSignal(new Set(webhook?.events ?? [])); + const isActive = useSignal(webhook?.isActive ?? true); + const processing = useSignal(false); + + return ( +
{ + e.preventDefault(); + + processing.value = true; + + (webhook + ? api.patch( + pkg + ? path`/scopes/${scope}/packages/${pkg}/webhooks/${webhook.id}` + : path`/scopes/${scope}/webhooks/${webhook.id}`, + { + description: description.value === webhook.description + ? undefined + : description.value, + url: url.value === webhook.url ? undefined : url.value, + payloadFormat: payloadFormat.value === webhook.payloadFormat + ? undefined + : payloadFormat.value, + secret: secret.value || null, // TODO + events: events.value.symmetricDifference(new Set(webhook.events)) + .size === 0 + ? undefined + : Array.from(events.value), + isActive: isActive.value === webhook.isActive + ? undefined + : isActive.value, + }, + ) + : api.post( + pkg + ? path`/scopes/${scope}/packages/${pkg}/webhooks` + : path`/scopes/${scope}/webhooks`, + { + description: description.value, + url: url.value, + payloadFormat: payloadFormat.value, + secret: secret.value || null, + events: Array.from(events.value), + isActive: isActive.value, + }, + )).then(() => { + if (webhook) { + location.reload(); + } else { + location.href = pkg + ? `/@${scope}/${pkg}/settings#webhooks` + : `/@${scope}/~/settings#webhooks`; + } + }); + }} + > +
+
+ + + + +
+
+ +

+ Events +

+
+
+ {WEBHOOK_EVENTS.filter((event) => { + if (pkg) { + return event.packageLevel; + } else { + return true; + } + }).map((event) => ( + + ))} +
+
+
+
+ {webhook && ( + + )} + + +
+
+ ); +} diff --git a/frontend/routes/@[scope]/~/settings.tsx b/frontend/routes/@[scope]/~/settings/index.tsx similarity index 83% rename from frontend/routes/@[scope]/~/settings.tsx rename to frontend/routes/@[scope]/~/settings/index.tsx index eaf57dbe5..04a49aa3b 100644 --- a/frontend/routes/@[scope]/~/settings.tsx +++ b/frontend/routes/@[scope]/~/settings/index.tsx @@ -2,16 +2,22 @@ import { HttpError } from "fresh"; import { ComponentChildren } from "preact"; import { TbCheck, TbTrash } from "tb-icons"; -import { define } from "../../../util.ts"; -import { ScopeHeader } from "../(_components)/ScopeHeader.tsx"; -import { ScopeNav } from "../(_components)/ScopeNav.tsx"; -import { ScopeDescriptionForm } from "../(_islands)/ScopeDescriptionForm.tsx"; -import { FullScope, User } from "../../../utils/api_types.ts"; -import { scopeDataWithMember } from "../../../utils/data.ts"; -import { path } from "../../../utils/api.ts"; -import { QuotaCard } from "../../../components/QuotaCard.tsx"; -import { scopeIAM } from "../../../utils/iam.ts"; -import { TicketModal } from "../../../islands/TicketModal.tsx"; +import { define } from "../../../../util.ts"; +import { ScopeHeader } from "../../(_components)/ScopeHeader.tsx"; +import { ScopeNav } from "../../(_components)/ScopeNav.tsx"; +import { ScopeDescriptionForm } from "../../(_islands)/ScopeDescriptionForm.tsx"; +import { + FullScope, + User, + WebhookEndpoint, +} from "../../../../utils/api_types.ts"; +import { scopeDataWithMember } from "../../../../utils/data.ts"; +import { path } from "../../../../utils/api.ts"; +import { QuotaCard } from "../../../../components/QuotaCard.tsx"; +import { scopeIAM } from "../../../../utils/iam.ts"; +import { TicketModal } from "../../../../islands/TicketModal.tsx"; +import { ListDisplay } from "../../../../components/List.tsx"; +import { Help } from "../../../../components/Help.tsx"; export default define.page(function ScopeSettingsPage( { data, state }, @@ -24,6 +30,7 @@ export default define.page(function ScopeSettingsPage( + ); @@ -33,10 +40,7 @@ function ScopeDescription({ scope }: { scope: FullScope }) { return (

Description

-

- The description of the scope{" "} - @{scope.scope}: -

+

The description of the scope

); @@ -225,6 +229,44 @@ function RequirePublishingFromCI({ scope }: { scope: FullScope }) { ); } +function Webhooks( + { webhooks }: { webhooks: WebhookEndpoint[] }, +) { + return ( +
+

+ Webhooks +

+

+ Webhooks let you receive notifications when packages are published or + other events happen in the scope. +

+ {webhooks.length > 0 && ( + + {webhooks.map((entry) => ({ + href: `./settings/webhooks/${entry.id}`, + content: ( +
+
+ {entry.description || entry.url} +
+ +
+ {entry.events.length} event{entry.events.length > 1 && "s"} +
+
+ ), + }))} +
+ )} + + + Create + +
+ ); +} + interface CardButtonProps { title: ComponentChildren; description: ComponentChildren; @@ -273,7 +315,7 @@ function DeleteScope({ scope }: { scope: FullScope }) { and publish packages to it. This action cannot be undone.