diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 13f3d33..77f1062 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -216,7 +216,13 @@ Cria um novo token de API. **Parâmetros (Body - JSON):** - `name` (string, obrigatório) - Nome do token -- `abilities` (array, opcional) - Permissões do token (padrão: ["*"]) +- `abilities` (array, opcional) - Permissões do token: `read`, `write`, `delete`, `admin`, `passwords` (padrão: `["read", "write"]`) +- `expires_at` (datetime, opcional) - Data de expiração + +**Senha dos clientes:** o campo `password` dos clientes só aparece nas respostas para o login do painel e para +tokens com a permissão `passwords` (ou `admin`). Tokens criados só com `read`/`write`/`delete` recebem os +clientes **sem** o campo `password` (também na exportação CSV). Tokens antigos sem essas permissões precisam ser +recriados para voltar a ver as senhas. #### DELETE /v1/api-tokens/{id} Exclui um token de API específico. @@ -236,8 +242,9 @@ Lista clientes do usuário com filtros e paginação. - `expires_at` (date, opcional) - Filtrar por data de expiração específica (formato: YYYY-MM-DD) - `expires_from` (date, opcional) - Data inicial do intervalo de expiração - `expires_to` (date, opcional) - Data final do intervalo de expiração -- `q` (string, opcional) - Busca textual (nome, username, whatsapp, servidor) -- `sort_by` (string, opcional) - Campo para ordenação: `id`, `name`, `username`, `whatsapp`, `status`, `expires_at`, `valor_mensalidade`, `created_at`, `updated_at`, `server_id` (padrão: `expires_at`) +- `code` (string, opcional) - Filtrar pelo código exato do cliente +- `q` (string, opcional) - Busca textual (nome, código, username, whatsapp, servidor) +- `sort_by` (string, opcional) - Campo para ordenação: `id`, `name`, `code`, `username`, `whatsapp`, `status`, `expires_at`, `valor_mensalidade`, `created_at`, `updated_at`, `server_id` (padrão: `expires_at`) - `sort_dir` (string, opcional) - Direção da ordenação: `asc`, `desc` (padrão: `asc`) - `per_page` (integer, opcional) - Itens por página (1-200, padrão: 50) - `limit` (integer, opcional) - Alias para `per_page` @@ -249,7 +256,9 @@ Lista clientes do usuário com filtros e paginação. { "id": 1, "name": "João Silva", + "code": "ERP-42", "username": "joao123", + "password": "s3nh4", "whatsapp": "5511999999999", "status": "active", "expires_at": "2025-12-31T23:59:59Z", @@ -257,7 +266,9 @@ Lista clientes do usuário com filtros e paginação. "screens": 1, "server": { "id": 1, - "name": "Servidor Principal" + "name": "Servidor Principal", + "panel_url": "https://painel.exemplo.com", + "xtream_url": "http://dns.exemplo.com:8080" } } ], @@ -267,6 +278,9 @@ Lista clientes do usuário com filtros e paginação. } ``` +> `password` só aparece para o painel e para tokens com permissão `passwords`/`admin` (veja Tokens de API). +> No banco a senha fica criptografada (AES-256, chave `APP_KEY` do `.env`). + #### GET /v1/clients/{id} Obtém detalhes de um cliente específico. @@ -279,8 +293,9 @@ Cria um novo cliente. **Parâmetros (Body - JSON):** - `name` (string, obrigatório) - Nome do cliente - `server_id` (integer, obrigatório) - ID do servidor +- `code` (string, opcional, max:100) - Código livre do cliente (ex.: código no seu sistema) - `username` (string, opcional) - Nome de usuário -- `password` (string, opcional) - Senha +- `password` (string, opcional) - Senha (guardada criptografada) - `whatsapp` (string, opcional) - Número do WhatsApp - `valor_mensalidade` (numeric, opcional) - Valor da mensalidade - `expires_at` (date, opcional) - Data de expiração (formato: YYYY-MM-DD) @@ -292,6 +307,7 @@ Cria um novo cliente. "id": 1, "name": "João Silva", "server_id": 1, + "code": "ERP-42", "username": "joao123", "status": "active", "created_at": "2025-01-15T12:00:00Z" @@ -356,7 +372,12 @@ Lista todos os servidores do usuário. { "id": 1, "name": "Servidor Principal", - "url": "https://servidor.com", + "contact_name": "Fulano", + "contact_whatsapp": "5511999999999", + "panel_url": "https://painel.exemplo.com", + "xtream_url": "http://dns.exemplo.com:8080", + "panel_username": "admin", + "panel_password": "senha", "active": true, "credits_balance": 1000, "clients_count": 50 @@ -379,6 +400,7 @@ Cria um novo servidor. - `contact_name` (string, opcional, max:255) - Nome do contato - `contact_whatsapp` (string, opcional, max:20, regex: /^[0-9]+$/) - WhatsApp do contato - `panel_url` (string, opcional, url, max:500) - URL do painel +- `xtream_url` (string, opcional, url, max:500) - URL do Xtream (DNS usada nos aplicativos, ex.: `http://dns.exemplo.com:8080`) - `panel_username` (string, opcional, max:255) - Username do painel - `panel_password` (string, opcional, max:255) - Senha do painel - `active` (boolean, opcional) - Status ativo/inativo @@ -407,6 +429,123 @@ Migra clientes de um servidor para outro. - `target_server_id` (integer, obrigatório) - ID do servidor de destino - `client_ids` (array, opcional) - IDs dos clientes para migrar (se não fornecido, migra todos) +### Webhooks de Saída (Notificações para outros sistemas) + +O sistema envia um **POST** com JSON para uma URL sua sempre que algo acontece. Cadastre pelo painel em +**Perfil → Webhooks** ou pela API abaixo. Cada usuário pode ter até 10 webhooks. + +**Eventos disponíveis:** + +| Evento | Quando | +|---|---| +| `client.created` | Cliente cadastrado (painel, API, importação ou MCP) | +| `client.updated` | Cliente alterado (envia `changes` com `old`/`new` de cada campo) | +| `client.renewed` | Vencimento do cliente foi para frente (renovação) | +| `client.deleted` | Cliente removido | +| `server.created` | Servidor cadastrado | +| `server.updated` | Servidor alterado (mudança só no saldo de créditos não gera evento) | +| `server.deleted` | Servidor removido | +| `user.created` | Usuário cadastrado no sistema (**só administradores**) | +| `user.deleted` | Usuário removido do sistema (**só administradores**) | + +**Cabeçalhos enviados:** +- `X-Webhook-Event` - nome do evento +- `X-Webhook-Delivery` - ID da entrega (use para ignorar duplicados) +- `X-Webhook-Timestamp` - horário do envio (Unix, segundos) +- `X-Webhook-Signature` - `sha256=` + HMAC-SHA256 de `timestamp + "." + corpo`, usando a chave secreta do webhook + +**Exemplo de corpo (`client.created`):** +```json +{ + "id": 15, + "webhook_id": 1, + "event": "client.created", + "occurred_at": "2026-10-16T14:30:00-03:00", + "data": { + "id": 120, + "user_id": 2, + "name": "João Silva", + "code": "ERP-42", + "username": "joao123", + "password": "s3nh4", + "whatsapp": "5511999999999", + "valor_mensalidade": 29.9, + "screens": 1, + "status": "active", + "start_at": null, + "expires_at": "2026-11-16", + "server": { + "id": 1, + "name": "Servidor Principal", + "panel_url": "https://painel.exemplo.com", + "xtream_url": "http://dns.exemplo.com:8080" + }, + "created_at": "2026-10-16T14:30:00-03:00", + "updated_at": "2026-10-16T14:30:00-03:00" + }, + "changes": [] +} +``` + +**Senhas:** `password` (cliente) e `panel_password` (servidor) só vão no corpo quando o webhook tem +`include_sensitive = true`, e isso só é aceito com URL **HTTPS**. Sem essa opção, os campos são omitidos e, em +`changes`, uma troca de senha aparece só como `{"changed": true}`. + +**Reenvio:** qualquer resposta fora de 2xx (ou timeout de 10s) é reenviada em 1, 5, 15, 60 e 180 minutos. +Depois de 50 falhas seguidas o webhook é desativado. O histórico fica guardado por 30 dias. + +**Segurança:** URLs para endereços internos/privados (localhost, 10.x, 192.168.x, 169.254.x...) são recusadas, +redirecionamentos não são seguidos e o conteúdo das entregas fica criptografado no banco. + +**Validando a assinatura (PHP):** +```php +$body = file_get_contents('php://input'); +$ts = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? ''; +$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, 'whsec_SUA_CHAVE'); +if (!hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '') || abs(time() - (int) $ts) > 300) { + http_response_code(401); + exit; +} +$evento = json_decode($body, true); +``` + +#### GET /v1/outgoing-webhooks +Lista os webhooks do usuário e os eventos disponíveis (`{ "webhooks": [...], "events": {"client.created": "Cliente cadastrado", ...} }`). + +#### GET /v1/outgoing-webhooks/events +Só a lista de eventos disponíveis para o usuário. + +#### POST /v1/outgoing-webhooks +Cria um webhook. A resposta traz o campo `secret` (chave da assinatura). + +**Parâmetros (Body - JSON):** +- `name` (string, obrigatório, max:100) - Nome +- `url` (string, obrigatório) - URL pública de destino +- `events` (array, obrigatório) - Eventos, ex.: `["client.created", "server.created"]` +- `include_sensitive` (boolean, opcional, padrão: false) - Envia senhas (exige HTTPS) +- `active` (boolean, opcional, padrão: true) + +#### GET /v1/outgoing-webhooks/{id} +Detalhes do webhook, incluindo `secret`. + +#### PUT /v1/outgoing-webhooks/{id} +Altera o webhook (mesmos campos do POST, todos opcionais). Reativar (`active: true`) zera o contador de falhas. + +#### DELETE /v1/outgoing-webhooks/{id} +Remove o webhook e o histórico. + +#### POST /v1/outgoing-webhooks/{id}/test +Envia um evento `webhook.test` na hora e devolve o resultado (200 = destino respondeu 2xx, 422 = falha). + +#### POST /v1/outgoing-webhooks/{id}/regenerate-secret +Gera uma nova chave secreta (a antiga deixa de valer na hora). + +#### GET /v1/outgoing-webhooks/{id}/deliveries +Histórico de entregas (`limit` até 100, padrão 30), com status, código HTTP, erro, conteúdo enviado e resposta. + +#### POST /v1/outgoing-webhooks/deliveries/{deliveryId}/retry +Reenvia uma entrega na hora. + ### WhatsApp #### GET /v1/whatsapp/my-connections @@ -1553,8 +1692,9 @@ Cria um novo cliente no sistema. **Argumentos:** - `name` (string, obrigatório) - Nome do cliente - `server_id` (integer, obrigatório) - ID do servidor +- `code` (string, opcional, max:100) - Código livre do cliente (ex.: código no seu sistema) - `username` (string, opcional) - Nome de usuário -- `password` (string, opcional) - Senha +- `password` (string, opcional) - Senha (guardada criptografada) - `whatsapp` (string, opcional) - Número WhatsApp - `valor_mensalidade` (number, opcional) - Valor mensalidade - `expires_at` (string, formato: YYYY-MM-DD, opcional) - Data expiração @@ -1738,6 +1878,8 @@ curl -X POST https://seu-dominio.com/api/v1/mcp/tools/call \ - Logs de auditoria registram todas as ações - Rate limiting protege contra abuso - Validação rigorosa de entrada +- Senhas dos clientes criptografadas no banco (AES-256 com a `APP_KEY`); guarde a `APP_KEY` junto com os backups +- Webhooks de saída assinados com HMAC-SHA256 - Sanitização de dados de saída ## Suporte diff --git a/new/backend/app/Casts/EncryptedString.php b/backend/app/Casts/EncryptedString.php new file mode 100644 index 0000000..f8b07b2 --- /dev/null +++ b/backend/app/Casts/EncryptedString.php @@ -0,0 +1,86 @@ + */ + private static array $columnIsText = []; + + public function get(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null || $value === '') { + return $value; + } + + if (!self::looksEncrypted($value)) { + return $value; + } + + try { + return Crypt::decryptString($value); + } catch (DecryptException $e) { + return $value; + } + } + + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null || $value === '') { + return $value; + } + + $value = (string) $value; + + if (!self::columnSupportsEncryption($model->getTable(), $key)) { + return $value; + } + + return Crypt::encryptString($value); + } + + /** + * Payload do Laravel: base64 de um JSON com iv, value e mac. + */ + public static function looksEncrypted(string $value): bool + { + if (strlen($value) < 100 || !str_starts_with($value, 'eyJ')) { + return false; + } + + $decoded = json_decode(base64_decode($value, true) ?: '', true); + + return is_array($decoded) && isset($decoded['iv'], $decoded['value'], $decoded['mac']); + } + + public static function columnSupportsEncryption(string $table, string $column): bool + { + $cacheKey = $table . '.' . $column; + + if (!array_key_exists($cacheKey, self::$columnIsText)) { + try { + $type = strtolower(Schema::getColumnType($table, $column)); + self::$columnIsText[$cacheKey] = in_array($type, ['text', 'mediumtext', 'longtext'], true); + } catch (\Throwable $e) { + self::$columnIsText[$cacheKey] = false; + } + } + + return self::$columnIsText[$cacheKey]; + } +} diff --git a/new/backend/app/Console/Commands/EncryptClientPasswords.php b/backend/app/Console/Commands/EncryptClientPasswords.php new file mode 100644 index 0000000..12734a5 --- /dev/null +++ b/backend/app/Console/Commands/EncryptClientPasswords.php @@ -0,0 +1,72 @@ +option('decrypt'); + $dryRun = (bool) $this->option('dry-run'); + + if (!$decrypt && !EncryptedString::columnSupportsEncryption('clients', 'password')) { + $this->error('A coluna clients.password ainda não é TEXT. Rode a migration 2026_10_16_000000_change_clients_password_to_text primeiro.'); + return self::FAILURE; + } + + $changed = 0; + $skipped = 0; + $failed = 0; + + DB::table('clients') + ->select(['id', 'password']) + ->whereNotNull('password') + ->where('password', '!=', '') + ->orderBy('id') + ->chunkById(500, function ($rows) use ($decrypt, $dryRun, &$changed, &$skipped, &$failed) { + foreach ($rows as $row) { + $isEncrypted = EncryptedString::looksEncrypted($row->password); + + if ($decrypt === !$isEncrypted) { + $skipped++; + continue; + } + + try { + $newValue = $decrypt + ? Crypt::decryptString($row->password) + : Crypt::encryptString($row->password); + } catch (DecryptException $e) { + $this->warn("Cliente #{$row->id}: não foi possível descriptografar (APP_KEY diferente?)."); + $failed++; + continue; + } + + if (!$dryRun) { + // Query builder: não dispara observers/webhooks + DB::table('clients')->where('id', $row->id)->update(['password' => $newValue]); + } + + $changed++; + } + }); + + $action = $decrypt ? 'descriptografadas' : 'criptografadas'; + $prefix = $dryRun ? '[dry-run] Seriam ' : ''; + $this->info("{$prefix}{$changed} senha(s) {$action}. {$skipped} já estavam no formato certo. {$failed} com erro."); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/new/backend/app/Console/Commands/ProcessOutgoingWebhooks.php b/backend/app/Console/Commands/ProcessOutgoingWebhooks.php new file mode 100644 index 0000000..b11a91d --- /dev/null +++ b/backend/app/Console/Commands/ProcessOutgoingWebhooks.php @@ -0,0 +1,46 @@ +warn('Tabelas de webhooks ainda não existem. Rode as migrations.'); + return self::SUCCESS; + } + + if ($this->option('prune')) { + $deleted = OutgoingWebhookDelivery::whereIn('status', [ + OutgoingWebhookDelivery::STATUS_SUCCESS, + OutgoingWebhookDelivery::STATUS_FAILED, + ]) + ->where('created_at', '<', now()->subDays(max(1, (int) $this->option('days')))) + ->delete(); + + $this->info("{$deleted} entrega(s) antiga(s) removida(s)."); + return self::SUCCESS; + } + + $stats = OutgoingWebhookService::processPending(max(1, (int) $this->option('limit'))); + + if ($stats['sent'] || $stats['failed']) { + $this->info("Webhooks: {$stats['sent']} enviado(s), {$stats['failed']} com falha."); + } + + return self::SUCCESS; + } +} diff --git a/backend/app/Http/Controllers/Api/ApiTokenController.php b/backend/app/Http/Controllers/Api/ApiTokenController.php index 1278ae5..3c74ac6 100644 --- a/backend/app/Http/Controllers/Api/ApiTokenController.php +++ b/backend/app/Http/Controllers/Api/ApiTokenController.php @@ -46,7 +46,7 @@ class ApiTokenController extends Controller $request->validate([ 'name' => 'required|string|max:255', 'abilities' => 'nullable|array', - 'abilities.*' => 'string|in:read,write,delete,admin', + 'abilities.*' => 'string|in:read,write,delete,admin,passwords', 'expires_at' => 'nullable|date|after:now', ]); diff --git a/backend/app/Http/Controllers/Api/ClientController.php b/backend/app/Http/Controllers/Api/ClientController.php index b087574..44d7507 100644 --- a/backend/app/Http/Controllers/Api/ClientController.php +++ b/backend/app/Http/Controllers/Api/ClientController.php @@ -24,6 +24,9 @@ class ClientController extends Controller // Get sort parameters early to decide on eager loading $sortBy = $request->get('sort_by', 'expires_at'); $allowedSorts = ['id', 'name', 'username', 'whatsapp', 'status', 'expires_at', 'valor_mensalidade', 'created_at', 'updated_at', 'server_id']; + if (Client::hasCodeColumn()) { + $allowedSorts[] = 'code'; + } if (!in_array($sortBy, $allowedSorts)) { $sortBy = 'expires_at'; } @@ -40,6 +43,10 @@ class ClientController extends Controller $query->where('server_id', $request->server_id); } + if ($request->filled('code') && Client::hasCodeColumn()) { + $query->where('code', $request->code); + } + if ($request->has('status')) { $query->where('status', $request->status); } @@ -95,6 +102,7 @@ class ClientController extends Controller $q->where('name', 'like', "%{$search}%") ->orWhere('username', 'like', "%{$search}%") ->orWhere('whatsapp', 'like', "%{$search}%") + ->when(Client::hasCodeColumn(), fn ($codeQuery) => $codeQuery->orWhere('code', 'like', "%{$search}%")) // Buscar por nome do servidor também ->orWhereHas('server', function($serverQuery) use ($search) { $serverQuery->where('name', 'like', "%{$search}%"); @@ -331,6 +339,7 @@ class ClientController extends Controller $validated = $request->validate([ 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', 'username' => 'nullable|string|max:255', 'password' => 'nullable|string|max:255', 'whatsapp' => 'nullable|string|max:20', @@ -376,7 +385,7 @@ class ClientController extends Controller 'screens' => $validated['screens'] ?? 1, 'status' => $validated['status'] ?? 'active', 'expires_at' => $validated['expires_at'] ?? Carbon::now()->addDays(30), - ]); + ] + (array_key_exists('code', $validated) ? ['code' => $validated['code']] : [])); // Registrar venda de crédito (desconto do servidor) Recharge::create([ @@ -446,6 +455,7 @@ class ClientController extends Controller $validated = $request->validate([ 'name' => 'sometimes|string|max:255', + 'code' => 'nullable|string|max:100', 'username' => 'nullable|string|max:255', 'password' => 'nullable|string|max:255', 'whatsapp' => 'nullable|string|max:20', diff --git a/backend/app/Http/Controllers/Api/ClientImportController.php b/backend/app/Http/Controllers/Api/ClientImportController.php index cd2d33d..aed2831 100644 --- a/backend/app/Http/Controllers/Api/ClientImportController.php +++ b/backend/app/Http/Controllers/Api/ClientImportController.php @@ -25,7 +25,8 @@ class ClientImportController extends Controller // CSV sem coluna Status (será calculado automaticamente na importação) $csvData = "Nome;Servidor;Usuario;Senha;WhatsApp;Vencimento;Valor;Telas\n"; - + $revealPasswords = Client::canRevealPasswords(); + foreach ($clients as $client) { $vencimento = $client->expires_at ? Carbon::parse($client->expires_at)->format('d/m/Y') @@ -39,7 +40,7 @@ class ClientImportController extends Controller $client->name, $client->server->name ?? '', $client->username ?? '', - $client->password ?? '', + $revealPasswords ? ($client->password ?? '') : '', $client->whatsapp ?? '', $vencimento, $valor, diff --git a/backend/app/Http/Controllers/Api/OpenApiController.php b/backend/app/Http/Controllers/Api/OpenApiController.php index 32902b9..c19c0e6 100644 --- a/backend/app/Http/Controllers/Api/OpenApiController.php +++ b/backend/app/Http/Controllers/Api/OpenApiController.php @@ -64,6 +64,7 @@ class OpenApiController extends Controller ['name' => 'Templates', 'description' => 'Templates de mensagens'], ['name' => 'Recargas', 'description' => 'Gerenciamento de recargas de créditos'], ['name' => 'Admin', 'description' => 'Endpoints administrativos (requer role admin)'], + ['name' => 'Webhooks', 'description' => 'Webhooks de saída: o sistema avisa uma URL sua quando clientes, servidores ou usuários são criados/alterados/removidos'], ], ]; @@ -552,7 +553,8 @@ class OpenApiController extends Controller 'name' => ['type' => 'string'], 'abilities' => [ 'type' => 'array', - 'items' => ['type' => 'string', 'enum' => ['read', 'write', 'delete', 'admin']], + 'items' => ['type' => 'string', 'enum' => ['read', 'write', 'delete', 'admin', 'passwords']], + 'description' => 'passwords (ou admin) permite ver a senha dos clientes nas respostas da API', ], 'expires_at' => ['type' => 'string', 'format' => 'date-time'], ], @@ -713,6 +715,66 @@ class OpenApiController extends Controller ], ], ], + '/outgoing-webhooks' => [ + 'get' => [ + 'tags' => ['Webhooks'], + 'summary' => 'Listar webhooks de saída e eventos disponíveis', + 'responses' => ['200' => ['description' => '{ webhooks: OutgoingWebhook[], events: {evento: descrição} }']], + ], + 'post' => [ + 'tags' => ['Webhooks'], + 'summary' => 'Criar webhook de saída', + 'description' => "Cada evento é enviado por POST (JSON) com os cabeçalhos X-Webhook-Event, X-Webhook-Delivery, X-Webhook-Timestamp e X-Webhook-Signature = 'sha256=' + HMAC_SHA256(timestamp + '.' + corpo, secret). Falhas são reenviadas em 1, 5, 15, 60 e 180 minutos.", + 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/OutgoingWebhookInput']]]], + 'responses' => ['201' => ['description' => 'Webhook criado (inclui secret)', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/OutgoingWebhook']]]]], + ], + ], + '/outgoing-webhooks/{id}' => [ + 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], + 'get' => ['tags' => ['Webhooks'], 'summary' => 'Ver webhook (inclui secret)', 'responses' => ['200' => ['description' => 'OK']]], + 'put' => [ + 'tags' => ['Webhooks'], + 'summary' => 'Alterar webhook', + 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/OutgoingWebhookInput']]]], + 'responses' => ['200' => ['description' => 'OK']], + ], + 'delete' => ['tags' => ['Webhooks'], 'summary' => 'Remover webhook', 'responses' => ['200' => ['description' => 'OK']]], + ], + '/outgoing-webhooks/{id}/test' => [ + 'post' => [ + 'tags' => ['Webhooks'], + 'summary' => 'Enviar evento de teste (webhook.test)', + 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], + 'responses' => ['200' => ['description' => 'Destino respondeu 2xx'], '422' => ['description' => 'Falha no envio']], + ], + ], + '/outgoing-webhooks/{id}/regenerate-secret' => [ + 'post' => [ + 'tags' => ['Webhooks'], + 'summary' => 'Gerar nova chave secreta', + 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + '/outgoing-webhooks/{id}/deliveries' => [ + 'get' => [ + 'tags' => ['Webhooks'], + 'summary' => 'Histórico de entregas (últimos 30 dias)', + 'parameters' => [ + ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']], + ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'maximum' => 100, 'default' => 30]], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + '/outgoing-webhooks/deliveries/{delivery}/retry' => [ + 'post' => [ + 'tags' => ['Webhooks'], + 'summary' => 'Reenviar uma entrega', + 'parameters' => [['name' => 'delivery', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], + 'responses' => ['200' => ['description' => 'Reenviado'], '422' => ['description' => 'Falha no envio']], + ], + ], ]; } @@ -737,7 +799,9 @@ class OpenApiController extends Controller 'properties' => [ 'id' => ['type' => 'integer'], 'name' => ['type' => 'string'], + 'code' => ['type' => 'string', 'nullable' => true, 'description' => 'Código livre do cliente (ex.: código no seu sistema)'], 'username' => ['type' => 'string', 'nullable' => true], + 'password' => ['type' => 'string', 'nullable' => true, 'description' => 'Senha de acesso do cliente. Criptografada no banco; retornada para o painel e para tokens com a permissão passwords ou admin (omitida para os demais tokens).'], 'whatsapp' => ['type' => 'string', 'nullable' => true], 'status' => ['type' => 'string'], 'expires_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], @@ -752,6 +816,7 @@ class OpenApiController extends Controller 'properties' => [ 'name' => ['type' => 'string'], 'server_id' => ['type' => 'integer'], + 'code' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Código livre do cliente'], 'username' => ['type' => 'string'], 'password' => ['type' => 'string'], 'whatsapp' => ['type' => 'string'], @@ -765,19 +830,59 @@ class OpenApiController extends Controller 'properties' => [ 'id' => ['type' => 'integer'], 'name' => ['type' => 'string'], - 'url' => ['type' => 'string'], + 'contact_name' => ['type' => 'string', 'nullable' => true], + 'contact_whatsapp' => ['type' => 'string', 'nullable' => true], + 'panel_url' => ['type' => 'string', 'nullable' => true, 'description' => 'URL do painel do servidor'], + 'xtream_url' => ['type' => 'string', 'nullable' => true, 'description' => 'URL do Xtream (DNS usada nos aplicativos)'], + 'panel_username' => ['type' => 'string', 'nullable' => true], + 'panel_password' => ['type' => 'string', 'nullable' => true], 'active' => ['type' => 'boolean'], 'credits_balance' => ['type' => 'integer'], + 'clients_count' => ['type' => 'integer'], ], ], 'ServerInput' => [ 'type' => 'object', - 'required' => ['name', 'url'], + 'required' => ['name'], + 'properties' => [ + 'name' => ['type' => 'string', 'maxLength' => 255], + 'contact_name' => ['type' => 'string'], + 'contact_whatsapp' => ['type' => 'string', 'description' => 'Só números, ex.: 5511999999999'], + 'panel_url' => ['type' => 'string', 'format' => 'uri', 'maxLength' => 500], + 'xtream_url' => ['type' => 'string', 'format' => 'uri', 'maxLength' => 500, 'example' => 'http://dns.exemplo.com:8080'], + 'panel_username' => ['type' => 'string'], + 'panel_password' => ['type' => 'string'], + 'active' => ['type' => 'boolean'], + ], + ], + 'OutgoingWebhook' => [ + 'type' => 'object', 'properties' => [ + 'id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'url' => ['type' => 'string', 'format' => 'uri'], - 'username' => ['type' => 'string'], - 'password' => ['type' => 'string'], + 'events' => ['type' => 'array', 'items' => ['type' => 'string']], + 'include_sensitive' => ['type' => 'boolean', 'description' => 'Envia senhas (cliente e painel). Exige URL HTTPS.'], + 'active' => ['type' => 'boolean'], + 'secret' => ['type' => 'string', 'description' => 'Chave para validar a assinatura (só em criar/mostrar/regenerar)'], + 'last_status_code' => ['type' => 'integer', 'nullable' => true], + 'last_error' => ['type' => 'string', 'nullable' => true], + 'last_triggered_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true], + ], + ], + 'OutgoingWebhookInput' => [ + 'type' => 'object', + 'required' => ['name', 'url', 'events'], + 'properties' => [ + 'name' => ['type' => 'string', 'maxLength' => 100], + 'url' => ['type' => 'string', 'format' => 'uri', 'description' => 'URL pública (endereços internos/privados são bloqueados)'], + 'events' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => ['client.created', 'client.updated', 'client.renewed', 'client.deleted', 'server.created', 'server.updated', 'server.deleted', 'user.created', 'user.deleted']], + 'description' => 'user.* só para administradores', + ], + 'include_sensitive' => ['type' => 'boolean', 'default' => false], + 'active' => ['type' => 'boolean', 'default' => true], ], ], 'WhatsAppConnection' => [ diff --git a/new/backend/app/Http/Controllers/Api/OutgoingWebhookController.php b/backend/app/Http/Controllers/Api/OutgoingWebhookController.php new file mode 100644 index 0000000..db8832a --- /dev/null +++ b/backend/app/Http/Controllers/Api/OutgoingWebhookController.php @@ -0,0 +1,214 @@ +user()->id) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json([ + 'webhooks' => $webhooks, + 'events' => OutgoingWebhook::availableEventsFor($request->user()), + ]); + } + + public function events(Request $request) + { + return response()->json([ + 'events' => OutgoingWebhook::availableEventsFor($request->user()), + ]); + } + + public function store(Request $request) + { + $user = $request->user(); + + if (OutgoingWebhook::where('user_id', $user->id)->count() >= OutgoingWebhook::MAX_PER_USER) { + return response()->json([ + 'message' => 'Limite de ' . OutgoingWebhook::MAX_PER_USER . ' webhooks atingido.', + ], 422); + } + + $validated = $this->validateWebhook($request); + + if ($error = OutgoingWebhookService::validateUrl($validated['url'], (bool) ($validated['include_sensitive'] ?? false))) { + return response()->json(['message' => $error, 'errors' => ['url' => [$error]]], 422); + } + + $webhook = OutgoingWebhook::create([ + 'user_id' => $user->id, + 'name' => $validated['name'], + 'url' => trim($validated['url']), + 'secret' => $this->newSecret(), + 'events' => array_values(array_unique($validated['events'])), + 'include_sensitive' => (bool) ($validated['include_sensitive'] ?? false), + 'active' => (bool) ($validated['active'] ?? true), + ]); + + AuditLog::log('outgoing_webhook_created', $user, $webhook, ['name' => $webhook->name]); + + return response()->json([ + 'webhook' => $webhook->makeVisible('secret'), + 'message' => 'Webhook criado com sucesso', + ], 201); + } + + public function show(Request $request, $id) + { + $webhook = $this->findOwned($request, $id); + + return response()->json([ + 'webhook' => $webhook->makeVisible('secret'), + ]); + } + + public function update(Request $request, $id) + { + $webhook = $this->findOwned($request, $id); + $validated = $this->validateWebhook($request, true); + + $url = $validated['url'] ?? $webhook->url; + $includeSensitive = array_key_exists('include_sensitive', $validated) + ? (bool) $validated['include_sensitive'] + : $webhook->include_sensitive; + + if ($error = OutgoingWebhookService::validateUrl($url, $includeSensitive)) { + return response()->json(['message' => $error, 'errors' => ['url' => [$error]]], 422); + } + + $data = [ + 'url' => trim($url), + 'include_sensitive' => $includeSensitive, + ]; + + if (array_key_exists('name', $validated)) { + $data['name'] = $validated['name']; + } + if (array_key_exists('events', $validated)) { + $data['events'] = array_values(array_unique($validated['events'])); + } + if (array_key_exists('active', $validated)) { + $data['active'] = (bool) $validated['active']; + if ($data['active']) { + $data['consecutive_failures'] = 0; + } + } + + $webhook->update($data); + + AuditLog::log('outgoing_webhook_updated', $request->user(), $webhook, ['name' => $webhook->name]); + + return response()->json([ + 'webhook' => $webhook->fresh()->makeVisible('secret'), + 'message' => 'Webhook atualizado com sucesso', + ]); + } + + public function destroy(Request $request, $id) + { + $webhook = $this->findOwned($request, $id); + + AuditLog::log('outgoing_webhook_deleted', $request->user(), $webhook, ['name' => $webhook->name]); + + $webhook->delete(); + + return response()->json(['message' => 'Webhook removido com sucesso']); + } + + public function regenerateSecret(Request $request, $id) + { + $webhook = $this->findOwned($request, $id); + $webhook->update(['secret' => $this->newSecret()]); + + return response()->json([ + 'webhook' => $webhook->fresh()->makeVisible('secret'), + 'message' => 'Nova chave secreta gerada. Atualize-a no sistema que recebe o webhook.', + ]); + } + + public function test(Request $request, $id) + { + $webhook = $this->findOwned($request, $id); + $delivery = OutgoingWebhookService::sendTest($webhook); + + $ok = $delivery->status === OutgoingWebhookDelivery::STATUS_SUCCESS; + + return response()->json([ + 'success' => $ok, + 'message' => $ok + ? "Teste enviado com sucesso (HTTP {$delivery->response_code})" + : 'Falha no teste: ' . ($delivery->error ?? 'erro desconhecido'), + 'delivery' => $delivery->makeVisible('payload'), + ], $ok ? 200 : 422); + } + + public function deliveries(Request $request, $id) + { + $webhook = $this->findOwned($request, $id); + + $deliveries = $webhook->deliveries() + ->orderBy('id', 'desc') + ->limit(min(100, max(1, (int) $request->get('limit', 30)))) + ->get() + ->each->makeVisible('payload'); + + return response()->json(['deliveries' => $deliveries]); + } + + public function retryDelivery(Request $request, $deliveryId) + { + $delivery = OutgoingWebhookDelivery::whereHas('webhook', function ($q) use ($request) { + $q->where('user_id', $request->user()->id); + })->findOrFail($deliveryId); + + $delivery = OutgoingWebhookService::retry($delivery); + $ok = $delivery->status === OutgoingWebhookDelivery::STATUS_SUCCESS; + + return response()->json([ + 'success' => $ok, + 'message' => $ok ? 'Reenviado com sucesso' : 'Falha ao reenviar: ' . ($delivery->error ?? 'erro desconhecido'), + 'delivery' => $delivery->makeVisible('payload'), + ], $ok ? 200 : 422); + } + + private function findOwned(Request $request, $id): OutgoingWebhook + { + return OutgoingWebhook::where('user_id', $request->user()->id)->findOrFail($id); + } + + private function validateWebhook(Request $request, bool $partial = false): array + { + $required = $partial ? 'sometimes' : 'required'; + $events = array_keys(OutgoingWebhook::availableEventsFor($request->user())); + + return $request->validate([ + 'name' => [$required, 'string', 'max:100'], + 'url' => [$required, 'string', 'max:2000'], + 'events' => [$required, 'array', 'min:1'], + 'events.*' => ['string', Rule::in($events)], + 'include_sensitive' => ['sometimes', 'boolean'], + 'active' => ['sometimes', 'boolean'], + ], [ + 'events.min' => 'Selecione pelo menos um evento.', + 'events.*.in' => 'Evento inválido.', + ]); + } + + private function newSecret(): string + { + return 'whsec_' . Str::random(40); + } +} diff --git a/backend/app/Http/Controllers/Api/ServerController.php b/backend/app/Http/Controllers/Api/ServerController.php index 3f46de5..e2ebe21 100644 --- a/backend/app/Http/Controllers/Api/ServerController.php +++ b/backend/app/Http/Controllers/Api/ServerController.php @@ -48,12 +48,13 @@ class ServerController extends Controller 'contact_name' => 'nullable|string|max:255', 'contact_whatsapp' => 'nullable|string|max:20|regex:/^[0-9]+$/', 'panel_url' => 'nullable|url|max:500', + 'xtream_url' => 'nullable|url|max:500', 'panel_username' => 'nullable|string|max:255', 'panel_password' => 'nullable|string|max:255', 'active' => 'sometimes|boolean', ]); - $server = Server::create([ + $data = [ 'user_id' => $request->user()->id, 'name' => $request->name, 'contact_name' => $request->contact_name, @@ -62,7 +63,13 @@ class ServerController extends Controller 'panel_username' => $request->panel_username, 'panel_password' => $request->panel_password, 'active' => $request->get('active', true), - ]); + ]; + + if ($request->has('xtream_url')) { + $data['xtream_url'] = $request->xtream_url; + } + + $server = Server::create($data); AuditLog::log('server_created', $request->user(), $server); @@ -101,13 +108,14 @@ class ServerController extends Controller 'contact_name' => 'sometimes|string|max:255', 'contact_whatsapp' => 'sometimes|string|max:20|regex:/^[0-9]+$/', 'panel_url' => 'sometimes|nullable|url|max:500', + 'xtream_url' => 'sometimes|nullable|url|max:500', 'panel_username' => 'sometimes|nullable|string|max:255', 'panel_password' => 'sometimes|nullable|string|max:255', 'active' => 'sometimes|boolean', ]); $server->update($request->only([ - 'name', 'contact_name', 'contact_whatsapp', 'panel_url', 'panel_username', 'panel_password', 'active' + 'name', 'contact_name', 'contact_whatsapp', 'panel_url', 'xtream_url', 'panel_username', 'panel_password', 'active' ])); AuditLog::log('server_updated', $request->user(), $server); diff --git a/backend/app/Models/Client.php b/backend/app/Models/Client.php index 8cd2831..47c2e82 100644 --- a/backend/app/Models/Client.php +++ b/backend/app/Models/Client.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Casts\EncryptedString; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -13,6 +14,7 @@ class Client extends Model 'user_id', 'server_id', 'name', + 'code', 'username', 'password', 'whatsapp', @@ -28,10 +30,71 @@ class Client extends Model 'start_at' => 'date', 'expires_at' => 'date', 'meta' => 'array', + 'password' => EncryptedString::class, 'valor_mensalidade' => 'decimal:2', 'screens' => 'integer', ]; + /** + * A senha do cliente (acesso ao servidor) precisa ser legível para ser + * enviada ao cliente final, então fica criptografada no banco e só aparece + * nas respostas da API para o painel ou para tokens com a permissão "passwords". + */ + public function toArray() + { + $array = parent::toArray(); + + if (array_key_exists('password', $array) && !static::canRevealPasswords()) { + unset($array['password']); + } + + return $array; + } + + /** + * O painel (login normal) e tokens com "passwords", "admin" ou "*" veem a senha. + * Tokens de API criados só com read/write/delete não veem. + */ + public static function canRevealPasswords(): bool + { + if (app()->runningInConsole()) { + return true; + } + + $token = request()->user()?->currentAccessToken(); + + if (!$token || !method_exists($token, 'can') || $token instanceof \Laravel\Sanctum\TransientToken) { + return true; + } + + $abilities = (array) ($token->abilities ?? []); + + // Token do login do painel (abilities = ["*"]) + if (in_array('*', $abilities, true)) { + return true; + } + + return in_array('passwords', $abilities, true) || in_array('admin', $abilities, true); + } + + /** + * A coluna "code" só existe depois da migration 2026_10_16_000003. + */ + public static function hasCodeColumn(): bool + { + static $hasColumn = null; + + if ($hasColumn === null) { + try { + $hasColumn = \Illuminate\Support\Facades\Schema::hasColumn('clients', 'code'); + } catch (\Throwable $e) { + $hasColumn = false; + } + } + + return $hasColumn; + } + /** * Get the user that owns the client. */ diff --git a/new/backend/app/Models/OutgoingWebhook.php b/backend/app/Models/OutgoingWebhook.php new file mode 100644 index 0000000..a5fda38 --- /dev/null +++ b/backend/app/Models/OutgoingWebhook.php @@ -0,0 +1,89 @@ + 'Cliente cadastrado', + 'client.updated' => 'Cliente alterado', + 'client.renewed' => 'Cliente renovado', + 'client.deleted' => 'Cliente removido', + 'server.created' => 'Servidor cadastrado', + 'server.updated' => 'Servidor alterado', + 'server.deleted' => 'Servidor removido', + ]; + + /** + * Eventos do sistema, só para administradores. + */ + public const ADMIN_EVENTS = [ + 'user.created' => 'Usuário cadastrado no sistema', + 'user.deleted' => 'Usuário removido do sistema', + ]; + + public const MAX_PER_USER = 10; + + /** Depois de tantas falhas seguidas o webhook é desativado. */ + public const MAX_CONSECUTIVE_FAILURES = 50; + + protected $fillable = [ + 'user_id', + 'name', + 'url', + 'secret', + 'events', + 'include_sensitive', + 'active', + 'last_status_code', + 'last_error', + 'last_triggered_at', + 'consecutive_failures', + ]; + + protected $hidden = [ + 'secret', + ]; + + protected $casts = [ + 'secret' => 'encrypted', + 'events' => 'array', + 'include_sensitive' => 'boolean', + 'active' => 'boolean', + 'last_triggered_at' => 'datetime', + 'consecutive_failures' => 'integer', + ]; + + public static function availableEventsFor(?User $user): array + { + if ($user && $user->isAdmin()) { + return self::USER_EVENTS + self::ADMIN_EVENTS; + } + + return self::USER_EVENTS; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(OutgoingWebhookDelivery::class); + } + + public function listensTo(string $event): bool + { + $events = $this->events ?? []; + + return in_array($event, $events, true) || in_array('*', $events, true); + } +} diff --git a/new/backend/app/Models/OutgoingWebhookDelivery.php b/backend/app/Models/OutgoingWebhookDelivery.php new file mode 100644 index 0000000..6669db7 --- /dev/null +++ b/backend/app/Models/OutgoingWebhookDelivery.php @@ -0,0 +1,53 @@ + 'encrypted:array', + 'attempts' => 'integer', + 'response_code' => 'integer', + 'next_attempt_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + + public function webhook(): BelongsTo + { + return $this->belongsTo(OutgoingWebhook::class, 'outgoing_webhook_id'); + } + + public static function maxAttempts(): int + { + return count(self::RETRY_DELAYS) + 1; + } +} diff --git a/backend/app/Models/Server.php b/backend/app/Models/Server.php index 2dc7122..e4f77cc 100644 --- a/backend/app/Models/Server.php +++ b/backend/app/Models/Server.php @@ -15,6 +15,7 @@ class Server extends Model 'contact_name', 'contact_whatsapp', 'panel_url', + 'xtream_url', 'panel_username', 'panel_password', 'meta', diff --git a/new/backend/app/Observers/OutgoingWebhookObserver.php b/backend/app/Observers/OutgoingWebhookObserver.php new file mode 100644 index 0000000..97acd68 --- /dev/null +++ b/backend/app/Observers/OutgoingWebhookObserver.php @@ -0,0 +1,90 @@ + ['updated_at', 'meta'], + Server::class => ['updated_at', 'credits_balance', 'meta'], + ]; + + public function created(Model $model): void + { + if ($prefix = $this->prefix($model)) { + OutgoingWebhookService::trigger("{$prefix}.created", $model); + } + } + + public function updated(Model $model): void + { + $prefix = $this->prefix($model); + + // Para usuários só há created/deleted (login e afins atualizam o registro o tempo todo) + if (!$prefix || $model instanceof User) { + return; + } + + $changes = OutgoingWebhookService::changesFrom($model, self::IGNORED_UPDATES[get_class($model)] ?? ['updated_at']); + + if (empty($changes)) { + return; + } + + $event = "{$prefix}.updated"; + + if ($model instanceof Client && $this->isRenewal($model)) { + $event = 'client.renewed'; + } + + OutgoingWebhookService::trigger($event, $model, $changes); + } + + public function deleted(Model $model): void + { + if ($prefix = $this->prefix($model)) { + OutgoingWebhookService::trigger("{$prefix}.deleted", $model); + } + } + + private function prefix(Model $model): ?string + { + return match (true) { + $model instanceof Client => 'client', + $model instanceof Server => 'server', + $model instanceof User => 'user', + default => null, + }; + } + + /** + * Renovação = o vencimento foi para frente. + */ + private function isRenewal(Client $client): bool + { + if (!$client->wasChanged('expires_at') || !$client->expires_at) { + return false; + } + + $old = $client->getOriginal('expires_at'); + + if (!$old) { + return true; + } + + return Carbon::parse($client->expires_at)->greaterThan(Carbon::parse($old)); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 7e6f0c6..23e6216 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -27,6 +27,11 @@ class AppServiceProvider extends ServiceProvider // string length avoids "Specified key was too long" on migrations. Schema::defaultStringLength(191); + // Webhooks de saída: avisam sistemas externos sobre cadastros/alterações + \App\Models\Client::observe(\App\Observers\OutgoingWebhookObserver::class); + \App\Models\Server::observe(\App\Observers\OutgoingWebhookObserver::class); + \App\Models\User::observe(\App\Observers\OutgoingWebhookObserver::class); + // Load system helper functions if (file_exists($helper = app_path('Helpers/SystemHelper.php'))) { require_once $helper; diff --git a/new/backend/app/Services/OutgoingWebhookService.php b/backend/app/Services/OutgoingWebhookService.php new file mode 100644 index 0000000..a619c6d --- /dev/null +++ b/backend/app/Services/OutgoingWebhookService.php @@ -0,0 +1,562 @@ +include_sensitive; + + $delivery = OutgoingWebhookDelivery::create([ + 'outgoing_webhook_id' => $webhook->id, + 'event' => $event, + 'payload' => [ + 'event' => $event, + 'occurred_at' => now()->toIso8601String(), + 'data' => self::serialize($model, $includeSensitive), + 'changes' => self::filterChanges($changes, $includeSensitive), + ], + 'status' => OutgoingWebhookDelivery::STATUS_PENDING, + 'next_attempt_at' => now(), + ]); + + self::queueImmediate($delivery->id); + } + } catch (\Throwable $e) { + // Um webhook nunca pode impedir o cadastro de cliente/servidor/usuário + Log::warning('OutgoingWebhook: falha ao registrar evento', [ + 'event' => $event, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Envia um evento de teste e devolve o resultado na hora. + */ + public static function sendTest(OutgoingWebhook $webhook): OutgoingWebhookDelivery + { + $user = $webhook->user; + + $delivery = OutgoingWebhookDelivery::create([ + 'outgoing_webhook_id' => $webhook->id, + 'event' => 'webhook.test', + 'payload' => [ + 'event' => 'webhook.test', + 'occurred_at' => now()->toIso8601String(), + 'data' => [ + 'message' => 'Teste de webhook do ' . config('app.name', 'GestorStream'), + 'user' => $user ? ['id' => $user->id, 'name' => $user->name] : null, + ], + 'changes' => [], + ], + 'status' => OutgoingWebhookDelivery::STATUS_PROCESSING, + 'next_attempt_at' => now(), + ]); + + self::deliver($delivery, false); + + return $delivery->fresh(); + } + + /** + * Reenvia manualmente uma entrega (ex.: botão "reenviar" no painel). + */ + public static function retry(OutgoingWebhookDelivery $delivery): OutgoingWebhookDelivery + { + $delivery->update([ + 'status' => OutgoingWebhookDelivery::STATUS_PROCESSING, + 'attempts' => 0, + 'next_attempt_at' => now(), + ]); + + self::deliver($delivery); + + return $delivery->fresh(); + } + + /** + * Processa as entregas pendentes (chamado pelo cron). + */ + public static function processPending(int $limit = 50): array + { + $stats = ['sent' => 0, 'failed' => 0]; + + if (!self::tablesExist()) { + return $stats; + } + + // Entregas que ficaram presas em "processing" (processo morto no meio do envio) + OutgoingWebhookDelivery::where('status', OutgoingWebhookDelivery::STATUS_PROCESSING) + ->where('updated_at', '<', now()->subMinutes(10)) + ->update(['status' => OutgoingWebhookDelivery::STATUS_PENDING]); + + $ids = OutgoingWebhookDelivery::where('status', OutgoingWebhookDelivery::STATUS_PENDING) + ->where(function ($q) { + $q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now()); + }) + ->orderBy('id') + ->limit($limit) + ->pluck('id'); + + foreach ($ids as $id) { + $delivery = self::claim($id); + if (!$delivery) { + continue; + } + + self::deliver($delivery) ? $stats['sent']++ : $stats['failed']++; + } + + return $stats; + } + + /** + * Valida a URL de destino. Bloqueia endereços internos (SSRF). + * Retorna a mensagem de erro, ou null se a URL for aceita. + */ + public static function validateUrl(string $url, bool $requireHttps = false): ?string + { + $parts = parse_url(trim($url)); + $scheme = strtolower($parts['scheme'] ?? ''); + + if (!$parts || empty($parts['host']) || !in_array($scheme, ['http', 'https'], true)) { + return 'Informe uma URL completa, começando com https:// (ou http://).'; + } + + if ($requireHttps && $scheme !== 'https') { + return 'Para enviar senhas, a URL precisa ser HTTPS.'; + } + + if (isset($parts['user']) || isset($parts['pass'])) { + return 'Não coloque usuário e senha dentro da URL.'; + } + + if (self::allowPrivateTargets()) { + return null; + } + + $host = strtolower(trim($parts['host'], '[]')); + + if ($host === 'localhost' || preg_match('/\.(localhost|local|internal|lan)$/', $host)) { + return 'Endereços internos não são permitidos.'; + } + + $ips = self::resolveHost($host); + + if (empty($ips)) { + return 'Não foi possível encontrar o domínio da URL.'; + } + + foreach ($ips as $ip) { + if (!self::isPublicIp($ip)) { + return 'Endereços internos ou privados não são permitidos.'; + } + } + + return null; + } + + // ------------------------------------------------------------------ + // Montagem do payload + // ------------------------------------------------------------------ + + public static function serialize(Model $model, bool $includeSensitive): array + { + if ($model instanceof Client) { + $data = [ + 'id' => $model->id, + 'user_id' => $model->user_id, + 'name' => $model->name, + 'code' => $model->code, + 'username' => $model->username, + 'password' => $model->password, + 'whatsapp' => $model->whatsapp, + 'valor_mensalidade' => $model->valor_mensalidade !== null ? (float) $model->valor_mensalidade : null, + 'screens' => $model->screens, + 'status' => $model->calculated_status, + 'start_at' => self::formatValue($model->start_at, true), + 'expires_at' => self::formatValue($model->expires_at, true), + 'server' => null, + 'created_at' => self::formatValue($model->created_at), + 'updated_at' => self::formatValue($model->updated_at), + ]; + + $server = $model->server_id ? Server::find($model->server_id) : null; + if ($server) { + $data['server'] = [ + 'id' => $server->id, + 'name' => $server->name, + 'panel_url' => $server->panel_url, + 'xtream_url' => $server->xtream_url, + ]; + } + } elseif ($model instanceof Server) { + $data = [ + 'id' => $model->id, + 'user_id' => $model->user_id, + 'name' => $model->name, + 'contact_name' => $model->contact_name, + 'contact_whatsapp' => $model->contact_whatsapp, + 'panel_url' => $model->panel_url, + 'xtream_url' => $model->xtream_url, + 'panel_username' => $model->panel_username, + 'panel_password' => $model->panel_password, + 'active' => (bool) $model->active, + 'credits_balance' => $model->credits_balance, + 'created_at' => self::formatValue($model->created_at), + 'updated_at' => self::formatValue($model->updated_at), + ]; + } elseif ($model instanceof User) { + // Nunca envia a senha (hash) do usuário + $data = [ + 'id' => $model->id, + 'name' => $model->name, + 'email' => $model->email, + 'role' => $model->role, + 'document' => $model->document, + 'created_at' => self::formatValue($model->created_at), + ]; + } else { + $data = ['id' => $model->getKey()]; + } + + if (!$includeSensitive) { + foreach (self::SENSITIVE_FIELDS as $field) { + unset($data[$field]); + } + } + + return $data; + } + + /** + * Monta {campo: {old, new}} a partir de um model que acabou de ser salvo. + */ + public static function changesFrom(Model $model, array $ignore = ['updated_at']): array + { + $changes = []; + + foreach (array_keys($model->getChanges()) as $key) { + if (in_array($key, $ignore, true)) { + continue; + } + + $isDate = in_array($key, ['start_at', 'expires_at'], true); + + $changes[$key] = [ + 'old' => self::formatValue($model->getOriginal($key), $isDate), + 'new' => self::formatValue($model->getAttribute($key), $isDate), + ]; + } + + return $changes; + } + + private static function filterChanges(array $changes, bool $includeSensitive): array + { + if ($includeSensitive) { + return $changes; + } + + foreach (self::SENSITIVE_FIELDS as $field) { + if (array_key_exists($field, $changes)) { + $changes[$field] = ['changed' => true]; + } + } + + return $changes; + } + + private static function formatValue(mixed $value, bool $dateOnly = false): mixed + { + if ($value instanceof \DateTimeInterface) { + $carbon = Carbon::instance($value); + return $dateOnly ? $carbon->format('Y-m-d') : $carbon->toIso8601String(); + } + + if (is_array($value) || is_scalar($value) || $value === null) { + return $value; + } + + return (string) $value; + } + + // ------------------------------------------------------------------ + // Envio + // ------------------------------------------------------------------ + + private static function webhooksFor(string $event, Model $model) + { + $query = OutgoingWebhook::query()->where('active', true); + + if (str_starts_with($event, 'user.')) { + // Eventos de usuário vão só para webhooks de administradores + $query->whereHas('user', fn ($q) => $q->where('role', 'admin')); + } else { + $ownerId = $model->getAttribute('user_id'); + if (!$ownerId) { + return collect(); + } + $query->where('user_id', $ownerId); + } + + return $query->get()->filter(fn (OutgoingWebhook $w) => $w->listensTo($event)); + } + + private static function claim(int $id): ?OutgoingWebhookDelivery + { + $claimed = OutgoingWebhookDelivery::where('id', $id) + ->where('status', OutgoingWebhookDelivery::STATUS_PENDING) + ->update([ + 'status' => OutgoingWebhookDelivery::STATUS_PROCESSING, + 'updated_at' => now(), + ]); + + return $claimed ? OutgoingWebhookDelivery::find($id) : null; + } + + /** + * Faz o POST. Retorna true se o destino respondeu 2xx. + */ + private static function deliver(OutgoingWebhookDelivery $delivery, bool $scheduleRetry = true): bool + { + $webhook = $delivery->webhook; + + if (!$webhook) { + $delivery->update(['status' => OutgoingWebhookDelivery::STATUS_FAILED, 'error' => 'Webhook removido']); + return false; + } + + $attempts = $delivery->attempts + 1; + $responseCode = null; + $responseBody = null; + $error = null; + + $urlError = self::validateUrl($webhook->url, $webhook->include_sensitive); + + if ($urlError) { + $error = $urlError; + } else { + $body = json_encode( + ['id' => $delivery->id, 'webhook_id' => $webhook->id] + ($delivery->payload ?? []), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + $timestamp = (string) time(); + $signature = hash_hmac('sha256', $timestamp . '.' . $body, (string) $webhook->secret); + + try { + $response = Http::timeout(self::TIMEOUT_SECONDS) + ->connectTimeout(5) + ->withOptions(['allow_redirects' => false] + self::pinnedResolveOptions($webhook->url)) + ->withHeaders([ + 'User-Agent' => 'GestorStream-Webhook/1.0', + 'X-Webhook-Event' => $delivery->event, + 'X-Webhook-Delivery' => (string) $delivery->id, + 'X-Webhook-Timestamp' => $timestamp, + 'X-Webhook-Signature' => 'sha256=' . $signature, + ]) + ->withBody($body, 'application/json') + ->post($webhook->url); + + $responseCode = $response->status(); + $responseBody = mb_substr((string) $response->body(), 0, 2000); + + if (!$response->successful()) { + $error = "O destino respondeu HTTP {$responseCode}"; + } + } catch (\Throwable $e) { + $error = mb_substr($e->getMessage(), 0, 1000); + } + } + + $success = $error === null; + + if ($success) { + $delivery->update([ + 'status' => OutgoingWebhookDelivery::STATUS_SUCCESS, + 'attempts' => $attempts, + 'response_code' => $responseCode, + 'response_body' => $responseBody, + 'error' => null, + 'next_attempt_at' => null, + 'delivered_at' => now(), + ]); + + $webhook->update([ + 'last_status_code' => $responseCode, + 'last_error' => null, + 'last_triggered_at' => now(), + 'consecutive_failures' => 0, + ]); + + return true; + } + + $canRetry = $scheduleRetry && $attempts < OutgoingWebhookDelivery::maxAttempts(); + $delay = OutgoingWebhookDelivery::RETRY_DELAYS[$attempts - 1] ?? null; + + $delivery->update([ + 'status' => $canRetry ? OutgoingWebhookDelivery::STATUS_PENDING : OutgoingWebhookDelivery::STATUS_FAILED, + 'attempts' => $attempts, + 'response_code' => $responseCode, + 'response_body' => $responseBody, + 'error' => $error, + 'next_attempt_at' => $canRetry && $delay ? now()->addMinutes($delay) : null, + ]); + + $failures = $webhook->consecutive_failures + 1; + + $webhook->update([ + 'last_status_code' => $responseCode, + 'last_error' => $error, + 'last_triggered_at' => now(), + 'consecutive_failures' => $failures, + 'active' => $failures >= OutgoingWebhook::MAX_CONSECUTIVE_FAILURES ? false : $webhook->active, + ]); + + return false; + } + + private static function queueImmediate(int $deliveryId): void + { + self::$pendingIds[] = $deliveryId; + + if (self::$flushRegistered) { + return; + } + + self::$flushRegistered = true; + + // Roda depois que a resposta já foi enviada ao navegador + app()->terminating(function () { + $ids = array_slice(array_unique(self::$pendingIds), 0, self::IMMEDIATE_LIMIT); + self::$pendingIds = []; + self::$flushRegistered = false; + + foreach ($ids as $id) { + try { + $delivery = self::claim($id); + if ($delivery) { + self::deliver($delivery); + } + } catch (\Throwable $e) { + Log::warning('OutgoingWebhook: falha no envio imediato', ['delivery_id' => $id, 'error' => $e->getMessage()]); + } + } + }); + } + + private static function tablesExist(): bool + { + if (self::$tablesExist === null) { + try { + self::$tablesExist = Schema::hasTable('outgoing_webhooks') && Schema::hasTable('outgoing_webhook_deliveries'); + } catch (\Throwable $e) { + self::$tablesExist = false; + } + } + + return self::$tablesExist; + } + + private static function allowPrivateTargets(): bool + { + return filter_var(env('OUTGOING_WEBHOOKS_ALLOW_PRIVATE', false), FILTER_VALIDATE_BOOLEAN); + } + + private static function resolveHost(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP)) { + return [$host]; + } + + $ips = @gethostbynamel($host) ?: []; + + $aaaa = @dns_get_record($host, DNS_AAAA) ?: []; + foreach ($aaaa as $record) { + if (!empty($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + + return array_values(array_unique($ips)); + } + + private static function isPublicIp(string $ip): bool + { + return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + } + + /** + * Fixa o IP já validado na conexão, para o DNS não trocar o destino + * entre a validação e o envio. + */ + private static function pinnedResolveOptions(string $url): array + { + if (self::allowPrivateTargets() || !defined('CURLOPT_RESOLVE')) { + return []; + } + + $parts = parse_url($url); + $host = strtolower(trim($parts['host'] ?? '', '[]')); + + if ($host === '' || filter_var($host, FILTER_VALIDATE_IP)) { + return []; + } + + $ipv4 = @gethostbynamel($host) ?: []; + $ip = collect($ipv4)->first(fn ($candidate) => self::isPublicIp($candidate)); + + if (!$ip) { + return []; + } + + $port = $parts['port'] ?? (strtolower($parts['scheme'] ?? '') === 'https' ? 443 : 80); + + return ['curl' => [CURLOPT_RESOLVE => ["{$host}:{$port}:{$ip}"]]]; + } +} diff --git a/new/backend/database/migrations/2026_10_16_000000_change_clients_password_to_text.php b/backend/database/migrations/2026_10_16_000000_change_clients_password_to_text.php new file mode 100644 index 0000000..172dff5 --- /dev/null +++ b/backend/database/migrations/2026_10_16_000000_change_clients_password_to_text.php @@ -0,0 +1,34 @@ +whereRaw('CHAR_LENGTH(password) > 255')->exists()) { + throw new \RuntimeException('Existem senhas criptografadas. Rode "php artisan clients:encrypt-passwords --decrypt" antes de reverter esta migration.'); + } + + DB::statement('ALTER TABLE clients MODIFY password VARCHAR(255) NULL'); + } +}; diff --git a/new/backend/database/migrations/2026_10_16_000001_create_outgoing_webhooks_tables.php b/backend/database/migrations/2026_10_16_000001_create_outgoing_webhooks_tables.php new file mode 100644 index 0000000..8a1a5a7 --- /dev/null +++ b/backend/database/migrations/2026_10_16_000001_create_outgoing_webhooks_tables.php @@ -0,0 +1,57 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('url'); + $table->text('secret'); + $table->json('events'); + $table->boolean('include_sensitive')->default(false); + $table->boolean('active')->default(true); + $table->unsignedSmallInteger('last_status_code')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamp('last_triggered_at')->nullable(); + $table->unsignedInteger('consecutive_failures')->default(0); + $table->timestamps(); + + $table->index(['user_id', 'active']); + }); + + Schema::create('outgoing_webhook_deliveries', function (Blueprint $table) { + $table->id(); + $table->foreignId('outgoing_webhook_id')->constrained('outgoing_webhooks')->cascadeOnDelete(); + $table->string('event', 64); + $table->longText('payload'); + $table->string('status', 20)->default('pending'); + $table->unsignedTinyInteger('attempts')->default(0); + $table->unsignedSmallInteger('response_code')->nullable(); + $table->text('response_body')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('next_attempt_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'next_attempt_at']); + $table->index(['outgoing_webhook_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('outgoing_webhook_deliveries'); + Schema::dropIfExists('outgoing_webhooks'); + } +}; diff --git a/new/backend/database/migrations/2026_10_16_000002_add_xtream_url_to_servers_table.php b/backend/database/migrations/2026_10_16_000002_add_xtream_url_to_servers_table.php new file mode 100644 index 0000000..8f2748d --- /dev/null +++ b/backend/database/migrations/2026_10_16_000002_add_xtream_url_to_servers_table.php @@ -0,0 +1,33 @@ +string('xtream_url', 500)->nullable()->after('panel_url'); + }); + } + + public function down(): void + { + if (!Schema::hasColumn('servers', 'xtream_url')) { + return; + } + + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('xtream_url'); + }); + } +}; diff --git a/new/backend/database/migrations/2026_10_16_000003_add_code_to_clients_table.php b/backend/database/migrations/2026_10_16_000003_add_code_to_clients_table.php new file mode 100644 index 0000000..c9c73a7 --- /dev/null +++ b/backend/database/migrations/2026_10_16_000003_add_code_to_clients_table.php @@ -0,0 +1,35 @@ +string('code', 100)->nullable()->after('name'); + $table->index(['user_id', 'code']); + }); + } + + public function down(): void + { + if (!Schema::hasColumn('clients', 'code')) { + return; + } + + Schema::table('clients', function (Blueprint $table) { + $table->dropIndex(['user_id', 'code']); + $table->dropColumn('code'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 7346942..d8156bb 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -335,6 +335,20 @@ Route::middleware(['auth:sanctum', 'api.limit'])->prefix('v1')->group(function ( Route::post('/revoke-all', [\App\Http\Controllers\Api\ApiTokenController::class, 'revokeAll']); }); + // Webhooks de saída (notificações para sistemas externos) + Route::prefix('outgoing-webhooks')->group(function () { + Route::get('/', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'index']); + Route::post('/', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'store'])->middleware('throttle:20,1'); + Route::get('/events', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'events']); + Route::post('/deliveries/{delivery}/retry', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'retryDelivery'])->whereNumber('delivery')->middleware('throttle:20,1'); + Route::get('/{id}', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'show'])->whereNumber('id'); + Route::put('/{id}', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'update'])->whereNumber('id'); + Route::delete('/{id}', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'destroy'])->whereNumber('id'); + Route::post('/{id}/test', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'test'])->whereNumber('id')->middleware('throttle:10,1'); + Route::post('/{id}/regenerate-secret', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'regenerateSecret'])->whereNumber('id'); + Route::get('/{id}/deliveries', [\App\Http\Controllers\Api\OutgoingWebhookController::class, 'deliveries'])->whereNumber('id'); + }); + // Affiliates routes (usuário) Route::prefix('affiliates')->group(function () { Route::get('/', [AffiliateController::class, 'index']); diff --git a/backend/routes/console.php b/backend/routes/console.php index df804eb..e0daea5 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -126,3 +126,18 @@ Schedule::command('queue:work --stop-when-empty --max-time=50') ->name('cpanel-queue-drain') ->withoutOverlapping() ->when(fn () => config('queue.default') === 'database'); + +// Webhooks de saída: reenvia o que falhou ou ficou pendente (o envio normal +// acontece logo após cada cadastro/alteração). +Schedule::command('webhooks:process --limit=50') + ->everyMinute() + ->name('outgoing-webhooks-process') + ->withoutOverlapping() + ->runInBackground(); + +// Webhooks de saída: mantém só 30 dias de histórico de entregas +Schedule::command('webhooks:process --prune --days=30') + ->daily() + ->at('03:30') + ->name('outgoing-webhooks-prune') + ->withoutOverlapping(); diff --git a/frontend/src/components/ApiTokensTab.tsx b/frontend/src/components/ApiTokensTab.tsx index 224bef2..493be3f 100644 --- a/frontend/src/components/ApiTokensTab.tsx +++ b/frontend/src/components/ApiTokensTab.tsx @@ -1,430 +1,431 @@ -import { useState, useEffect } from 'react'; -import { Plus, Trash2, Copy, Check, Eye, EyeOff, Calendar, Key, AlertCircle, BookOpen, ExternalLink } from 'lucide-react'; -import api from '../lib/api'; -import { toast } from './Toast'; -import { ConfirmDialog } from './ConfirmDialog'; - -interface ApiToken { - id: number; - name: string; - abilities: string[]; - last_used_at: string | null; - expires_at: string | null; - created_at: string; - is_expired: boolean; -} - -export function ApiTokensTab() { - const [tokens, setTokens] = useState([]); - const [loading, setLoading] = useState(true); - const [showCreateModal, setShowCreateModal] = useState(false); - const [newTokenName, setNewTokenName] = useState(''); - const [newTokenAbilities, setNewTokenAbilities] = useState(['read', 'write']); - const [newTokenExpiresAt, setNewTokenExpiresAt] = useState(''); - const [creating, setCreating] = useState(false); - const [newToken, setNewToken] = useState(null); - const [showToken, setShowToken] = useState(false); - const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; tokenId: number | null }>({ - show: false, - tokenId: null, - }); - - useEffect(() => { - loadTokens(); - }, []); - - const loadTokens = async () => { - try { - setLoading(true); - const { data } = await api.get('/api-tokens'); - setTokens(data.tokens || []); - } catch (error) { - console.error('Erro ao carregar tokens:', error); - toast.error('Erro ao carregar tokens de API'); - } finally { - setLoading(false); - } - }; - - const handleCreateToken = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!newTokenName.trim()) { - toast.error('Informe um nome para o token'); - return; - } - - if (newTokenAbilities.length === 0) { - toast.error('Selecione pelo menos uma permissão'); - return; - } - - setCreating(true); - try { - const { data } = await api.post('/api-tokens', { - name: newTokenName, - abilities: newTokenAbilities, - expires_at: newTokenExpiresAt || null, - }); - - setNewToken(data.token.token); - setShowToken(true); - setNewTokenName(''); - setNewTokenAbilities(['read', 'write']); - setNewTokenExpiresAt(''); - await loadTokens(); - } catch (error: any) { - console.error('Erro ao criar token:', error); - const errorMessage = error.response?.data?.message || 'Erro ao criar token'; - toast.error(errorMessage); - } finally { - setCreating(false); - } - }; - - const handleDeleteToken = async (tokenId: number) => { - try { - await api.delete(`/api-tokens/${tokenId}`); - toast.success('Token excluído com sucesso'); - await loadTokens(); - } catch (error: any) { - console.error('Erro ao excluir token:', error); - const errorMessage = error.response?.data?.message || 'Erro ao excluir token'; - toast.error(errorMessage); - } - }; - - const handleCopyToken = (token: string) => { - navigator.clipboard.writeText(token); - toast.success('Token copiado para a área de transferência!'); - }; - - const toggleAbility = (ability: string) => { - if (newTokenAbilities.includes(ability)) { - setNewTokenAbilities(newTokenAbilities.filter(a => a !== ability)); - } else { - setNewTokenAbilities([...newTokenAbilities, ability]); - } - }; - - const formatDate = (dateString: string | null) => { - if (!dateString) return 'Nunca'; - const date = new Date(dateString); - return date.toLocaleString('pt-BR'); - }; - - const getAbilityLabel = (ability: string) => { - const labels: Record = { - read: 'Leitura', - write: 'Escrita', - delete: 'Exclusão', - admin: 'Administrador', - }; - return labels[ability] || ability; - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
-
-
- -

- Gerencie seus tokens de acesso para usar a API e integrações -

-
- -
- - {/* Alerta de segurança */} -
- -
-

Importante sobre segurança

-

- Os tokens de API fornecem acesso completo à sua conta. Mantenha-os seguros e não os compartilhe. - Revogue tokens que não estão mais em uso. -

-
-
- - {/* Lista de tokens */} - {tokens.length === 0 ? ( -
- -

Nenhum token criado

-

Crie um token para começar a usar a API

-
- ) : ( -
- - - - - - - - - - - - {tokens.map((token) => ( - - - - - - - - ))} - -
- Nome - - Permissões - - Último Uso - - Expira em - - Ações -
-
{token.name}
-
- Criado em {new Date(token.created_at).toLocaleDateString('pt-BR')} -
-
-
- {token.abilities.map((ability) => ( - - {getAbilityLabel(ability)} - - ))} -
-
- {formatDate(token.last_used_at)} - - {token.expires_at ? ( - - {formatDate(token.expires_at)} - {token.is_expired && ' (Expirado)'} - - ) : ( - Nunca - )} - - -
-
- )} - - {/* Modal de criação de token */} - {showCreateModal && ( -
{ - if (e.target === e.currentTarget && !showToken) { - setShowCreateModal(false); - } - }} - > -
-
-

- {showToken ? 'Token Criado' : 'Criar Novo Token'} -

- -
- -
- {showToken && newToken ? ( -
-
-

- ⚠️ Guarde este token com segurança! -

-

- Você só verá este token uma vez. Certifique-se de copiá-lo agora. -

-
- -
- -
-
- {newToken} -
- -
-
- -
-

Como usar:

- - Authorization: Bearer {newToken.substring(0, 20)}... - -
- - -
- ) : ( -
-
- - setNewTokenName(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" - placeholder="Ex: Meu App, Integração XYZ..." - required - /> -
- -
- -
- {['read', 'write', 'delete', 'admin'].map((ability) => ( - - ))} -
-
- -
- - setNewTokenExpiresAt(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" - /> -

- Deixe em branco para token sem expiração -

-
- -
- - -
-
- )} -
-
-
- )} - - {/* Confirm Dialog para exclusão */} - { - if (deleteConfirm.tokenId) { - await handleDeleteToken(deleteConfirm.tokenId); - } - setDeleteConfirm({ show: false, tokenId: null }); - }} - onClose={() => setDeleteConfirm({ show: false, tokenId: null })} - /> -
- ); -} - +import { useState, useEffect } from 'react'; +import { Plus, Trash2, Copy, Check, Eye, EyeOff, Calendar, Key, AlertCircle, BookOpen, ExternalLink } from 'lucide-react'; +import api from '../lib/api'; +import { toast } from './Toast'; +import { ConfirmDialog } from './ConfirmDialog'; + +interface ApiToken { + id: number; + name: string; + abilities: string[]; + last_used_at: string | null; + expires_at: string | null; + created_at: string; + is_expired: boolean; +} + +export function ApiTokensTab() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [newTokenName, setNewTokenName] = useState(''); + const [newTokenAbilities, setNewTokenAbilities] = useState(['read', 'write']); + const [newTokenExpiresAt, setNewTokenExpiresAt] = useState(''); + const [creating, setCreating] = useState(false); + const [newToken, setNewToken] = useState(null); + const [showToken, setShowToken] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; tokenId: number | null }>({ + show: false, + tokenId: null, + }); + + useEffect(() => { + loadTokens(); + }, []); + + const loadTokens = async () => { + try { + setLoading(true); + const { data } = await api.get('/api-tokens'); + setTokens(data.tokens || []); + } catch (error) { + console.error('Erro ao carregar tokens:', error); + toast.error('Erro ao carregar tokens de API'); + } finally { + setLoading(false); + } + }; + + const handleCreateToken = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!newTokenName.trim()) { + toast.error('Informe um nome para o token'); + return; + } + + if (newTokenAbilities.length === 0) { + toast.error('Selecione pelo menos uma permissão'); + return; + } + + setCreating(true); + try { + const { data } = await api.post('/api-tokens', { + name: newTokenName, + abilities: newTokenAbilities, + expires_at: newTokenExpiresAt || null, + }); + + setNewToken(data.token.token); + setShowToken(true); + setNewTokenName(''); + setNewTokenAbilities(['read', 'write']); + setNewTokenExpiresAt(''); + await loadTokens(); + } catch (error: any) { + console.error('Erro ao criar token:', error); + const errorMessage = error.response?.data?.message || 'Erro ao criar token'; + toast.error(errorMessage); + } finally { + setCreating(false); + } + }; + + const handleDeleteToken = async (tokenId: number) => { + try { + await api.delete(`/api-tokens/${tokenId}`); + toast.success('Token excluído com sucesso'); + await loadTokens(); + } catch (error: any) { + console.error('Erro ao excluir token:', error); + const errorMessage = error.response?.data?.message || 'Erro ao excluir token'; + toast.error(errorMessage); + } + }; + + const handleCopyToken = (token: string) => { + navigator.clipboard.writeText(token); + toast.success('Token copiado para a área de transferência!'); + }; + + const toggleAbility = (ability: string) => { + if (newTokenAbilities.includes(ability)) { + setNewTokenAbilities(newTokenAbilities.filter(a => a !== ability)); + } else { + setNewTokenAbilities([...newTokenAbilities, ability]); + } + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return 'Nunca'; + const date = new Date(dateString); + return date.toLocaleString('pt-BR'); + }; + + const getAbilityLabel = (ability: string) => { + const labels: Record = { + read: 'Leitura', + write: 'Escrita', + delete: 'Exclusão', + admin: 'Administrador', + passwords: 'Senhas dos clientes', + }; + return labels[ability] || ability; + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ +

+ Gerencie seus tokens de acesso para usar a API e integrações +

+
+ +
+ + {/* Alerta de segurança */} +
+ +
+

Importante sobre segurança

+

+ Os tokens de API fornecem acesso completo à sua conta. Mantenha-os seguros e não os compartilhe. + Revogue tokens que não estão mais em uso. +

+
+
+ + {/* Lista de tokens */} + {tokens.length === 0 ? ( +
+ +

Nenhum token criado

+

Crie um token para começar a usar a API

+
+ ) : ( +
+ + + + + + + + + + + + {tokens.map((token) => ( + + + + + + + + ))} + +
+ Nome + + Permissões + + Último Uso + + Expira em + + Ações +
+
{token.name}
+
+ Criado em {new Date(token.created_at).toLocaleDateString('pt-BR')} +
+
+
+ {token.abilities.map((ability) => ( + + {getAbilityLabel(ability)} + + ))} +
+
+ {formatDate(token.last_used_at)} + + {token.expires_at ? ( + + {formatDate(token.expires_at)} + {token.is_expired && ' (Expirado)'} + + ) : ( + Nunca + )} + + +
+
+ )} + + {/* Modal de criação de token */} + {showCreateModal && ( +
{ + if (e.target === e.currentTarget && !showToken) { + setShowCreateModal(false); + } + }} + > +
+
+

+ {showToken ? 'Token Criado' : 'Criar Novo Token'} +

+ +
+ +
+ {showToken && newToken ? ( +
+
+

+ ⚠️ Guarde este token com segurança! +

+

+ Você só verá este token uma vez. Certifique-se de copiá-lo agora. +

+
+ +
+ +
+
+ {newToken} +
+ +
+
+ +
+

Como usar:

+ + Authorization: Bearer {newToken.substring(0, 20)}... + +
+ + +
+ ) : ( +
+
+ + setNewTokenName(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + placeholder="Ex: Meu App, Integração XYZ..." + required + /> +
+ +
+ +
+ {['read', 'write', 'delete', 'admin', 'passwords'].map((ability) => ( + + ))} +
+
+ +
+ + setNewTokenExpiresAt(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +

+ Deixe em branco para token sem expiração +

+
+ +
+ + +
+
+ )} +
+
+
+ )} + + {/* Confirm Dialog para exclusão */} + { + if (deleteConfirm.tokenId) { + await handleDeleteToken(deleteConfirm.tokenId); + } + setDeleteConfirm({ show: false, tokenId: null }); + }} + onClose={() => setDeleteConfirm({ show: false, tokenId: null })} + /> +
+ ); +} + diff --git a/new/frontend/src/components/WebhooksTab.tsx b/frontend/src/components/WebhooksTab.tsx new file mode 100644 index 0000000..374ebbb --- /dev/null +++ b/frontend/src/components/WebhooksTab.tsx @@ -0,0 +1,647 @@ +import { useState, useEffect } from 'react'; +import { Plus, Trash2, Copy, Send, Pencil, History, Key, RefreshCw, AlertCircle, Webhook, X, ChevronDown, ChevronUp } from 'lucide-react'; +import api from '../lib/api'; +import { toast } from './Toast'; +import { ConfirmDialog } from './ConfirmDialog'; + +interface OutgoingWebhook { + id: number; + name: string; + url: string; + events: string[]; + include_sensitive: boolean; + active: boolean; + secret?: string; + last_status_code: number | null; + last_error: string | null; + last_triggered_at: string | null; + consecutive_failures: number; +} + +interface Delivery { + id: number; + event: string; + status: 'pending' | 'processing' | 'success' | 'failed'; + attempts: number; + response_code: number | null; + response_body: string | null; + error: string | null; + next_attempt_at: string | null; + delivered_at: string | null; + created_at: string; + payload?: unknown; +} + +interface FormState { + name: string; + url: string; + events: string[]; + include_sensitive: boolean; + active: boolean; +} + +const emptyForm: FormState = { name: '', url: '', events: [], include_sensitive: false, active: true }; + +const statusLabels: Record = { + pending: { label: 'Aguardando', className: 'bg-yellow-100 text-yellow-800' }, + processing: { label: 'Enviando', className: 'bg-blue-100 text-blue-800' }, + success: { label: 'Entregue', className: 'bg-green-100 text-green-800' }, + failed: { label: 'Falhou', className: 'bg-red-100 text-red-800' }, +}; + +const inputClass = 'w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent'; + +const formatDate = (value: string | null) => (value ? new Date(value).toLocaleString('pt-BR') : '-'); + +const errorMessage = (error: any, fallback: string) => + error?.response?.data?.message || fallback; + +export function WebhooksTab() { + const [webhooks, setWebhooks] = useState([]); + const [events, setEvents] = useState>({}); + const [loading, setLoading] = useState(true); + + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + + const [secretModal, setSecretModal] = useState(null); + const [testingId, setTestingId] = useState(null); + const [deleteId, setDeleteId] = useState(null); + + const [historyFor, setHistoryFor] = useState(null); + const [deliveries, setDeliveries] = useState([]); + const [loadingDeliveries, setLoadingDeliveries] = useState(false); + const [expandedDelivery, setExpandedDelivery] = useState(null); + const [retryingId, setRetryingId] = useState(null); + + useEffect(() => { + loadWebhooks(); + }, []); + + const loadWebhooks = async () => { + try { + setLoading(true); + const { data } = await api.get('/outgoing-webhooks'); + setWebhooks(data.webhooks || []); + setEvents(data.events || {}); + } catch (error) { + console.error('Erro ao carregar webhooks:', error); + toast.error('Erro ao carregar webhooks'); + } finally { + setLoading(false); + } + }; + + const openCreate = () => { + setEditingId(null); + setForm(emptyForm); + setShowForm(true); + }; + + const openEdit = (webhook: OutgoingWebhook) => { + setEditingId(webhook.id); + setForm({ + name: webhook.name, + url: webhook.url, + events: webhook.events || [], + include_sensitive: webhook.include_sensitive, + active: webhook.active, + }); + setShowForm(true); + }; + + const toggleEvent = (event: string) => { + setForm((prev) => ({ + ...prev, + events: prev.events.includes(event) ? prev.events.filter((e) => e !== event) : [...prev.events, event], + })); + }; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + + if (form.events.length === 0) { + toast.error('Selecione pelo menos um evento'); + return; + } + + setSaving(true); + try { + if (editingId) { + await api.put(`/outgoing-webhooks/${editingId}`, form); + toast.success('Webhook atualizado'); + } else { + const { data } = await api.post('/outgoing-webhooks', form); + toast.success('Webhook criado'); + setSecretModal(data.webhook); + } + setShowForm(false); + await loadWebhooks(); + } catch (error: any) { + console.error('Erro ao salvar webhook:', error); + toast.error(errorMessage(error, 'Erro ao salvar webhook')); + } finally { + setSaving(false); + } + }; + + const handleToggleActive = async (webhook: OutgoingWebhook) => { + try { + await api.put(`/outgoing-webhooks/${webhook.id}`, { active: !webhook.active }); + toast.success(webhook.active ? 'Webhook desativado' : 'Webhook ativado'); + await loadWebhooks(); + } catch (error: any) { + toast.error(errorMessage(error, 'Erro ao alterar webhook')); + } + }; + + const handleTest = async (webhook: OutgoingWebhook) => { + setTestingId(webhook.id); + try { + const { data } = await api.post(`/outgoing-webhooks/${webhook.id}/test`); + toast.success(data.message || 'Teste enviado'); + } catch (error: any) { + toast.error(errorMessage(error, 'Falha no teste')); + } finally { + setTestingId(null); + await loadWebhooks(); + } + }; + + const handleDelete = async () => { + if (!deleteId) return; + try { + await api.delete(`/outgoing-webhooks/${deleteId}`); + toast.success('Webhook removido'); + await loadWebhooks(); + } catch (error: any) { + toast.error(errorMessage(error, 'Erro ao remover webhook')); + } finally { + setDeleteId(null); + } + }; + + const showSecret = async (webhook: OutgoingWebhook) => { + try { + const { data } = await api.get(`/outgoing-webhooks/${webhook.id}`); + setSecretModal(data.webhook); + } catch (error: any) { + toast.error(errorMessage(error, 'Erro ao carregar a chave')); + } + }; + + const regenerateSecret = async () => { + if (!secretModal) return; + try { + const { data } = await api.post(`/outgoing-webhooks/${secretModal.id}/regenerate-secret`); + setSecretModal(data.webhook); + toast.success('Nova chave gerada. Atualize-a no sistema que recebe o webhook.'); + } catch (error: any) { + toast.error(errorMessage(error, 'Erro ao gerar nova chave')); + } + }; + + const copy = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copiado!`); + }; + + const openHistory = async (webhook: OutgoingWebhook) => { + setHistoryFor(webhook); + setExpandedDelivery(null); + await loadDeliveries(webhook.id); + }; + + const loadDeliveries = async (webhookId: number) => { + setLoadingDeliveries(true); + try { + const { data } = await api.get(`/outgoing-webhooks/${webhookId}/deliveries`, { params: { limit: 50 } }); + setDeliveries(data.deliveries || []); + } catch (error: any) { + toast.error(errorMessage(error, 'Erro ao carregar histórico')); + } finally { + setLoadingDeliveries(false); + } + }; + + const retryDelivery = async (delivery: Delivery) => { + setRetryingId(delivery.id); + try { + const { data } = await api.post(`/outgoing-webhooks/deliveries/${delivery.id}/retry`); + toast.success(data.message || 'Reenviado'); + } catch (error: any) { + toast.error(errorMessage(error, 'Falha ao reenviar')); + } finally { + setRetryingId(null); + if (historyFor) await loadDeliveries(historyFor.id); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

Webhooks

+

+ Avise outro sistema (n8n, Make, Zapier, seu servidor...) automaticamente quando algo acontecer no painel. +

+
+ +
+ +
+ +
+

+ Cada evento é enviado por POST em JSON, com o cabeçalho{' '} + X-Webhook-Signature ={' '} + sha256=HMAC_SHA256(timestamp + "." + corpo, chave), onde o + timestamp vem em X-Webhook-Timestamp. +

+

+ Senhas só são enviadas se você marcar "Incluir senhas" (exige URL HTTPS). Se o destino falhar, + o sistema tenta de novo em 1, 5, 15, 60 e 180 minutos. +

+
+
+ + {webhooks.length === 0 ? ( +
+ +

Nenhum webhook cadastrado

+

Crie um para receber os cadastros em outro sistema

+
+ ) : ( +
+ {webhooks.map((webhook) => ( +
+
+
+
+ {webhook.name} + + {webhook.active ? 'Ativo' : 'Inativo'} + + {webhook.include_sensitive && ( + + Envia senhas + + )} +
+

{webhook.url}

+
+ {webhook.events.map((event) => ( + + {events[event] || event} + + ))} +
+

+ Último envio: {formatDate(webhook.last_triggered_at)} + {webhook.last_status_code ? ` · HTTP ${webhook.last_status_code}` : ''} +

+ {webhook.last_error && ( +

Último erro: {webhook.last_error}

+ )} +
+ +
+ + + + + + +
+
+
+ ))} +
+ )} + + {/* Criar / editar */} + {showForm && ( +
+
+
+
+

+ {editingId ? 'Editar Webhook' : 'Novo Webhook'} +

+ +
+ +
+ + setForm({ ...form, name: e.target.value })} + className={inputClass} + placeholder="Ex: n8n, Planilha, Meu sistema" + maxLength={100} + required + /> +
+ +
+ + setForm({ ...form, url: e.target.value })} + className={inputClass} + placeholder="https://seu-sistema.com/webhook" + required + /> +
+ +
+ +
+ {Object.entries(events).map(([event, label]) => ( + + ))} +
+
+ + + + + +
+ + +
+
+
+
+ )} + + {/* Chave secreta */} + {secretModal && ( +
+
+
+

Chave secreta · {secretModal.name}

+ +
+

+ Use esta chave no sistema de destino para conferir o cabeçalho{' '} + X-Webhook-Signature. +

+
+ {secretModal.secret} + +
+
+ Exemplo de validação (Node.js) +
{`const crypto = require('crypto');
+// rawBody = corpo da requisição exatamente como chegou
+const ts = req.headers['x-webhook-timestamp'];
+const expected = 'sha256=' + crypto
+  .createHmac('sha256', SECRET)
+  .update(ts + '.' + rawBody)
+  .digest('hex');
+const ok = crypto.timingSafeEqual(
+  Buffer.from(expected),
+  Buffer.from(req.headers['x-webhook-signature'])
+);
+// recuse também timestamps com mais de 5 minutos`}
+
+
+ + +
+
+
+ )} + + {/* Histórico */} + {historyFor && ( +
+
+
+

Histórico · {historyFor.name}

+
+ + +
+
+
+ {loadingDeliveries ? ( +
+
+
+ ) : deliveries.length === 0 ? ( +

Nenhum envio ainda

+ ) : ( + deliveries.map((delivery) => { + const status = statusLabels[delivery.status] || statusLabels.pending; + const expanded = expandedDelivery === delivery.id; + return ( +
+
+ + {status.label} + + {delivery.event} + {formatDate(delivery.created_at)} + + {delivery.response_code ? `HTTP ${delivery.response_code} · ` : ''} + {delivery.attempts} tentativa(s) + +
+ {delivery.status !== 'success' && delivery.status !== 'processing' && ( + + )} + +
+
+ {delivery.error && ( +

{delivery.error}

+ )} + {delivery.status === 'pending' && delivery.next_attempt_at && ( +

+ Próxima tentativa: {formatDate(delivery.next_attempt_at)} +

+ )} + {expanded && ( +
+

Conteúdo enviado

+
+                            {JSON.stringify(delivery.payload, null, 2)}
+                          
+ {delivery.response_body && ( + <> +

Resposta do destino

+
+                                {delivery.response_body}
+                              
+ + )} +
+ )} +
+ ); + }) + )} +
+
+
+ )} + + setDeleteId(null)} + onConfirm={handleDelete} + title="Remover webhook" + message="O webhook e todo o histórico de envios serão removidos. Deseja continuar?" + confirmText="Remover" + type="danger" + /> +
+ ); +} diff --git a/frontend/src/pages/ClientEditPage.tsx b/frontend/src/pages/ClientEditPage.tsx index e16d825..eb1b239 100644 --- a/frontend/src/pages/ClientEditPage.tsx +++ b/frontend/src/pages/ClientEditPage.tsx @@ -23,6 +23,7 @@ export function ClientEditPage() { const [formData, setFormData] = useState({ name: '', + code: '', username: '', password: '', whatsapp: '', @@ -61,6 +62,7 @@ export function ClientEditPage() { setFormData({ name: clientData.name || '', + code: clientData.code || '', username: clientData.username || '', password: clientData.password || '', whatsapp: clientData.whatsapp || '', @@ -317,6 +319,21 @@ export function ClientEditPage() { + {/* Código */} +
+ + setFormData({ ...formData, code: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + placeholder="Ex: código no seu sistema" + maxLength={100} + /> +
+ {/* Usuário */}
+ {/* Código */} +
+ + setFormData({ ...formData, code: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + placeholder="Ex: código no seu sistema" + maxLength={100} + /> +
+ {/* Usuário */}
+ {/* Código */} +
+ + setFormData({ ...formData, code: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + placeholder="Ex: código no seu sistema" + maxLength={100} + /> +
+ {/* Usuário */}
@@ -503,6 +513,9 @@ export function ProfilePage() { {/* Tokens de API */} {activeTab === 'api-tokens' && } + + {/* Webhooks de saída */} + {activeTab === 'webhooks' && } diff --git a/frontend/src/pages/ServerEditPage.tsx b/frontend/src/pages/ServerEditPage.tsx index e59bc6b..3d3430a 100644 --- a/frontend/src/pages/ServerEditPage.tsx +++ b/frontend/src/pages/ServerEditPage.tsx @@ -7,6 +7,7 @@ interface ServerFormData { contact_name: string; contact_whatsapp: string; panel_url: string; + xtream_url: string; panel_username: string; panel_password: string; active: boolean; @@ -22,6 +23,7 @@ export function ServerEditPage() { contact_name: '', contact_whatsapp: '', panel_url: '', + xtream_url: '', panel_username: '', panel_password: '', active: true, @@ -44,6 +46,7 @@ export function ServerEditPage() { contact_name: server.contact_name || '', contact_whatsapp: server.contact_whatsapp || '', panel_url: server.panel_url || '', + xtream_url: server.xtream_url || '', panel_username: server.panel_username || '', panel_password: server.panel_password || '', active: server.active, @@ -67,6 +70,7 @@ export function ServerEditPage() { contact_name: formData.contact_name, contact_whatsapp: formData.contact_whatsapp, panel_url: formData.panel_url, + xtream_url: formData.xtream_url, panel_username: formData.panel_username, panel_password: formData.panel_password, active: formData.active, @@ -190,6 +194,22 @@ export function ServerEditPage() {

URL onde o cliente pode renovar sua assinatura

+ {/* URL do Xtream */} +
+ + +

DNS/URL usada nos aplicativos (Xtream Codes)

+
+ {/* Usuário e Senha do Painel */}
diff --git a/frontend/src/pages/ServerFormPage.tsx b/frontend/src/pages/ServerFormPage.tsx index c8bc176..59ccf13 100644 --- a/frontend/src/pages/ServerFormPage.tsx +++ b/frontend/src/pages/ServerFormPage.tsx @@ -10,6 +10,7 @@ export function ServerFormPage() { contact_name: '', contact_whatsapp: '', panel_url: '', + xtream_url: '', panel_username: '', panel_password: '', active: true, @@ -25,6 +26,7 @@ export function ServerFormPage() { contact_name: formData.contact_name, contact_whatsapp: formData.contact_whatsapp, panel_url: formData.panel_url, + xtream_url: formData.xtream_url, panel_username: formData.panel_username, panel_password: formData.panel_password, active: formData.active, @@ -125,6 +127,21 @@ export function ServerFormPage() {

URL onde o cliente pode renovar sua assinatura

+ {/* URL do Xtream */} +
+ + setFormData({ ...formData, xtream_url: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + placeholder="http://dns.exemplo.com:8080" + /> +

DNS/URL usada nos aplicativos (Xtream Codes)

+
+ {/* Usuário e Senha do Painel */}
diff --git a/frontend/src/pages/ServersPage.tsx b/frontend/src/pages/ServersPage.tsx index 8216564..96a49ab 100644 --- a/frontend/src/pages/ServersPage.tsx +++ b/frontend/src/pages/ServersPage.tsx @@ -12,6 +12,7 @@ interface Server { contact_name?: string; contact_whatsapp?: string; panel_url?: string; + xtream_url?: string; panel_username?: string; panel_password?: string; active: boolean; @@ -256,6 +257,7 @@ export function ServersPage() { .replace(/{contato_nome}/g, server.contact_name || 'N/A') .replace(/{contato_whatsapp}/g, server.contact_whatsapp || 'N/A') .replace(/{painel_url}/g, server.panel_url || 'N/A') + .replace(/{xtream_url}/g, server.xtream_url || 'N/A') .replace(/{painel_usuario}/g, server.panel_username || 'N/A') .replace(/{painel_senha}/g, server.panel_password || 'N/A'); }; @@ -628,6 +630,7 @@ export function ServersPage() { { key: 'contato_nome', label: 'Nome do Contato', example: whatsappModal.contact_name || 'N/A' }, { key: 'contato_whatsapp', label: 'WhatsApp do Contato', example: whatsappModal.contact_whatsapp || 'N/A' }, { key: 'painel_url', label: 'URL do Painel', example: whatsappModal.panel_url || 'N/A' }, + { key: 'xtream_url', label: 'URL do Xtream', example: whatsappModal.xtream_url || 'N/A' }, { key: 'painel_usuario', label: 'Usuário do Painel', example: whatsappModal.panel_username || 'N/A' }, { key: 'painel_senha', label: 'Senha do Painel', example: whatsappModal.panel_password || 'N/A' }, ]