diff --git a/.env b/.env index e69de29..3687460 100644 --- a/.env +++ b/.env @@ -0,0 +1 @@ +JWT_SECRET=5741f9da3be6007674a5e10edab7b124 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..03889b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# JWT Secret - Deve ser o mesmo usado no frontend +JWT_SECRET=5741f9da3be6007674a5e10edab7b124 + +# URL base do Ollama +OLLAMA_BASE_URL=http://localhost:11434 + +# Caminho para lista de modelos +MODEL_LIST_PATH=modelList.json + +# Origem permitida para CORS +ALLOWED_ORIGINS=http://localhost:3000 + +# Suprimir avisos de antivírus (opcional) +SKIP_ANTIVIRUS_WARNINGS=false diff --git a/.gitignore b/.gitignore index 423b479..9c96446 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ target/ # Added by cargo /target + +# Environment variables +.env \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 27ba6a1..0119982 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,6 +7,7 @@ name = "OllaHubBackend" version = "0.1.0" dependencies = [ "actix", + "actix-cors", "actix-rt", "actix-web", "actix-web-actors", @@ -15,6 +16,7 @@ dependencies = [ "awc", "base64 0.21.7", "dirs", + "dotenv", "hex", "jsonwebtoken", "rand 0.8.5", @@ -70,6 +72,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + [[package]] name = "actix-http" version = "3.11.2" @@ -741,6 +758,12 @@ dependencies = [ "syn 2.0.109", ] +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "either" version = "1.15.0" diff --git a/Cargo.toml b/Cargo.toml index 01d7b8d..412ff2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ actix-rt = "2.11.0" actix-web = "4.11.0" actix-web-actors = "4.3.0" actix-web-lab = "0.24.3" +actix-cors = "0.7" awc = "3.8.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -24,3 +25,4 @@ utoipa = { version = "4", features = ["actix_extras"] } utoipa-swagger-ui = { version = "4", features = ["actix-web"] } validator = { version = "0.16", features = ["derive"] } jsonwebtoken = "9" +dotenv = "0.15" diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6e22ab4 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,1316 @@ +# Documentação Técnica das APIs - OllaHubBackend + +## Visão Geral + +O **OllaHubBackend** é uma API REST desenvolvida em Rust (Actix Web) que fornece gerenciamento de modelos Ollama, operações criptográficas, gerenciamento de usuários e comunicação em tempo real via WebSocket. + +### Base URL + +- **Desenvolvimento**: `http://127.0.0.1:8080` +- **Produção**: [A definir] + +### Versões da API + +- **V1 (Legado)**: Endpoints implementados diretamente no `main.rs` +- **V2 (Modular)**: Endpoints organizados em módulos (`src/api/mod.rs`) - **Recomendado** +- **Usuários V1**: CRUD de usuários em `/api/v1/users` + +### Swagger UI + +Documentação interativa disponível em: `http://127.0.0.1:8080/docs` + +--- + +## Autenticação + +A API utiliza **JWT (JSON Web Tokens)** para autenticação. Endpoints protegidos requerem o header de autorização. + +### Como Autenticar + +```http +Authorization: Bearer +``` + +### Endpoints Protegidos + +Os seguintes caminhos requerem autenticação JWT: +- `/api/v1/*` (todos os endpoints de usuários) +- `/v2/api/crypto/*` (todas as operações de criptografia) +- `/v2/api/models/*` (operações de modelos) + +### Endpoints Públicos + +- `GET /health` +- `GET /v2/health` +- `GET /v2/api/ollama/check` +- `POST /v2/api/ollama/install` +- `WS /v2/ws` (WebSocket) + +### Configuração JWT + +A chave secreta JWT é configurada via variável de ambiente: +- Variável: `JWT_SECRET` +- Padrão (dev): `changeme-dev-secret` + +--- + +## Endpoints + +### Sistema / Health + +#### GET /health + +Verifica o status básico da API. + +**Autenticação**: Não requerida + +**Resposta**: +```json +"ok" +``` + +**Exemplo**: +```bash +curl http://127.0.0.1:8080/health +``` + +--- + +#### GET /v2/health + +Verifica o status da API V2. + +**Autenticação**: Não requerida + +**Resposta**: +```json +{ + "status": "ok" +} +``` + +**Exemplo**: +```bash +curl http://127.0.0.1:8080/v2/health +``` + +--- + +### Ollama - Instalação e Verificação + +#### GET /v2/api/ollama/check + +Verifica se o Ollama está instalado e acessível no sistema. + +**Autenticação**: Não requerida + +**Resposta**: +```json +{ + "installed": true, + "in_path": true, + "version": "0.1.15", + "service_running": true, + "api_status": "reachable", + "details": { + "native_version": "ollama version is 0.1.15", + "api_/api/version": "{\"version\":\"0.1.15\"}", + "api_/api/tags": "{\"models\":[...]}", + "security_antivirus_active": "false", + "security_antivirus_instructions": "..." + }, + "issues": [] +} +``` + +**Campos da Resposta**: +- `installed` (boolean): Se o Ollama está instalado +- `in_path` (boolean): Se o binário está no PATH +- `version` (string|null): Versão do Ollama +- `service_running` (boolean): Se o serviço está rodando +- `api_status` (string): `"reachable"`, `"unreachable"` ou `"degraded"` +- `details` (object): Detalhes técnicos da instalação +- `issues` (array): Lista de problemas encontrados + +**Exemplo**: +```bash +curl http://127.0.0.1:8080/v2/api/ollama/check +``` + +--- + +#### POST /v2/api/ollama/install + +Inicia o processo de instalação automática do Ollama (se não estiver instalado) e garante que o serviço esteja rodando. O progresso é enviado via WebSocket. + +**Autenticação**: Não requerida + +**Request Body** (opcional): +```json +{ + "method": "auto", + "platform": "auto" +} +``` + +**Resposta**: +```json +{ + "status": "started", + "operation_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Campos da Resposta**: +- `status` (string): Status da operação (`"started"`) +- `operation_id` (string): UUID da operação para rastreamento via WebSocket + +**Eventos WebSocket**: +- `Log`: Mensagens de log durante a instalação +- `Progress`: Progresso da instalação +- `Done`: Instalação concluída com sucesso +- `Error`: Erro durante a instalação + +**Exemplo**: +```bash +curl -X POST http://127.0.0.1:8080/v2/api/ollama/install \ + -H "Content-Type: application/json" \ + -d '{"method": "auto", "platform": "auto"}' +``` + +**Notas**: +- A instalação é específica por plataforma (Windows, Linux, macOS) +- O progresso é enviado via WebSocket (`WS /v2/ws`) +- Em caso de falha, eventos `Error` são enviados via WebSocket +- Os endpoints V1 (`GET /ollama/install-check` e `POST /ollama/install`) foram removidos + +--- + +### Modelos (V2) + +#### GET /v2/api/models + +Lista todos os modelos disponíveis do arquivo `modelList.json`. + +**Autenticação**: Requerida (JWT) + +**Resposta**: +```json +[ + { + "name": "llama2", + "description": "Modelo LLaMA 2" + }, + { + "name": "mistral", + "description": "Modelo Mistral" + } +] +``` + +**Códigos de Status**: +- `200 OK`: Sucesso +- `401 Unauthorized`: Token JWT inválido ou ausente +- `500 Internal Server Error`: Erro ao ler arquivo de modelos + +**Exemplo**: +```bash +curl -H "Authorization: Bearer " \ + http://127.0.0.1:8080/v2/api/models +``` + +**JavaScript/TypeScript**: +```typescript +const response = await fetch('http://127.0.0.1:8080/v2/api/models', { + headers: { + 'Authorization': `Bearer ${token}` + } +}); +const models = await response.json(); +``` + +--- + +#### POST /v2/api/models/install + +Inicia a instalação (download) de um modelo do Ollama. O progresso é enviado via WebSocket. + +**Autenticação**: Requerida (JWT) + +**Request Body**: +```json +{ + "name": "llama2:7b" +} +``` + +**Resposta**: +```json +{ + "status": "started", + "model": "llama2:7b" +} +``` + +**Códigos de Status**: +- `202 Accepted`: Instalação iniciada +- `400 Bad Request`: Nome do modelo inválido +- `401 Unauthorized`: Token JWT inválido ou ausente +- `500 Internal Server Error`: Erro ao iniciar instalação + +**Exemplo**: +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name":"llama2:7b"}' \ + http://127.0.0.1:8080/v2/api/models/install +``` + +**JavaScript/TypeScript**: +```typescript +const response = await fetch('http://127.0.0.1:8080/v2/api/models/install', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: 'llama2:7b' }) +}); +const result = await response.json(); +``` + +**Notas**: +- O progresso da instalação é enviado via WebSocket (`/v2/ws`) +- Conecte-se ao WebSocket antes de iniciar a instalação para receber eventos +- O formato do nome pode incluir tag: `"modelo:tag"` (ex: `"llama2:7b"`) + +--- + +### Criptografia (V2) + +Todas as operações de criptografia usam **AES-256-GCM** com chave de 32 bytes. + +#### POST /v2/api/crypto/set-key + +Define a chave de criptografia (deve ser chamado antes de usar encrypt/decrypt). + +**Autenticação**: Requerida (JWT) + +**Request Body**: +```json +{ + "hex_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +} +``` + +**Resposta**: +```json +{ + "ok": true +} +``` + +**Códigos de Status**: +- `200 OK`: Chave definida com sucesso +- `400 Bad Request`: Chave inválida (deve ter 64 caracteres hex = 32 bytes) +- `401 Unauthorized`: Token JWT inválido ou ausente +- `409 Conflict`: Chave já foi definida anteriormente + +**Exemplo**: +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"hex_key":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' \ + http://127.0.0.1:8080/v2/api/crypto/set-key +``` + +**Notas**: +- A chave é persistida no disco automaticamente +- Use a mesma chave no frontend para operações de criptografia +- A chave deve ter exatamente 64 caracteres hexadecimais (32 bytes) + +--- + +#### POST /v2/api/crypto/rotate-key + +Gera uma nova chave de criptografia aleatória e retorna em hexadecimal. + +**Autenticação**: Requerida (JWT) + +**Resposta**: +```json +{ + "key": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" +} +``` + +**Códigos de Status**: +- `200 OK`: Chave rotacionada com sucesso +- `401 Unauthorized`: Token JWT inválido ou ausente +- `500 Internal Server Error`: Erro ao gerar/salvar chave + +**Exemplo**: +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + http://127.0.0.1:8080/v2/api/crypto/rotate-key +``` + +**Notas**: +- A nova chave substitui a anterior +- Salve a chave retornada para uso no frontend +- A chave é automaticamente persistida no disco + +--- + +#### POST /v2/api/crypto/encrypt + +Criptografa dados usando AES-256-GCM. + +**Autenticação**: Requerida (JWT) + +**Request Body**: +```json +{ + "data": "dados sensíveis para criptografar" +} +``` + +**Resposta**: +```json +{ + "ciphertext": "base64_encoded_ciphertext_with_nonce" +} +``` + +**Códigos de Status**: +- `200 OK`: Criptografia bem-sucedida +- `400 Bad Request`: Chave não definida ou dados inválidos +- `401 Unauthorized`: Token JWT inválido ou ausente + +**Exemplo**: +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"data":"minha senha secreta"}' \ + http://127.0.0.1:8080/v2/api/crypto/encrypt +``` + +**Notas**: +- O nonce (12 bytes) é incluído automaticamente no início do ciphertext +- O resultado é retornado em Base64 +- O formato do ciphertext: `[nonce 12 bytes][ciphertext]` (tudo em Base64) + +--- + +#### POST /v2/api/crypto/decrypt + +Descriptografa dados usando AES-256-GCM. + +**Autenticação**: Requerida (JWT) + +**Request Body**: +```json +{ + "data": "base64_encoded_ciphertext_with_nonce" +} +``` + +**Resposta**: +```json +{ + "plaintext": "dados descriptografados" +} +``` + +**Códigos de Status**: +- `200 OK`: Descriptografia bem-sucedida +- `400 Bad Request`: Chave não definida, Base64 inválido ou tag de autenticação inválida +- `401 Unauthorized`: Token JWT inválido ou ausente + +**Exemplo**: +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"data":"ciphertext_base64_aqui"}' \ + http://127.0.0.1:8080/v2/api/crypto/decrypt +``` + +**Notas**: +- O nonce é extraído automaticamente dos primeiros 12 bytes +- O `data` deve ser o mesmo `ciphertext` retornado por `encrypt` +- A tag de autenticação GCM é verificada automaticamente + +--- + +### Usuários (V1) + +#### GET /api/v1/users + +Lista todos os usuários cadastrados. + +**Autenticação**: Requerida (JWT) + +**Resposta**: +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "João Silva", + "email": "joao@example.com" + }, + { + "id": "660e8400-e29b-41d4-a716-446655440001", + "name": "Maria Santos", + "email": "maria@example.com" + } +] +``` + +**Códigos de Status**: +- `200 OK`: Sucesso +- `401 Unauthorized`: Token JWT inválido ou ausente + +**Exemplo**: +```bash +curl -H "Authorization: Bearer " \ + http://127.0.0.1:8080/api/v1/users +``` + +--- + +#### GET /api/v1/users/{id} + +Obtém um usuário específico por ID. + +**Autenticação**: Requerida (JWT) + +**Parâmetros de Path**: +- `id` (string): UUID do usuário + +**Resposta**: +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "João Silva", + "email": "joao@example.com" +} +``` + +**Códigos de Status**: +- `200 OK`: Usuário encontrado +- `401 Unauthorized`: Token JWT inválido ou ausente +- `404 Not Found`: Usuário não encontrado + +**Exemplo**: +```bash +curl -H "Authorization: Bearer " \ + http://127.0.0.1:8080/api/v1/users/550e8400-e29b-41d4-a716-446655440000 +``` + +--- + +#### POST /api/v1/users + +Cria um novo usuário. + +**Autenticação**: Requerida (JWT) + +**Request Body**: +```json +{ + "name": "João Silva", + "email": "joao@example.com" +} +``` + +**Validações**: +- `name`: Mínimo 2 caracteres +- `email`: Formato de email válido + +**Resposta**: +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "João Silva", + "email": "joao@example.com" +} +``` + +**Códigos de Status**: +- `201 Created`: Usuário criado com sucesso +- `400 Bad Request`: Erro de validação +- `401 Unauthorized`: Token JWT inválido ou ausente + +**Exemplo**: +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name":"João Silva","email":"joao@example.com"}' \ + http://127.0.0.1:8080/api/v1/users +``` + +--- + +#### PUT /api/v1/users/{id} + +Atualiza um usuário existente. + +**Autenticação**: Requerida (JWT) + +**Parâmetros de Path**: +- `id` (string): UUID do usuário + +**Request Body**: +```json +{ + "name": "João Silva Atualizado", + "email": "joao.novo@example.com" +} +``` + +**Validações**: +- `name`: Mínimo 2 caracteres (opcional) +- `email`: Formato de email válido (opcional) + +**Resposta**: +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "João Silva Atualizado", + "email": "joao.novo@example.com" +} +``` + +**Códigos de Status**: +- `200 OK`: Usuário atualizado +- `400 Bad Request`: Erro de validação +- `401 Unauthorized`: Token JWT inválido ou ausente +- `404 Not Found`: Usuário não encontrado + +**Exemplo**: +```bash +curl -X PUT \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name":"João Silva Atualizado","email":"joao.novo@example.com"}' \ + http://127.0.0.1:8080/api/v1/users/550e8400-e29b-41d4-a716-446655440000 +``` + +--- + +#### DELETE /api/v1/users/{id} + +Remove um usuário. + +**Autenticação**: Requerida (JWT) + +**Parâmetros de Path**: +- `id` (string): UUID do usuário + +**Resposta**: Sem corpo (204 No Content) + +**Códigos de Status**: +- `204 No Content`: Usuário removido +- `401 Unauthorized`: Token JWT inválido ou ausente +- `404 Not Found`: Usuário não encontrado + +**Exemplo**: +```bash +curl -X DELETE \ + -H "Authorization: Bearer " \ + http://127.0.0.1:8080/api/v1/users/550e8400-e29b-41d4-a716-446655440000 +``` + +--- + +### WebSocket (V2) + +#### WS /v2/ws + +Endpoint WebSocket para receber eventos em tempo real, especialmente durante a instalação de modelos. + +**Autenticação**: Não requerida (mas pode ser implementada) + +**Conexão**: +``` +ws://127.0.0.1:8080/v2/ws +``` + +**Tipos de Eventos**: + +1. **Log**: Mensagens de log durante operações +```json +{ + "kind": "Log", + "message": "Downloading model llama2:7b..." +} +``` + +2. **Progress**: Progresso de operações (ex: download de modelo) +```json +{ + "kind": "Progress", + "message": "{\"status\":\"downloading\",\"completed\":50,\"total\":100}" +} +``` + +3. **Done**: Operação concluída +```json +{ + "kind": "Done", + "message": "Download do modelo 'llama2:7b' concluído" +} +``` + +4. **Error**: Erro durante operação +```json +{ + "kind": "Error", + "message": "Falha ao conectar com API do Ollama" +} +``` + +**Exemplo JavaScript**: +```javascript +const ws = new WebSocket('ws://127.0.0.1:8080/v2/ws'); + +ws.onopen = () => { + console.log('WebSocket conectado'); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + console.log('Evento recebido:', data); + + switch(data.kind) { + case 'Log': + console.log('Log:', data.message); + break; + case 'Progress': + console.log('Progresso:', data.message); + break; + case 'Done': + console.log('Concluído:', data.message); + break; + case 'Error': + console.error('Erro:', data.message); + break; + } +}; + +ws.onerror = (error) => { + console.error('Erro WebSocket:', error); +}; + +ws.onclose = () => { + console.log('WebSocket desconectado'); +}; +``` + +**Exemplo TypeScript**: +```typescript +enum WsEventKind { + Log = 'Log', + Progress = 'Progress', + Done = 'Done', + Error = 'Error' +} + +interface WsEvent { + kind: WsEventKind; + message: string; +} + +const ws = new WebSocket('ws://127.0.0.1:8080/v2/ws'); + +ws.onmessage = (event: MessageEvent) => { + const data: WsEvent = JSON.parse(event.data); + // Processar evento +}; +``` + +**Fluxo de Instalação de Modelo**: + +1. Conecte-se ao WebSocket +2. Chame `POST /v2/api/models/install` com o nome do modelo +3. Receba eventos via WebSocket: + - `Log`: Mensagens de progresso + - `Progress`: Atualizações de progresso (se disponível) + - `Done`: Instalação concluída + - `Error`: Erro durante instalação + +**Notas**: +- O WebSocket mantém a conexão aberta para receber múltiplos eventos + +--- + +### WebSocket Chat (`/v2/ws/chat`) + +Endpoint WebSocket dedicado para chat com modelos Ollama, oferecendo streaming em tempo real das respostas. + +#### Conectar + +```javascript +const ws = new WebSocket('ws://127.0.0.1:8080/v2/ws/chat'); + +ws.onopen = () => { + console.log('Chat WebSocket conectado'); + + // Enviar mensagem de chat + const chatRequest = { + model: "llama2", + messages: [ + { role: "user", content: "Olá, como você está?" } + ], + stream: true, + options: { + temperature: 0.7, + top_p: 0.9 + } + }; + + ws.send(JSON.stringify(chatRequest)); +}; + +ws.onmessage = (event) => { + const response = JSON.parse(event.data); + + if (response.done) { + console.log('Chat finalizado'); + return; + } + + if (response.message) { + // Acumular conteúdo da mensagem + console.log('Chunk recebido:', response.message.content); + } + + if (response.error) { + console.error('Erro no chat:', response.error); + } +}; + +ws.onerror = (error) => { + console.error('Erro WebSocket chat:', error); +}; + +ws.onclose = () => { + console.log('Chat WebSocket desconectado'); +}; +``` + +#### Formato da Requisição + +```json +{ + "model": "llama2", + "messages": [ + { "role": "user", "content": "Sua mensagem aqui" }, + { "role": "assistant", "content": "Resposta anterior" } + ], + "stream": true, + "options": { + "temperature": 0.7, + "top_p": 0.9, + "top_k": 40 + } +} +``` + +**Campos**: +- `model` (string, obrigatório): Nome do modelo a ser usado +- `messages` (array, obrigatório): Array de mensagens no formato `{ role: "user"|"assistant", content: string }` +- `stream` (boolean, opcional): Sempre `true` para streaming (padrão: `true`) +- `options` (object, opcional): Opções do modelo (temperature, top_p, top_k, etc.) + +#### Formato da Resposta (Streaming) + +Cada chunk recebido é um objeto JSON: + +```json +{ + "model": "llama2", + "created_at": "2024-01-01T12:00:00Z", + "message": { + "role": "assistant", + "content": "Olá! Estou bem, obrigado por perguntar." + }, + "done": false +} +``` + +**Último chunk** (quando `done: true`): +```json +{ + "model": "llama2", + "created_at": "2024-01-01T12:00:05Z", + "message": null, + "done": true, + "total_duration": 5000000000, + "load_duration": 100000000, + "prompt_eval_count": 10, + "prompt_eval_duration": 200000000, + "eval_count": 50, + "eval_duration": 4700000000 +} +``` + +#### Características + +- **Streaming em tempo real**: Respostas são enviadas chunk por chunk conforme geradas +- **Baixa latência**: WebSocket mantém conexão persistente +- **Heartbeat**: Ping/pong automático a cada 5 segundos +- **Timeout**: Desconexão automática após 30 segundos de inatividade +- **Múltiplas conversas**: Uma conexão pode enviar múltiplas requisições de chat + +#### Exemplo Completo: Conversa com Histórico + +```javascript +const ws = new WebSocket('ws://127.0.0.1:8080/v2/ws/chat'); +let conversationHistory = []; + +ws.onopen = () => { + sendMessage("Olá!"); +}; + +function sendMessage(userMessage) { + // Adicionar mensagem do usuário ao histórico + conversationHistory.push({ role: "user", content: userMessage }); + + const request = { + model: "llama2", + messages: conversationHistory, + stream: true + }; + + ws.send(JSON.stringify(request)); +} + +ws.onmessage = (event) => { + const response = JSON.parse(event.data); + + if (response.done) { + // Adicionar resposta completa ao histórico + if (response.message) { + conversationHistory.push(response.message); + } + console.log('Conversa atualizada:', conversationHistory); + return; + } + + // Acumular chunks da resposta + if (response.message && response.message.content) { + process.stdout.write(response.message.content); + } +}; + +// Enviar próxima mensagem após resposta completa +ws.onmessage = (event) => { + const response = JSON.parse(event.data); + + if (response.done) { + if (response.message) { + conversationHistory.push(response.message); + } + // Próxima mensagem do usuário + sendMessage("Conte-me uma piada"); + } else if (response.message) { + process.stdout.write(response.message.content); + } +}; +``` + +#### Tratamento de Erros + +```javascript +ws.onmessage = (event) => { + const response = JSON.parse(event.data); + + if (response.error) { + console.error('Erro do Ollama:', response.error); + // Erro pode ocorrer em qualquer chunk + } + + if (response.done && response.error) { + // Erro final - conexão pode ser fechada + ws.close(); + } +}; +``` + +#### Notas Importantes + +- O endpoint requer autenticação JWT (mesmo token usado em outras APIs) +- O WebSocket mantém a conexão aberta para múltiplas requisições +- Cada requisição deve incluir o histórico completo da conversa +- Respostas são enviadas em formato JSON lines (uma linha por chunk) +- Use `done: true` para saber quando a resposta está completa +- Eventos são broadcast para todos os clientes conectados +- Ping/Pong são gerenciados automaticamente + +--- + +## Modelos de Dados + +### User + +```typescript +interface User { + id: string; // UUID + name: string; // Mínimo 2 caracteres + email: string; // Email válido +} +``` + +### CreateUserRequest + +```typescript +interface CreateUserRequest { + name: string; // Mínimo 2 caracteres + email: string; // Email válido +} +``` + +### UpdateUserRequest + +```typescript +interface UpdateUserRequest { + name?: string; // Mínimo 2 caracteres (opcional) + email?: string; // Email válido (opcional) +} +``` + +### ModelInfo + +```typescript +interface ModelInfo { + name: string; + description?: string; +} +``` + +### InstallReq + +```typescript +interface InstallReq { + name: string; // Nome do modelo (ex: "llama2:7b") +} +``` + +### SetKeyReq + +```typescript +interface SetKeyReq { + hex_key: string; // 64 caracteres hex (32 bytes) +} +``` + +### CryptReq + +```typescript +interface CryptReq { + data: string; // Dados para criptografar/descriptografar +} +``` + +### ApiErrorBody + +```typescript +interface ApiErrorBody { + error: string; // Mensagem de erro +} +``` + +### WsEvent + +```typescript +enum WsEventKind { + Log = 'Log', + Progress = 'Progress', + Done = 'Done', + Error = 'Error' +} + +interface WsEvent { + kind: WsEventKind; + message: string; +} +``` + +--- + +## Tratamento de Erros + +### Códigos de Status HTTP + +- `200 OK`: Requisição bem-sucedida +- `201 Created`: Recurso criado com sucesso +- `202 Accepted`: Requisição aceita para processamento assíncrono +- `204 No Content`: Sucesso sem conteúdo de retorno +- `400 Bad Request`: Erro na requisição (validação, formato) +- `401 Unauthorized`: Token JWT ausente ou inválido +- `404 Not Found`: Recurso não encontrado +- `409 Conflict`: Conflito (ex: chave já definida) +- `500 Internal Server Error`: Erro interno do servidor + +### Formato de Erro + +Todos os erros retornam o seguinte formato: + +```json +{ + "error": "Mensagem de erro descritiva" +} +``` + +### Exemplos de Erros + +**400 Bad Request**: +```json +{ + "error": "validação: email: deve ser um email válido" +} +``` + +**401 Unauthorized**: +```json +{ + "error": "Token JWT inválido ou ausente" +} +``` + +**404 Not Found**: +```json +{ + "error": "Usuário não encontrado" +} +``` + +**500 Internal Server Error**: +```json +{ + "error": "Falha ao ler arquivo de modelos: No such file or directory" +} +``` + +--- + +## Variáveis de Ambiente + +### OLLAMA_BASE_URL + +- **Descrição**: URL base da API do Ollama +- **Padrão**: `http://localhost:11434` +- **Uso**: Usado para comunicação com o Ollama + +### MODEL_LIST_PATH + +- **Descrição**: Caminho para o arquivo JSON com lista de modelos +- **Padrão**: `modelList.json` +- **Uso**: Arquivo lido para listar modelos disponíveis + +### JWT_SECRET + +- **Descrição**: Chave secreta para assinatura de tokens JWT +- **Padrão**: `changeme-dev-secret` +- **Uso**: Validação de tokens JWT +- **Importante**: + - Deve ser o **mesmo valor** usado no frontend para gerar tokens + - Altere em produção! + - Pode ser configurado via variável de ambiente ou arquivo `.env` + +**Configuração via `.env`** (recomendado): +```env +JWT_SECRET=5741f9da3be6007674a5e10edab7b124 +``` + +**Configuração via variável de ambiente**: +```bash +# Windows (PowerShell) +$env:JWT_SECRET="5741f9da3be6007674a5e10edab7b124" + +# Linux/Mac +export JWT_SECRET="5741f9da3be6007674a5e10edab7b124" +``` + +### SKIP_ANTIVIRUS_WARNINGS + +- **Descrição**: Suprime avisos de verificação de antivírus durante inicialização +- **Padrão**: `false` +- **Valores**: `true` ou `false` +- **Uso**: Define como `true` para desabilitar avisos informativos sobre antivírus/segurança +- **Exemplo**: `SKIP_ANTIVIRUS_WARNINGS=true cargo run` + +--- + +## Exemplos de Fluxos Completos + +### Fluxo 1: Instalar um Modelo com Acompanhamento via WebSocket + +```javascript +// 1. Conectar ao WebSocket +const ws = new WebSocket('ws://127.0.0.1:8080/v2/ws'); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.kind === 'Done') { + console.log('Modelo instalado!'); + } +}; + +// 2. Aguardar conexão +ws.onopen = async () => { + // 3. Iniciar instalação + const response = await fetch('http://127.0.0.1:8080/v2/api/models/install', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: 'llama2:7b' }) + }); + + const result = await response.json(); + console.log('Instalação iniciada:', result); +}; +``` + +### Fluxo 2: Configurar Criptografia e Criptografar Dados + +```javascript +// 1. Definir chave (ou rotacionar) +const rotateResponse = await fetch('http://127.0.0.1:8080/v2/api/crypto/rotate-key', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } +}); + +const { key } = await rotateResponse.json(); +console.log('Nova chave:', key); + +// 2. Criptografar dados +const encryptResponse = await fetch('http://127.0.0.1:8080/v2/api/crypto/encrypt', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ data: 'dados sensíveis' }) +}); + +const { ciphertext } = await encryptResponse.json(); +console.log('Dados criptografados:', ciphertext); + +// 3. Descriptografar +const decryptResponse = await fetch('http://127.0.0.1:8080/v2/api/crypto/decrypt', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ data: ciphertext }) +}); + +const { plaintext } = await decryptResponse.json(); +console.log('Dados descriptografados:', plaintext); +``` + +### Fluxo 3: CRUD Completo de Usuários + +```javascript +const baseUrl = 'http://127.0.0.1:8080/api/v1/users'; +const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' +}; + +// 1. Criar usuário +const createResponse = await fetch(baseUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + name: 'João Silva', + email: 'joao@example.com' + }) +}); +const newUser = await createResponse.json(); +console.log('Usuário criado:', newUser); + +// 2. Listar usuários +const listResponse = await fetch(baseUrl, { headers }); +const users = await listResponse.json(); +console.log('Usuários:', users); + +// 3. Obter usuário específico +const getUserResponse = await fetch(`${baseUrl}/${newUser.id}`, { headers }); +const user = await getUserResponse.json(); +console.log('Usuário:', user); + +// 4. Atualizar usuário +const updateResponse = await fetch(`${baseUrl}/${newUser.id}`, { + method: 'PUT', + headers, + body: JSON.stringify({ + name: 'João Silva Atualizado', + email: 'joao.novo@example.com' + }) +}); +const updatedUser = await updateResponse.json(); +console.log('Usuário atualizado:', updatedUser); + +// 5. Deletar usuário +await fetch(`${baseUrl}/${newUser.id}`, { + method: 'DELETE', + headers +}); +console.log('Usuário deletado'); +``` + +--- + +## Notas Importantes + +### Segurança + +1. **JWT Secret**: Sempre altere o `JWT_SECRET` em produção +2. **HTTPS**: Use HTTPS em produção para proteger tokens e dados +3. **Chave de Criptografia**: Mantenha a chave de criptografia segura e nunca a exponha +4. **Validação**: Todos os inputs são validados no backend + +### Performance + +1. **WebSocket**: Use WebSocket para operações longas (instalação de modelos) +2. **Assíncrono**: Instalação de modelos é assíncrona (202 Accepted) +3. **Cache**: Considere cachear lista de modelos no frontend + +### Compatibilidade + +1. **V1 vs V2**: Prefira endpoints V2 quando disponíveis +2. **Swagger**: Use `/docs` para documentação interativa +3. **Versões**: Endpoints V1 podem ser descontinuados no futuro + +### Limitações + +1. **Armazenamento de Usuários**: Atualmente em memória (perdido ao reiniciar) +2. **Chave de Criptografia**: Uma chave por instância do servidor +3. **WebSocket**: Broadcast para todos os clientes (não filtrado por operação) + +--- + +## Suporte e Recursos + +- **Swagger UI**: `http://127.0.0.1:8080/docs` +- **Health Check**: `http://127.0.0.1:8080/health` +- **Código Fonte**: Ver repositório do projeto + +--- + +**Última Atualização**: 2024 + diff --git a/docs/API_EXAMPLES.md b/docs/API_EXAMPLES.md new file mode 100644 index 0000000..523ed9c --- /dev/null +++ b/docs/API_EXAMPLES.md @@ -0,0 +1,796 @@ +# Exemplos Práticos de Uso da API - OllaHubBackend + +Este documento contém exemplos práticos e casos de uso comuns para integração com a API do OllaHubBackend. + +--- + +## Configuração Inicial + +### TypeScript/JavaScript - Cliente Base + +```typescript +// api-client.ts +const API_BASE_URL = 'http://127.0.0.1:8080'; +const WS_BASE_URL = 'ws://127.0.0.1:8080'; + +class OllaHubClient { + private token: string | null = null; + + constructor(token?: string) { + this.token = token || null; + } + + setToken(token: string) { + this.token = token; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || `HTTP ${response.status}`); + } + + if (response.status === 204) { + return null as T; + } + + return response.json(); + } + + // Health + async health(): Promise<{ status: string }> { + return this.request('/v2/health'); + } + + // Modelos + async listModels(): Promise> { + return this.request('/v2/api/models'); + } + + async installModel(name: string): Promise<{ status: string; model: string }> { + return this.request('/v2/api/models/install', { + method: 'POST', + body: JSON.stringify({ name }), + }); + } + + // Criptografia + async setCryptoKey(hexKey: string): Promise<{ ok: boolean }> { + return this.request('/v2/api/crypto/set-key', { + method: 'POST', + body: JSON.stringify({ hex_key: hexKey }), + }); + } + + async rotateCryptoKey(): Promise<{ key: string }> { + return this.request('/v2/api/crypto/rotate-key', { + method: 'POST', + }); + } + + async encrypt(data: string): Promise<{ ciphertext: string }> { + return this.request('/v2/api/crypto/encrypt', { + method: 'POST', + body: JSON.stringify({ data }), + }); + } + + async decrypt(ciphertext: string): Promise<{ plaintext: string }> { + return this.request('/v2/api/crypto/decrypt', { + method: 'POST', + body: JSON.stringify({ data: ciphertext }), + }); + } + + // Usuários + async listUsers(): Promise> { + return this.request('/api/v1/users'); + } + + async getUser(id: string): Promise<{ id: string; name: string; email: string }> { + return this.request(`/api/v1/users/${id}`); + } + + async createUser(data: { name: string; email: string }): Promise<{ id: string; name: string; email: string }> { + return this.request('/api/v1/users', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateUser(id: string, data: { name?: string; email?: string }): Promise<{ id: string; name: string; email: string }> { + return this.request(`/api/v1/users/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteUser(id: string): Promise { + return this.request(`/api/v1/users/${id}`, { + method: 'DELETE', + }); + } + + // WebSocket + createWebSocket(): WebSocket { + return new WebSocket(`${WS_BASE_URL}/v2/ws`); + } +} + +export default OllaHubClient; +``` + +--- + +## Casos de Uso + +### 1. Verificar Status do Sistema + +```typescript +import OllaHubClient from './api-client'; + +const client = new OllaHubClient(); + +async function checkSystem() { + try { + const health = await client.health(); + console.log('Sistema OK:', health); + } catch (error) { + console.error('Sistema indisponível:', error); + } +} + +checkSystem(); +``` + +--- + +### 2. Verificar e Instalar Ollama + +```typescript +async function checkAndInstallOllama() { + try { + // Verificar status + const response = await fetch('http://127.0.0.1:8080/ollama/install-check'); + const status = await response.json(); + + console.log('Status Ollama:', { + instalado: status.installed, + rodando: status.service_running, + api: status.api_status, + }); + + if (!status.installed || status.api_status !== 'reachable') { + console.log('Iniciando instalação...'); + + const installResponse = await fetch('http://127.0.0.1:8080/ollama/install', { + method: 'POST', + }); + + const installResult = await installResponse.json(); + + if (installResult.installed && installResult.service_started) { + console.log('Ollama instalado e rodando!'); + } else { + console.error('Falha na instalação:', installResult.issues); + if (installResult.manual_instructions) { + console.log('Instruções manuais:', installResult.manual_instructions); + } + } + } + } catch (error) { + console.error('Erro:', error); + } +} + +checkAndInstallOllama(); +``` + +--- + +### 3. Listar e Instalar Modelos com Progresso + +```typescript +import OllaHubClient from './api-client'; + +const client = new OllaHubClient('seu_token_jwt'); + +async function installModelWithProgress(modelName: string) { + return new Promise((resolve, reject) => { + // 1. Conectar WebSocket + const ws = client.createWebSocket(); + + let modelInstalled = false; + + ws.onopen = async () => { + console.log('WebSocket conectado'); + + // 2. Iniciar instalação + try { + await client.installModel(modelName); + console.log(`Instalação de ${modelName} iniciada`); + } catch (error) { + console.error('Erro ao iniciar instalação:', error); + ws.close(); + reject(error); + } + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.kind) { + case 'Log': + console.log('📝', data.message); + break; + + case 'Progress': + try { + const progress = JSON.parse(data.message); + console.log('📊 Progresso:', progress); + } catch { + console.log('📊', data.message); + } + break; + + case 'Done': + console.log('✅', data.message); + modelInstalled = true; + ws.close(); + resolve(); + break; + + case 'Error': + console.error('❌ Erro:', data.message); + ws.close(); + reject(new Error(data.message)); + break; + } + }; + + ws.onerror = (error) => { + console.error('Erro WebSocket:', error); + reject(error); + }; + + ws.onclose = () => { + if (!modelInstalled) { + reject(new Error('Conexão WebSocket fechada antes da conclusão')); + } + }; + }); +} + +// Uso +async function main() { + try { + // Listar modelos disponíveis + const models = await client.listModels(); + console.log('Modelos disponíveis:', models); + + // Instalar um modelo + await installModelWithProgress('llama2:7b'); + console.log('Modelo instalado com sucesso!'); + } catch (error) { + console.error('Erro:', error); + } +} + +main(); +``` + +--- + +### 4. Sistema de Criptografia Completo + +```typescript +import OllaHubClient from './api-client'; + +const client = new OllaHubClient('seu_token_jwt'); + +async function cryptoWorkflow() { + try { + // 1. Gerar nova chave + console.log('Gerando nova chave de criptografia...'); + const { key } = await client.rotateCryptoKey(); + console.log('Chave gerada:', key); + + // Salvar chave localmente (ex: localStorage, secure storage) + localStorage.setItem('crypto_key', key); + + // 2. Criptografar dados sensíveis + const sensitiveData = 'senha_super_secreta_123'; + console.log('Criptografando dados...'); + const { ciphertext } = await client.encrypt(sensitiveData); + console.log('Dados criptografados:', ciphertext); + + // 3. Descriptografar + console.log('Descriptografando...'); + const { plaintext } = await client.decrypt(ciphertext); + console.log('Dados descriptografados:', plaintext); + + if (plaintext === sensitiveData) { + console.log('✅ Criptografia funcionando corretamente!'); + } + } catch (error) { + console.error('Erro no fluxo de criptografia:', error); + } +} + +// Usar chave existente +async function useExistingKey() { + const savedKey = localStorage.getItem('crypto_key'); + + if (!savedKey) { + console.error('Chave não encontrada'); + return; + } + + try { + await client.setCryptoKey(savedKey); + console.log('Chave configurada com sucesso'); + } catch (error) { + console.error('Erro ao configurar chave:', error); + } +} + +cryptoWorkflow(); +``` + +--- + +### 5. Gerenciamento Completo de Usuários + +```typescript +import OllaHubClient from './api-client'; + +const client = new OllaHubClient('seu_token_jwt'); + +async function userManagementDemo() { + try { + // 1. Criar usuários + console.log('Criando usuários...'); + const user1 = await client.createUser({ + name: 'João Silva', + email: 'joao@example.com', + }); + console.log('Usuário criado:', user1); + + const user2 = await client.createUser({ + name: 'Maria Santos', + email: 'maria@example.com', + }); + console.log('Usuário criado:', user2); + + // 2. Listar todos + console.log('\nListando usuários...'); + const users = await client.listUsers(); + console.log('Total de usuários:', users.length); + users.forEach(u => console.log(`- ${u.name} (${u.email})`)); + + // 3. Obter usuário específico + console.log('\nObtendo usuário específico...'); + const user = await client.getUser(user1.id); + console.log('Usuário:', user); + + // 4. Atualizar usuário + console.log('\nAtualizando usuário...'); + const updated = await client.updateUser(user1.id, { + name: 'João Silva Atualizado', + email: 'joao.novo@example.com', + }); + console.log('Usuário atualizado:', updated); + + // 5. Deletar usuário + console.log('\nDeletando usuário...'); + await client.deleteUser(user2.id); + console.log('Usuário deletado'); + + // 6. Verificar lista final + const finalUsers = await client.listUsers(); + console.log('\nUsuários restantes:', finalUsers.length); + } catch (error) { + console.error('Erro no gerenciamento de usuários:', error); + } +} + +userManagementDemo(); +``` + +--- + +### 6. Tratamento de Erros Robusto + +```typescript +import OllaHubClient from './api-client'; + +const client = new OllaHubClient('seu_token_jwt'); + +async function robustRequest( + fn: () => Promise, + retries = 3 +): Promise { + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (error: any) { + const isLastAttempt = i === retries - 1; + + if (error.message.includes('401')) { + throw new Error('Token JWT inválido. Faça login novamente.'); + } + + if (error.message.includes('500')) { + if (isLastAttempt) { + throw new Error('Erro interno do servidor. Tente novamente mais tarde.'); + } + console.warn(`Tentativa ${i + 1} falhou, tentando novamente...`); + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + continue; + } + + throw error; + } + } + + throw new Error('Número máximo de tentativas excedido'); +} + +// Uso +async function exemploComRetry() { + try { + const models = await robustRequest(() => client.listModels()); + console.log('Modelos:', models); + } catch (error) { + console.error('Erro após retries:', error); + } +} + +exemploComRetry(); +``` + +--- + +### 7. Hook React para WebSocket + +```typescript +// useWebSocket.ts +import { useEffect, useState, useRef } from 'react'; + +interface WsEvent { + kind: 'Log' | 'Progress' | 'Done' | 'Error'; + message: string; +} + +export function useWebSocket(url: string) { + const [events, setEvents] = useState([]); + const [connected, setConnected] = useState(false); + const wsRef = useRef(null); + + useEffect(() => { + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + console.log('WebSocket conectado'); + }; + + ws.onmessage = (event) => { + const data: WsEvent = JSON.parse(event.data); + setEvents(prev => [...prev, data]); + }; + + ws.onerror = (error) => { + console.error('Erro WebSocket:', error); + }; + + ws.onclose = () => { + setConnected(false); + console.log('WebSocket desconectado'); + }; + + return () => { + ws.close(); + }; + }, [url]); + + return { events, connected }; +} + +// Uso no componente +function ModelInstallation() { + const { events, connected } = useWebSocket('ws://127.0.0.1:8080/v2/ws'); + const client = new OllaHubClient('token'); + + const handleInstall = async (modelName: string) => { + try { + await client.installModel(modelName); + } catch (error) { + console.error('Erro:', error); + } + }; + + return ( +
+ + +
+ Status: {connected ? 'Conectado' : 'Desconectado'} +
+ +
+ {events.map((event, i) => ( +
+ {event.kind}: {event.message} +
+ ))} +
+
+ ); +} +``` + +--- + +### 8. Validação de Dados no Frontend + +```typescript +// validators.ts +export function validateEmail(email: string): boolean { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +} + +export function validateName(name: string): string | null { + if (name.length < 2) { + return 'Nome deve ter pelo menos 2 caracteres'; + } + return null; +} + +export function validateCryptoKey(hexKey: string): string | null { + if (!/^[0-9a-fA-F]{64}$/.test(hexKey)) { + return 'Chave deve ter 64 caracteres hexadecimais (32 bytes)'; + } + return null; +} + +export function validateModelName(name: string): string | null { + if (!/^[a-zA-Z0-9:._-]+$/.test(name)) { + return 'Nome do modelo contém caracteres inválidos'; + } + return null; +} + +// Uso +async function createUserWithValidation(data: { name: string; email: string }) { + const nameError = validateName(data.name); + if (nameError) { + throw new Error(nameError); + } + + if (!validateEmail(data.email)) { + throw new Error('Email inválido'); + } + + return client.createUser(data); +} +``` + +--- + +### 9. Cache de Modelos + +```typescript +// model-cache.ts +class ModelCache { + private cache: Array<{ name: string; description?: string }> | null = null; + private cacheTime: number = 0; + private readonly TTL = 5 * 60 * 1000; // 5 minutos + + async getModels(client: OllaHubClient, forceRefresh = false): Promise> { + const now = Date.now(); + + if (!forceRefresh && this.cache && (now - this.cacheTime) < this.TTL) { + return this.cache; + } + + try { + const models = await client.listModels(); + this.cache = models; + this.cacheTime = now; + return models; + } catch (error) { + if (this.cache) { + console.warn('Erro ao atualizar cache, usando cache antigo:', error); + return this.cache; + } + throw error; + } + } + + clear() { + this.cache = null; + this.cacheTime = 0; + } +} + +// Uso +const cache = new ModelCache(); +const models = await cache.getModels(client); +``` + +--- + +### 10. Monitoramento de Saúde do Sistema + +```typescript +async function monitorSystemHealth(intervalMs = 30000) { + const checkHealth = async () => { + try { + const health = await client.health(); + console.log('✅ Sistema saudável:', new Date().toISOString()); + return true; + } catch (error) { + console.error('❌ Sistema indisponível:', error); + return false; + } + }; + + // Verificação inicial + await checkHealth(); + + // Verificações periódicas + setInterval(checkHealth, intervalMs); +} + +// Monitorar com alertas +async function monitorWithAlerts() { + let consecutiveFailures = 0; + const MAX_FAILURES = 3; + + setInterval(async () => { + const isHealthy = await checkHealth(); + + if (!isHealthy) { + consecutiveFailures++; + + if (consecutiveFailures >= MAX_FAILURES) { + console.error('🚨 ALERTA: Sistema indisponível há múltiplas verificações!'); + // Enviar notificação, email, etc. + } + } else { + consecutiveFailures = 0; + } + }, 30000); +} +``` + +--- + +## Casos de Erro Comuns e Soluções + +### Erro 401: Unauthorized + +**Causa**: Token JWT ausente ou inválido. + +**Solução**: +```typescript +try { + await client.listModels(); +} catch (error: any) { + if (error.message.includes('401')) { + // Redirecionar para login ou renovar token + console.log('Token inválido, faça login novamente'); + } +} +``` + +### Erro 400: Bad Request - Validação + +**Causa**: Dados de entrada inválidos. + +**Solução**: +```typescript +try { + await client.createUser({ name: 'A', email: 'invalid' }); +} catch (error: any) { + if (error.message.includes('validação')) { + console.error('Erro de validação:', error.message); + // Mostrar erros específicos ao usuário + } +} +``` + +### Erro 409: Conflict - Chave Já Definida + +**Causa**: Tentativa de definir chave de criptografia quando já existe. + +**Solução**: +```typescript +try { + await client.setCryptoKey(key); +} catch (error: any) { + if (error.message.includes('já definida')) { + console.log('Chave já configurada, usando existente'); + // Continuar com operações de criptografia + } +} +``` + +### WebSocket Desconectado + +**Causa**: Conexão perdida durante operação. + +**Solução**: +```typescript +function createResilientWebSocket(url: string, onReconnect: () => void) { + let ws: WebSocket; + let reconnectAttempts = 0; + const MAX_RECONNECTS = 5; + + function connect() { + ws = new WebSocket(url); + + ws.onopen = () => { + reconnectAttempts = 0; + console.log('WebSocket conectado'); + }; + + ws.onclose = () => { + if (reconnectAttempts < MAX_RECONNECTS) { + reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); + console.log(`Reconectando em ${delay}ms...`); + setTimeout(() => { + connect(); + onReconnect(); + }, delay); + } else { + console.error('Máximo de tentativas de reconexão atingido'); + } + }; + } + + connect(); + return ws; +} +``` + +--- + +## Boas Práticas + +1. **Sempre trate erros**: Use try/catch em todas as chamadas de API +2. **Valide dados no frontend**: Reduza requisições inválidas +3. **Use cache quando apropriado**: Reduza carga no servidor +4. **Implemente retry logic**: Para operações críticas +5. **Monitore saúde do sistema**: Detecte problemas cedo +6. **Gerencie tokens JWT**: Renove antes de expirar +7. **Use WebSocket para operações longas**: Melhor UX +8. **Log erros adequadamente**: Facilita debugging + +--- + +**Última Atualização**: 2024 + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..299a4bc --- /dev/null +++ b/docs/README.md @@ -0,0 +1,66 @@ +# Documentação do OllaHubBackend + +Esta pasta contém a documentação técnica completa das APIs do OllaHubBackend. + +## Documentos Disponíveis + +### [API.md](./API.md) +Documentação técnica completa de todas as APIs: +- Visão geral e configuração +- Autenticação JWT +- Todos os endpoints (Sistema, Ollama, Modelos, Criptografia, Usuários, WebSocket) +- Modelos de dados e schemas +- Tratamento de erros +- Variáveis de ambiente +- Exemplos básicos de uso + +### [API_EXAMPLES.md](./API_EXAMPLES.md) +Exemplos práticos e casos de uso: +- Cliente TypeScript/JavaScript completo +- Casos de uso detalhados +- Hooks React para WebSocket +- Tratamento robusto de erros +- Validação de dados +- Cache e monitoramento +- Boas práticas + +### [OLLAMA.md](./OLLAMA.md) +Documentação específica sobre integração com Ollama (se existir). + +## Início Rápido + +1. **Leia primeiro**: [API.md](./API.md) para entender a estrutura geral +2. **Veja exemplos**: [API_EXAMPLES.md](./API_EXAMPLES.md) para implementações práticas +3. **Teste interativamente**: Acesse `http://127.0.0.1:8080/docs` (Swagger UI) + +## Estrutura da API + +### Versões +- **V1 (Legado)**: Endpoints em `main.rs` +- **V2 (Modular)**: Endpoints organizados - **Recomendado** + +### Categorias +- **Sistema**: Health checks +- **Ollama**: Instalação e verificação +- **Modelos**: Listagem e instalação +- **Criptografia**: AES-256-GCM +- **Usuários**: CRUD completo +- **WebSocket**: Eventos em tempo real + +## Links Úteis + +- **Swagger UI**: `http://127.0.0.1:8080/docs` +- **Health Check**: `http://127.0.0.1:8080/health` +- **Base URL**: `http://127.0.0.1:8080` + +## Contribuindo + +Ao adicionar novos endpoints ou modificar APIs existentes: +1. Atualize [API.md](./API.md) com a documentação do endpoint +2. Adicione exemplos em [API_EXAMPLES.md](./API_EXAMPLES.md) se aplicável +3. Mantenha a consistência com o formato existente + +--- + +**Última Atualização**: 2024 + diff --git a/src/api/mod.rs b/src/api/mod.rs index a909615..27853dd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,33 +1,52 @@ use actix_web::{get, post, web, HttpResponse, Responder}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; use crate::core::models::{pull_model, read_model_list}; -use crate::crypto::CryptoState; +use crate::services::ollama_install::OllamaInstallService; use crate::state::AppContext; -use crate::utils::log; -use crate::ws::{ws_route, BrokerAddr, WsEvent, WsEventKind}; +use crate::ws::{ws_route, BrokerAddr}; use base64::{engine::general_purpose, Engine as _}; +use crate::middlewares::error::ApiError; +#[utoipa::path( + get, + path = "/v2/health", + responses((status = 200, description = "Saúde", body = HealthResponse)) +)] #[get("/health")] pub async fn health() -> impl Responder { - HttpResponse::Ok().json(serde_json::json!({"status":"ok"})) + HttpResponse::Ok().json(HealthResponse { status: "ok".into() }) } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct HealthResponse { pub status: String } + +#[utoipa::path( + get, + path = "/v2/api/models", + responses((status = 200, description = "Lista de modelos", body = [crate::core::models::ModelInfo]), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody)) +)] #[get("/api/models")] -pub async fn models_list(state: web::Data) -> impl Responder { +pub async fn models_list(state: web::Data) -> Result { match read_model_list(&state.model_list_path) { - Ok(list) => HttpResponse::Ok().json(list), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})), + Ok(list) => Ok(HttpResponse::Ok().json(list)), + Err(e) => Err(ApiError::Internal(e)), } } -#[derive(Deserialize)] -pub struct InstallReq { - pub name: String, -} +#[derive(Deserialize, ToSchema)] +pub struct InstallReq { pub name: String } +#[utoipa::path( + post, + path = "/v2/api/models/install", + request_body = InstallReq, + responses((status = 202, description = "Instalação iniciada", body = ModelInstallStarted)) +)] #[post("/api/models/install")] -pub async fn models_install(state: web::Data, payload: web::Json) -> impl Responder { +pub async fn models_install(state: web::Data, payload: web::Json) -> Result { let name = payload.name.clone(); let tx = state.broker.clone(); let base = state.ollama_base_url.clone(); @@ -35,55 +54,142 @@ pub async fn models_install(state: web::Data, payload: web::Json, payload: web::Json) -> impl Responder { +pub async fn crypto_set_key(state: web::Data, payload: web::Json) -> Result { match state.crypto.set_key_hex(&payload.hex_key) { - Ok(()) => HttpResponse::Ok().json(serde_json::json!({"status":"ok"})), - Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})), + Ok(()) => Ok(HttpResponse::Ok().json(SimpleOk { ok: true })), + Err(e) => Err(ApiError::BadRequest(e)), } } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SimpleOk { pub ok: bool } + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RotateKeyResponse { pub key: String } + +#[utoipa::path( + post, + path = "/v2/api/crypto/rotate-key", + responses((status = 200, description = "Chave rotacionada", body = RotateKeyResponse), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody)) +)] #[post("/api/crypto/rotate-key")] -pub async fn crypto_rotate_key(state: web::Data) -> impl Responder { +pub async fn crypto_rotate_key(state: web::Data) -> Result { match state.crypto.rotate_key() { - Ok(hex) => HttpResponse::Ok().json(serde_json::json!({"key": hex})), - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})), + Ok(hex) => Ok(HttpResponse::Ok().json(RotateKeyResponse { key: hex })), + Err(e) => Err(ApiError::Internal(e)), } } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct CryptReq { pub data: String } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct EncryptResponse { pub ciphertext: String } + +#[utoipa::path( + post, + path = "/v2/api/crypto/encrypt", + request_body = CryptReq, + responses((status = 200, description = "Criptografia", body = EncryptResponse), (status = 400, description = "Erro", body = crate::middlewares::error::ApiErrorBody)) +)] #[post("/api/crypto/encrypt")] -pub async fn crypto_encrypt(state: web::Data, payload: web::Json) -> impl Responder { +pub async fn crypto_encrypt(state: web::Data, payload: web::Json) -> Result { match state.crypto.encrypt(payload.data.as_bytes()) { - Ok(ct) => HttpResponse::Ok().json(serde_json::json!({"ciphertext": general_purpose::STANDARD.encode(ct)})), - Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})), + Ok(ct) => Ok(HttpResponse::Ok().json(EncryptResponse { ciphertext: general_purpose::STANDARD.encode(ct) })), + Err(e) => Err(ApiError::BadRequest(e)), } } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct DecryptResponse { pub plaintext: String } + +#[utoipa::path( + post, + path = "/v2/api/crypto/decrypt", + request_body = CryptReq, + responses((status = 200, description = "Descriptografia", body = DecryptResponse), (status = 400, description = "Erro", body = crate::middlewares::error::ApiErrorBody)) +)] #[post("/api/crypto/decrypt")] -pub async fn crypto_decrypt(state: web::Data, payload: web::Json) -> impl Responder { +pub async fn crypto_decrypt(state: web::Data, payload: web::Json) -> Result { match general_purpose::STANDARD.decode(&payload.data) { Ok(bytes) => match state.crypto.decrypt(&bytes) { - Ok(pt) => HttpResponse::Ok().json(serde_json::json!({"plaintext": String::from_utf8_lossy(&pt)})), - Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})), + Ok(pt) => Ok(HttpResponse::Ok().json(DecryptResponse { plaintext: String::from_utf8_lossy(&pt).to_string() })), + Err(e) => Err(ApiError::BadRequest(e)), }, - Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": format!("base64 decode: {}", e)})), + Err(e) => Err(ApiError::BadRequest(format!("base64 decode: {}", e))), } } +#[utoipa::path( + get, + path = "/v2/api/ollama/check", + responses((status = 200, description = "Verificação de instalação", body = InstallCheckResult), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody)) +)] +#[get("/api/ollama/check")] +pub async fn ollama_check(state: web::Data) -> Result { + match OllamaInstallService::check_installation(&state.ollama_base_url).await { + Ok(result) => Ok(HttpResponse::Ok().json(result)), + Err(e) => Err(ApiError::Internal(e)), + } +} + +#[derive(Deserialize, ToSchema)] +pub struct OllamaInstallReq { + #[serde(default)] + pub method: Option, + #[serde(default)] + pub platform: Option, +} + +#[utoipa::path( + post, + path = "/v2/api/ollama/install", + request_body = OllamaInstallReq, + responses((status = 202, description = "Instalação iniciada", body = OllamaInstallStarted), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody)) +)] +#[post("/api/ollama/install")] +pub async fn ollama_install(state: web::Data, _payload: web::Json) -> Result { + let operation_id = Uuid::new_v4(); + let base_url = state.ollama_base_url.clone(); + let broker = state.broker.clone(); + let op_id = operation_id; + + actix_rt::spawn(async move { + let _ = OllamaInstallService::install(&base_url, &broker, op_id).await; + }); + + Ok(HttpResponse::Accepted().json(OllamaInstallStarted { + status: "started".into(), + operation_id: operation_id.to_string(), + })) +} + pub fn configure(cfg: &mut web::ServiceConfig, state: web::Data) { cfg.app_data(state); cfg.service(health); cfg.service(models_list); cfg.service(models_install); + cfg.service(ollama_check); + cfg.service(ollama_install); cfg.service(crypto_set_key); cfg.service(crypto_rotate_key); cfg.service(crypto_encrypt); @@ -93,4 +199,9 @@ pub fn configure(cfg: &mut web::ServiceConfig, state: web::Data) { pub fn ws_config(cfg: &mut web::ServiceConfig, broker: actix_web::web::Data) { cfg.app_data(broker); cfg.service(ws_route); +} + +pub fn chat_ws_config(cfg: &mut web::ServiceConfig, state: web::Data) { + cfg.app_data(state); + cfg.service(crate::ws::chat::chat_ws_route); } \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ea97ce7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,44 @@ +use dirs::config_dir; +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Deserialize, Clone)] +pub struct Config { + pub host: String, + pub port: u16, + pub ollama_base_url: String, + pub model_list_path: String, + pub jwt_secret: String, + pub v2_key_path: PathBuf, +} + +impl Config { + pub fn from_env() -> Self { + let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = std::env::var("PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse() + .expect("PORT must be a number"); + + let ollama_base_url = std::env::var("OLLAMA_BASE_URL") + .unwrap_or_else(|_| "http://localhost:11434".to_string()); + + let model_list_path = + std::env::var("MODEL_LIST_PATH").unwrap_or_else(|_| "modelList.json".to_string()); + + let jwt_secret = + std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme-dev-secret".to_string()); + + let v2_key_dir = config_dir().unwrap_or(PathBuf::from(".")); + let v2_key_path = v2_key_dir.join("OllaHubBackend").join("crypto_v2.key"); + + Self { + host, + port, + ollama_base_url, + model_list_path, + jwt_secret, + v2_key_path, + } + } +} diff --git a/src/controllers/user_controller.rs b/src/controllers/user_controller.rs index 45dc811..93391bf 100644 --- a/src/controllers/user_controller.rs +++ b/src/controllers/user_controller.rs @@ -1,14 +1,16 @@ -use actix_web::{get, post, put, delete, web, HttpResponse, Responder}; -use utoipa::{IntoParams, ToSchema}; +use actix_web::{delete, get, post, put, web, HttpResponse, Responder}; use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; use validator::Validate; use crate::middlewares::error::ApiError; -use crate::models::user::{CreateUserRequest, UpdateUserRequest, User}; +use crate::models::user::{CreateUserRequest, UpdateUserRequest}; use crate::services::user_service::UserService; #[derive(Deserialize, IntoParams, ToSchema)] -pub struct UserPath { pub id: String } +pub struct UserPath { + pub id: String, +} #[utoipa::path( get, @@ -18,7 +20,9 @@ pub struct UserPath { pub id: String } ) )] #[get("/api/v1/users")] -pub async fn list_users(svc: web::Data>) -> impl Responder { +pub async fn list_users( + svc: web::Data>, +) -> impl Responder { HttpResponse::Ok().json(svc.list()) } @@ -32,7 +36,10 @@ pub async fn list_users(svc: web::Data, svc: web::Data>) -> Result { +pub async fn get_user( + path: web::Path, + svc: web::Data>, +) -> Result { match svc.get(path.id.clone()) { Some(u) => Ok(HttpResponse::Ok().json(u)), None => Err(ApiError::NotFound("Usuário não encontrado".into())), @@ -49,9 +56,14 @@ pub async fn get_user(path: web::Path, svc: web::Data, svc: web::Data>) -> Result { - let mut req = payload.into_inner(); - if let Err(e) = req.validate() { return Err(ApiError::BadRequest(format!("validação: {}", e))); } +pub async fn create_user( + payload: web::Json, + svc: web::Data>, +) -> Result { + let req = payload.into_inner(); + if let Err(e) = req.validate() { + return Err(ApiError::BadRequest(format!("validação: {}", e))); + } let user = svc.create(req); Ok(HttpResponse::Created().json(user)) } @@ -68,9 +80,15 @@ pub async fn create_user(payload: web::Json, svc: web::Data, payload: web::Json, svc: web::Data>) -> Result { - let mut req = payload.into_inner(); - if let Err(e) = req.validate() { return Err(ApiError::BadRequest(format!("validação: {}", e))); } +pub async fn update_user( + path: web::Path, + payload: web::Json, + svc: web::Data>, +) -> Result { + let req = payload.into_inner(); + if let Err(e) = req.validate() { + return Err(ApiError::BadRequest(format!("validação: {}", e))); + } match svc.update(path.id.clone(), req) { Some(u) => Ok(HttpResponse::Ok().json(u)), None => Err(ApiError::NotFound("Usuário não encontrado".into())), @@ -87,6 +105,13 @@ pub async fn update_user(path: web::Path, payload: web::Json, svc: web::Data>) -> Result { - if svc.delete(path.id.clone()) { Ok(HttpResponse::NoContent().finish()) } else { Err(ApiError::NotFound("Usuário não encontrado".into())) } -} \ No newline at end of file +pub async fn delete_user( + path: web::Path, + svc: web::Data>, +) -> Result { + if svc.delete(path.id.clone()) { + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::NotFound("Usuário não encontrado".into())) + } +} diff --git a/src/core/models.rs b/src/core/models.rs index 020712e..cc7ce87 100644 --- a/src/core/models.rs +++ b/src/core/models.rs @@ -1,25 +1,134 @@ +use std::collections::HashMap; use std::fs; -use std::path::Path; use actix_web::http::header::USER_AGENT; use awc::Client; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::utils::log; use crate::ws::{WsEvent, WsEventKind}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ModelInfo { pub name: String, pub description: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApiStatus { + Reachable, + Unreachable, + Degraded, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MethodKind { + Native, + Docker, + Api, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct DetectedMethod { + pub kind: MethodKind, + pub details: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct InstallCheckResult { + pub installed: bool, + pub in_path: bool, + pub version: Option, + pub service_running: bool, + pub api_status: ApiStatus, + pub details: HashMap, + pub issues: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct InstallResult { + pub initiated: bool, + pub installed: bool, + pub service_started: bool, + pub final_api_status: ApiStatus, + pub details: HashMap, + pub issues: Vec, + pub timing_ms: HashMap, + pub aborted: Option, + pub manual_instructions: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct InstallStarted { + pub status: String, + pub operation_id: String, +} + +#[derive(Debug, Deserialize)] +struct ModelListFile { + models: Vec, +} + pub fn read_model_list(path: &str) -> Result, String> { let data = fs::read_to_string(path).map_err(|e| e.to_string())?; - let list: Vec = serde_json::from_str(&data).map_err(|e| e.to_string())?; + + // Tentar deserializar como objeto com campo "models" primeiro + if let Ok(file) = serde_json::from_str::(&data) { + return Ok(file.models); + } + + // Se falhar, tentar como array direto (formato antigo) + let list: Vec = serde_json::from_str(&data).map_err(|e| { + format!("Erro ao deserializar modelList.json: {}. Formato esperado: objeto com campo 'models' ou array direto.", e) + })?; Ok(list) } +#[derive(Debug, Deserialize)] +struct OllamaTagsResponse { + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaModelInfo { + name: String, +} + +/// Verifica se um modelo está instalado no Ollama +pub async fn check_model_installed(base_url: &str, model_name: &str) -> Result { + let client = Client::default(); + let url = format!("{}/api/tags", base_url.trim_end_matches('/')); + + let mut resp = client + .get(&url) + .insert_header((USER_AGENT, "OllaHubBackend/1.0")) + .send() + .await + .map_err(|e| format!("Erro ao conectar com Ollama: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Ollama API retornou erro: {}", resp.status())); + } + + let body = resp.body().await.map_err(|e| format!("Erro ao ler resposta: {}", e))?; + let text = String::from_utf8_lossy(&body); + + let tags: OllamaTagsResponse = serde_json::from_str(&text) + .map_err(|e| format!("Erro ao parsear resposta do Ollama: {}", e))?; + + // Verificar se o modelo está na lista (comparação exata ou parcial) + let installed = tags.models.iter().any(|m| { + m.name == model_name || + m.name.starts_with(&format!("{}:", model_name)) || + m.name == format!("{}:latest", model_name) + }); + + Ok(installed) +} + pub async fn pull_model( base_url: &str, model: &str, diff --git a/src/main.rs b/src/main.rs index 231ec67..4115b30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,547 +1,85 @@ -use std::collections::HashMap; -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process::Command; +use std::time::Instant; -use awc::Client; -use std::time::{Duration, Instant}; -use serde::{Deserialize, Serialize}; -use actix_rt::time::sleep; -use actix_web::{web, App, HttpResponse, HttpServer, Responder}; -use actix_web_actors::ws as actix_ws; use actix_web::middleware::from_fn; -use utoipa_swagger_ui::SwaggerUi; -use actix::prelude::*; -use tokio::sync::broadcast; +use actix_web::{web, App, HttpResponse, HttpServer, Responder}; use sysinfo::System; -use uuid::Uuid; -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine; -use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; -use aes_gcm::aead::Aead; -use rand::RngCore; -use dirs::config_dir; // Arquitetura modular mod api; +mod controllers; mod core; mod crypto; -mod controllers; -mod models; mod middlewares; -mod services; -mod state; +mod models; mod repositories; mod routes; +mod services; +mod state; mod utils; mod ws; use state::AppContext; use ws::BrokerAddr; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum Platform { - Linux { distro: Option }, - Windows, - Macos, - Unknown(String), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum ApiStatus { - Reachable, - Unreachable, - Degraded, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum MethodKind { - Native, - Docker, - Api, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct DetectedMethod { - kind: MethodKind, - details: HashMap, -} - -// Estrutura de relatório antigo removida; endpoints HTTP retornam DTOs específicos. - -fn detect_platform() -> Platform { - let os = env::consts::OS; - match os { - "linux" => Platform::Linux { distro: detect_linux_distro() }, - "windows" => Platform::Windows, - "macos" => Platform::Macos, - other => Platform::Unknown(other.to_string()), - } -} - -fn detect_linux_distro() -> Option { - // Tenta ler /etc/os-release e extrair ID - let content = fs::read_to_string("/etc/os-release").ok()?; - for line in content.lines() { - if let Some(stripped) = line.strip_prefix("ID=") { - return Some(stripped.trim_matches('\"').to_string()); - } - } - None -} - -fn path_exists(path: &PathBuf) -> bool { - fs::metadata(path).is_ok() -} - -fn run_cmd(cmd: &str, args: &[&str]) -> Result { - let output = Command::new(cmd).args(args).output(); - match output { - Ok(out) => { - if out.status.success() { - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - Ok(stdout) - } else { - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - Err(format!("command failed: {} {:?} -> {}", cmd, args, stderr)) - } - } - Err(e) => Err(format!("command error: {} {:?} -> {}", cmd, args, e)), - } -} - fn info(msg: &str) { println!("[INFO] {}", msg); } -fn warn(msg: &str) { - println!("[AVISO] {}", msg); -} +mod config; // Add config module -fn error(msg: &str) { - eprintln!("[ERRO] {}", msg); -} - -fn check_antivirus(platform: &Platform, issues: &mut Vec) -> HashMap { - let mut details = HashMap::new(); - match platform { - Platform::Windows => { - info("Verificando antivírus no Windows..."); - match run_cmd("sc", &["query", "WinDefend"]) { - Ok(out) => { - details.insert("win_defender_service".into(), out.clone()); - if out.to_lowercase().contains("running") { - warn("Antivírus ativo detectado (Windows Defender). Autorize este programa no antivírus."); - details.insert("antivirus_active".into(), "true".into()); - } else { - details.insert("antivirus_active".into(), "false".into()); - } - } - Err(e) => { - issues.push(format!("falha ao consultar serviço WinDefend: {}", e)); - } - } - match run_cmd( - "powershell", - &["-NoProfile", "-Command", "Get-MpComputerStatus | Format-List"], - ) { - Ok(ps) => { - details.insert("mp_status".into(), ps); - } - Err(e) => { - issues.push(format!("PowerShell Get-MpComputerStatus falhou: {}", e)); - } - } - warn("Se necessário, desative temporariamente o antivírus durante a inicialização."); - details.insert("antivirus_instructions".into(), "Abra o antivírus, autorize este programa ou desative temporariamente.".into()); - } - Platform::Linux { .. } => { - info("Verificando SELinux/AppArmor no Linux..."); - match run_cmd("getenforce", &[]) { - Ok(enf) => { - details.insert("selinux_getenforce".into(), enf.trim().to_string()); - if enf.trim().eq_ignore_ascii_case("enforcing") { - warn("SELinux em modo Enforcing. Pode bloquear inicialização. Autorize ou ajuste política."); - details.insert("selinux_enforcing".into(), "true".into()); - } - } - Err(e) => issues.push(format!("getenforce falhou: {}", e)), - } - match run_cmd("sestatus", &[]) { - Ok(st) => { - details.insert("sestatus".into(), st); - } - Err(e) => issues.push(format!("sestatus falhou: {}", e)), - } - match run_cmd("aa-status", &[]) { - Ok(st) => { - details.insert("apparmor_status".into(), st); - warn("AppArmor detectado. Garanta perfis permitindo este programa ou ajuste temporariamente."); - } - Err(e) => { - let p = PathBuf::from("/sys/module/apparmor/parameters/enabled"); - if let Ok(content) = fs::read_to_string(&p) { - let c = content.trim().to_string(); - details.insert("apparmor_enabled".into(), c.clone()); - if c.to_lowercase().contains("y") || c.to_lowercase().contains("yes") { - warn("AppArmor ativo. Autorize este programa ou ajuste perfil."); - } - } else { - issues.push(format!("aa-status falhou e sysfs não disponível: {}", e)); - } - } - } - details.insert("antivirus_instructions".into(), "Verifique SELinux/AppArmor. Ajuste políticas ou desative temporariamente.".into()); - } - Platform::Macos => { - info("Verificando proteções no macOS (Gatekeeper/Antivírus)..."); - match run_cmd("spctl", &["--status"]) { - Ok(st) => { - details.insert("gatekeeper".into(), st.trim().to_string()); - } - Err(e) => issues.push(format!("spctl --status falhou: {}", e)), - } - warn("Se o sistema alertar, permita a execução deste programa nas Preferências de Segurança."); - details.insert("antivirus_instructions".into(), "Permita o app nas Preferências de Segurança, se solicitado.".into()); - } - Platform::Unknown(_) => { - warn("Plataforma desconhecida. Verificações de antivírus limitadas."); - } - } - details -} - -async fn ensure_ollama_running(client: &Client, platform: &Platform, issues: &mut Vec) -> bool { - info("Verificando disponibilidade da API do Ollama..."); - let (status, _) = check_api(client, issues).await; - if matches!(status, ApiStatus::Reachable) { - info("API do Ollama já está acessível."); - return true; - } - - info("Tentando iniciar o serviço Ollama..."); - match platform { - Platform::Windows => { - match Command::new("ollama").arg("serve").spawn() { - Ok(_) => info("Comando 'ollama serve' iniciado no Windows."), - Err(e) => { - issues.push(format!("falha ao iniciar ollama serve: {}", e)); - warn("Não foi possível iniciar automaticamente. Abra o aplicativo Ollama ou reinicie o serviço."); - } - } - } - Platform::Linux { .. } => { - match run_cmd("systemctl", &["start", "ollama"]) { - Ok(_) => info("systemctl start ollama executado."), - Err(e) => { - issues.push(format!("systemctl start falhou: {}", e)); - match Command::new("ollama").arg("serve").spawn() { - Ok(_) => info("Comando 'ollama serve' iniciado (fallback)."), - Err(e2) => { - issues.push(format!("falha ao iniciar ollama serve: {}", e2)); - warn("Inicialização automática falhou. Inicie manualmente o serviço Ollama."); - } - } - } - } - } - Platform::Macos => { - match Command::new("ollama").arg("serve").spawn() { - Ok(_) => info("Comando 'ollama serve' iniciado no macOS."), - Err(e) => { - issues.push(format!("falha ao iniciar ollama serve: {}", e)); - warn("Inicialização automática falhou. Abra o aplicativo Ollama."); - } - } - } - Platform::Unknown(_) => { - warn("Plataforma desconhecida; tentativa genérica de iniciar ollama serve."); - let _ = Command::new("ollama").arg("serve").spawn(); - } - } - - for _ in 0..10 { - sleep(Duration::from_millis(1000)).await; - let (s, _) = check_api(client, issues).await; - if matches!(s, ApiStatus::Reachable) { - info("API do Ollama está acessível após inicialização."); - return true; - } - } - warn("API do Ollama continua inacessível após tentativas."); - false -} - -fn check_native(platform: &Platform, issues: &mut Vec) -> Option { - let mut details = HashMap::new(); - - match platform { - Platform::Linux { distro } => { - // Verifica binário via which - if let Ok(which) = run_cmd("which", &["ollama"]) { - details.insert("which".into(), which.trim().to_string()); - } - // Checa /usr/bin/ollama - let path = PathBuf::from("/usr/bin/ollama"); - details.insert("/usr/bin/ollama".into(), path_exists(&path).to_string()); - - // Serviço via systemctl, se disponível - match run_cmd("systemctl", &["is-active", "ollama"]) { - Ok(active) => { - details.insert("systemctl".into(), active.trim().to_string()); - } - Err(e) => { - issues.push(format!("systemctl check failed: {}", e)); - } - } - - // Versão - match run_cmd("ollama", &["-v"]) { - Ok(ver) => { - details.insert("version".into(), ver.trim().to_string()); - return Some(DetectedMethod { kind: MethodKind::Native, details }); - } - Err(e) => { - issues.push(format!("ollama -v failed: {}", e)); - } - } - // Ajuda por distro - if let Some(d) = distro { - details.insert("distro".into(), d.clone()); - } - } - Platform::Macos => { - // Caminhos comuns - let bin_paths = [ - "/usr/local/bin/ollama", - "/opt/homebrew/bin/ollama", - ]; - for p in bin_paths { - let pb = PathBuf::from(p); - details.insert(p.into(), path_exists(&pb).to_string()); - } - match run_cmd("ollama", &["-v"]) { - Ok(ver) => { - details.insert("version".into(), ver.trim().to_string()); - return Some(DetectedMethod { kind: MethodKind::Native, details }); - } - Err(e) => { - issues.push(format!("ollama -v failed: {}", e)); - } - } - } - Platform::Windows => { - // Checa localização padrão do instalador - let mut possible = vec![]; - if let Ok(localappdata) = env::var("LOCALAPPDATA") { - possible.push(PathBuf::from(format!("{}\\Programs\\Ollama\\ollama.exe", localappdata))); - possible.push(PathBuf::from(format!("{}\\Ollama\\server.log", localappdata))); // logs - } - // Preferir USERPROFILE; se não houver, usar HOMEDRIVE+HOMEPATH - if let Ok(userprofile) = env::var("USERPROFILE") { - possible.push(PathBuf::from(format!("{}\\.ollama", userprofile))); - } else { - let homepath = env::var("HOMEPATH").unwrap_or_default(); - let homedrive = env::var("HOMEDRIVE").unwrap_or_default(); - if !homepath.is_empty() || !homedrive.is_empty() { - let combined = format!("{}{}", homedrive, homepath); - if !combined.trim().is_empty() { - possible.push(PathBuf::from(format!("{}\\.ollama", combined))); - } - } - } - for p in possible { - details.insert(p.display().to_string(), path_exists(&p).to_string()); - } - // where.exe - match run_cmd("where", &["ollama"]) { - Ok(loc) => { - details.insert("where".into(), loc.trim().to_string()); - } - Err(e) => issues.push(format!("where ollama failed: {}", e)), - } - // versão - match run_cmd("ollama", &["-v"]) { - Ok(ver) => { - details.insert("version".into(), ver.trim().to_string()); - return Some(DetectedMethod { kind: MethodKind::Native, details }); - } - Err(e) => issues.push(format!("ollama -v failed: {}", e)), - } - } - Platform::Unknown(_) => { - issues.push("plataforma desconhecida; checagem nativa limitada".into()); - } - } - - None -} - -fn check_docker(issues: &mut Vec) -> Option { - let mut details = HashMap::new(); - // Tenta listar containers, caso docker esteja disponível - match run_cmd("docker", &[ - "ps", - "--format", - "{{.Image}} {{.Names}}", - ]) { - Ok(list) => { - let mut found = false; - for line in list.lines() { - if line.to_lowercase().contains("ollama") { - found = true; - details.insert("container".into(), line.trim().to_string()); - } - } - if found { - return Some(DetectedMethod { kind: MethodKind::Docker, details }); - } else { - details.insert("docker_ps".into(), "no_ollama_container".into()); - return None; - } - } - Err(e) => { - issues.push(format!("docker ps failed: {}", e)); - } - } - - None -} - -async fn check_api(client: &Client, issues: &mut Vec) -> (ApiStatus, Option) { - let mut details = HashMap::new(); - let base = "http://localhost:11434"; - - // Tenta /api/version - if let Ok(mut resp) = client.get(format!("{}/api/version", base)).send().await { - if resp.status().is_success() { - let body = resp.body().await; - if let Ok(bytes) = body { - let text = String::from_utf8_lossy(&bytes).to_string(); - details.insert("/api/version".into(), text); - } - } else { - issues.push(format!("/api/version status: {}", resp.status())); - } - } else { - issues.push("falha ao conectar /api/version".into()); - } - - // Tenta /api/tags - if let Ok(mut resp) = client.get(format!("{}/api/tags", base)).send().await { - if resp.status().is_success() { - let body = resp.body().await; - if let Ok(bytes) = body { - let text = String::from_utf8_lossy(&bytes).to_string(); - details.insert("/api/tags".into(), text); - } - } else { - issues.push(format!("/api/tags status: {}", resp.status())); - } - } else { - issues.push("falha ao conectar /api/tags".into()); - } - - // Heurística de status - let api_status = if details.is_empty() { - ApiStatus::Unreachable - } else if !details.contains_key("/api/version") { - ApiStatus::Degraded - } else { - ApiStatus::Reachable - }; - - let method = if matches!(api_status, ApiStatus::Reachable | ApiStatus::Degraded) { - Some(DetectedMethod { kind: MethodKind::Api, details }) - } else { - None - }; - - (api_status, method) -} - -fn add_doc_fallbacks(platform: &Platform, details: &mut HashMap) { - // Informações baseadas na documentação pública do Ollama - match platform { - Platform::Windows => { - details.insert("docs_windows_api".into(), "API local: http://localhost:11434".into()); - details.insert("docs_windows_logs".into(), "%LOCALAPPDATA%\\Ollama\\server.log".into()); - details.insert("docs_windows_bin".into(), "%LOCALAPPDATA%\\Programs\\Ollama".into()); - } - Platform::Linux { .. } => { - details.insert("docs_linux_service".into(), "systemctl status ollama".into()); - details.insert("docs_linux_api".into(), "API local: http://localhost:11434".into()); - } - Platform::Macos => { - details.insert("docs_macos_api".into(), "API local: http://localhost:11434".into()); - details.insert("docs_macos_common_paths".into(), "/usr/local/bin, /opt/homebrew/bin".into()); - } - Platform::Unknown(_) => { - details.insert("docs_general".into(), "API local: http://localhost:11434".into()); - } - } -} - -struct AppState { - tx: broadcast::Sender, - crypto: CryptoState, -} - -#[derive(Clone)] -struct CryptoState { - // Chave AES-256-GCM (32 bytes) armazenada se inicializada - key: std::sync::Arc>>, - key_path: std::path::PathBuf, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WsEvent { - event: String, - operation_id: Option, - data: serde_json::Value, -} +use config::Config; #[actix_rt::main] async fn main() -> std::io::Result<()> { + // Carregar variáveis de ambiente do arquivo .env (se existir) + dotenv::dotenv().ok(); + + // Load configuration + let config = Config::from_env(); + info("Iniciando servidor HTTP para gerenciamento do Ollama..."); + + // Verificar e solicitar permissões Windows (apenas no Windows) + #[cfg(target_os = "windows")] + { + use crate::services::windows_permissions::WindowsPermissions; + match WindowsPermissions::setup_all_permissions() { + Ok(status) => { + if status.issues.is_empty() { + info("✓ Todas as permissões do Windows configuradas com sucesso"); + } else { + info(&format!("⚠ Algumas permissões não puderam ser configuradas automaticamente ({} problema(s))", status.issues.len())); + if !status.admin { + info("💡 Dica: Execute como Administrador para configurar todas as permissões automaticamente"); + } + } + } + Err(e) => { + info(&format!( + "⚠ Erro ao configurar permissões do Windows: {}", + e + )); + info("💡 A API pode não funcionar corretamente. Execute como Administrador."); + } + } + } + let start = Instant::now(); let mut sys = System::new(); sys.refresh_cpu(); sys.refresh_memory(); - info(&format!("Recursos iniciais: CPU {:.2}% | Memória usada: {} / {} MB", + info(&format!( + "Recursos iniciais: CPU {:.2}% | Memória usada: {} / {} MB", sys.global_cpu_info().cpu_usage(), - sys.used_memory() / 1024, sys.total_memory() / 1024)); - - // Broker para eventos WebSocket - let (tx, _rx) = broadcast::channel::(128); - // Estado de criptografia - let key_dir = config_dir().unwrap_or(std::path::PathBuf::from(".")); - let key_path = key_dir.join("OllaHubBackend").join("crypto.key"); - let crypto_state = CryptoState { key: std::sync::Arc::new(std::sync::Mutex::new(None)), key_path: key_path.clone() }; - // Tenta carregar chave existente - if let Some(parent) = key_path.parent() { let _ = std::fs::create_dir_all(parent); } - if let Ok(bytes) = std::fs::read(&key_path) { - if bytes.len() == 32 { - let mut key_lock = crypto_state.key.lock().unwrap(); - let mut key_bytes = [0u8;32]; - key_bytes.copy_from_slice(&bytes); - *key_lock = Some(key_bytes); - info(&format!("Chave carregada de: {}. Use a MESMA chave no frontend.", key_path.display())); - } else { - warn("Arquivo de chave encontrado com tamanho inválido; ignorando."); - } - } + sys.used_memory() / 1024, + sys.total_memory() / 1024 + )); // Estado V2 (modular) - let v2_key_dir = config_dir().unwrap_or(std::path::PathBuf::from(".")); - let v2_key_path = v2_key_dir.join("OllaHubBackend").join("crypto_v2.key"); let v2_state = AppContext::new( - std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()), - std::env::var("MODEL_LIST_PATH").unwrap_or_else(|_| "modelList.json".to_string()), - v2_key_path, + config.ollama_base_url.clone(), + config.model_list_path.clone(), + config.v2_key_path.clone(), + config.jwt_secret.clone(), ); let v2_state_data = web::Data::new(v2_state); let v2_broker = ws::new_broker(); @@ -549,18 +87,12 @@ async fn main() -> std::io::Result<()> { let server = HttpServer::new(move || { App::new() - .app_data(web::Data::new(AppState { tx: tx.clone(), crypto: crypto_state.clone() })) + // Middleware CORS (deve ser aplicado primeiro) + .wrap(middlewares::cors::configure_cors()) + // Middleware de logging customizado completo + .wrap(middlewares::logging::LoggingMiddleware) .route("/health", web::get().to(health)) - .route("/ollama/install-check", web::get().to(install_check)) - .route("/ollama/install", web::post().to(install_ollama)) - .route("/ws", web::get().to(ws_route)) - .route("/api/models", web::get().to(models_list)) - .route("/api/models/install", web::post().to(models_install)) - .route("/api/crypto/key", web::post().to(set_crypto_key)) - .route("/api/crypto/rotate", web::post().to(rotate_crypto_key)) - .route("/api/crypto/encrypt", web::post().to(api_encrypt)) - .route("/api/crypto/decrypt", web::post().to(api_decrypt)) - // Documentação Swagger UI e rotas v1 + // Documentação Swagger UI e rotas v1 (usuários) .service(routes::swagger_ui()) .configure(|cfg| routes::configure_v1(cfg, v2_state_data.clone())) .wrap(from_fn(middlewares::auth::jwt_auth)) @@ -568,18 +100,25 @@ async fn main() -> std::io::Result<()> { .service( actix_web::web::scope("/v2") .configure(|cfg| api::configure(cfg, v2_state_data.clone())) - .configure(|cfg| api::ws_config(cfg, v2_broker_data.clone())), + .configure(|cfg| api::ws_config(cfg, v2_broker_data.clone())) + .configure(|cfg| api::chat_ws_config(cfg, v2_state_data.clone())), ) }) - .bind(("127.0.0.1", 8080))?; + .bind((config.host.as_str(), config.port))?; let bind_elapsed = start.elapsed().as_millis(); - info(&format!("Bind concluído em {} ms", bind_elapsed)); + info(&format!( + "Bind concluído em {} ms em {}:{}", + bind_elapsed, config.host, config.port + )); sys.refresh_cpu(); sys.refresh_memory(); - info(&format!("Recursos após bind: CPU {:.2}% | Memória usada: {} / {} MB", + info(&format!( + "Recursos após bind: CPU {:.2}% | Memória usada: {} / {} MB", sys.global_cpu_info().cpu_usage(), - sys.used_memory() / 1024, sys.total_memory() / 1024)); + sys.used_memory() / 1024, + sys.total_memory() / 1024 + )); server.run().await } @@ -588,702 +127,3 @@ async fn main() -> std::io::Result<()> { async fn health() -> impl Responder { HttpResponse::Ok().json("ok") } - -// ===== WebSocket ===== -struct WsSession { - rx: broadcast::Receiver, -} - -impl actix::Actor for WsSession { - type Context = actix_ws::WebsocketContext; - - fn started(&mut self, ctx: &mut Self::Context) { - // Mensagem inicial de handshake - let hello = WsEvent { event: "hello".into(), operation_id: None, data: serde_json::json!({"msg":"connected"}) }; - ctx.text(serde_json::to_string(&hello).unwrap()); - - // Repassa eventos do broker para o cliente via mensagens do actor - let mut rx = self.rx.resubscribe(); - let addr = ctx.address(); - actix::spawn(async move { - loop { - match rx.recv().await { - Ok(ev) => addr.do_send(SendWs(ev)), - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, - } - } - }); - } -} - -#[derive(Message)] -#[rtype(result = "()")] -struct SendWs(WsEvent); - -impl Handler for WsSession { - type Result = (); - - fn handle(&mut self, msg: SendWs, ctx: &mut Self::Context) -> Self::Result { - let payload = serde_json::to_string(&msg.0).unwrap(); - ctx.text(payload); - } -} - -impl actix::StreamHandler> for WsSession { - fn handle(&mut self, msg: Result, ctx: &mut actix_ws::WebsocketContext) { - if let Ok(m) = msg { - match m { - actix_ws::Message::Ping(bytes) => ctx.pong(&bytes), - actix_ws::Message::Text(text) => { - // Mensagens de controle do cliente (ex: subscribe por operation_id) – atualmente broadcast geral - ctx.text(text); - } - actix_ws::Message::Close(reason) => { - ctx.close(reason); - } - _ => {} - } - } - } -} - -async fn ws_route(state: web::Data, req: actix_web::HttpRequest, stream: web::Payload) -> impl Responder { - let rx = state.tx.subscribe(); - let session = WsSession { rx }; - actix_ws::start(session, &req, stream) -} - -// ===== Modelos ===== -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ModelMeta { - name: String, - description: Option, - url: Option, - capabilities: Option>, - parameter_sizes: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ModelsResponse { - total: usize, - models: Vec, -} - -async fn models_list(q: web::Query>) -> impl Responder { - let path = std::path::Path::new("modelList.json"); - let content = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({"error":format!("falha ao ler modelList.json: {}", e)})), - }; - let v: serde_json::Value = match serde_json::from_str(&content) { - Ok(v) => v, - Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({"error":format!("JSON inválido: {}", e)})), - }; - let mut list: Vec = vec![]; - if let Some(models) = v.get("models").and_then(|m| m.as_array()) { - for m in models { - let meta = ModelMeta { - name: m.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(), - description: m.get("description").and_then(|x| x.as_str()).map(|s| s.to_string()), - url: m.get("url").and_then(|x| x.as_str()).map(|s| s.to_string()), - capabilities: m.get("capabilities").and_then(|x| x.as_array()).map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()), - parameter_sizes: m.get("parameter_sizes").and_then(|x| x.as_array()).map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()), - }; - list.push(meta); - } - } - if let Some(qs) = q.get("q") { - let needle = qs.to_lowercase(); - list.retain(|m| m.name.to_lowercase().contains(&needle) || m.description.as_ref().map(|d| d.to_lowercase().contains(&needle)).unwrap_or(false)); - } - HttpResponse::Ok().json(ModelsResponse { total: list.len(), models: list }) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct InstallModelRequest { - model: Option, - name: Option, - size: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct InstallModelResponse { - started: bool, - operation_id: Uuid, -} - -async fn models_install(state: web::Data, payload: web::Json) -> impl Responder { - // Validar nome - let model_name = if let Some(m) = &payload.model { m.clone() } else if let Some(n) = &payload.name { if let Some(s) = &payload.size { format!("{}:{}", n, s) } else { n.clone() } } else { String::new() }; - if model_name.is_empty() || !model_name.chars().all(|c| c.is_ascii_alphanumeric() || ":.-_".contains(c)) { - return HttpResponse::BadRequest().json(serde_json::json!({"error":"formato de modelo inválido"})); - } - let op_id = Uuid::new_v4(); - let tx = state.tx.clone(); - - actix_rt::spawn(async move { - // Publica início - let _ = tx.send(WsEvent { event: "pull_start".into(), operation_id: Some(op_id), data: serde_json::json!({"model":model_name}) }); - // Chama API do Ollama - let client = awc::Client::default(); - let url = "http://localhost:11434/api/pull"; - let req_body = serde_json::json!({"name": model_name}); - match client.post(url).send_json(&req_body).await { - Ok(mut resp) => { - let mut buf = String::new(); - // Fallback: lê corpo completo e processa linhas (menos granular que stream) - match resp.body().await { - Ok(bytes) => { - let s = String::from_utf8_lossy(&bytes).to_string(); - buf.push_str(&s); - for line in buf.lines() { - if line.trim().is_empty() { continue; } - if let Ok(val) = serde_json::from_str::(line) { - let _ = tx.send(WsEvent { event: "pull_progress".into(), operation_id: Some(op_id), data: val }); - } else { - let _ = tx.send(WsEvent { event: "pull_log".into(), operation_id: Some(op_id), data: serde_json::json!({"line":line}) }); - } - } - } - Err(e) => { - let _ = tx.send(WsEvent { event: "pull_error".into(), operation_id: Some(op_id), data: serde_json::json!({"error": format!("payload_error: {}", e)}) }); - } - } - let _ = tx.send(WsEvent { event: "pull_done".into(), operation_id: Some(op_id), data: serde_json::json!({}) }); - } - Err(e) => { - let _ = tx.send(WsEvent { event: "pull_error".into(), operation_id: Some(op_id), data: serde_json::json!({"error":e.to_string()}) }); - } - } - }); - - HttpResponse::Accepted().json(InstallModelResponse { started: true, operation_id: op_id }) -} - -// ===== Criptografia ===== -#[derive(Debug, Clone, Serialize, Deserialize)] -struct SetKeyRequest { key_hex: Option, key_base64: Option } - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct SimpleOk { ok: bool } - -async fn set_crypto_key(state: web::Data, payload: web::Json) -> impl Responder { - // Aceita primeira chave válida - let mut lock = state.crypto.key.lock().unwrap(); - if lock.is_some() { return HttpResponse::Conflict().json(serde_json::json!({"error":"chave já definida"})); } - let mut key = [0u8;32]; - let bytes = if let Some(hex) = &payload.key_hex { hex::decode(hex).ok() } else if let Some(b64) = &payload.key_base64 { BASE64.decode(b64).ok() } else { None }; - let bytes = match bytes { Some(b) => b, None => return HttpResponse::BadRequest().json(serde_json::json!({"error":"forneça key_hex ou key_base64"})) }; - if bytes.len() != 32 { return HttpResponse::BadRequest().json(serde_json::json!({"error":"chave deve ter 32 bytes"})); } - key.copy_from_slice(&bytes); - *lock = Some(key); - // Persiste no disco - if let Some(parent) = state.crypto.key_path.parent() { let _ = std::fs::create_dir_all(parent); } - if let Err(e) = std::fs::write(&state.crypto.key_path, &key) { return HttpResponse::InternalServerError().json(serde_json::json!({"error":format!("falha ao salvar chave: {}", e)})); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&state.crypto.key_path, std::fs::Permissions::from_mode(0o600)); - } - info(&format!("Chave salva em {}. Use a MESMA chave no frontend.", state.crypto.key_path.display())); - HttpResponse::Ok().json(SimpleOk { ok: true }) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RotateKeyRequest { key_hex: String } - -async fn rotate_crypto_key(state: web::Data, payload: web::Json) -> impl Responder { - let mut lock = state.crypto.key.lock().unwrap(); - let mut key = [0u8;32]; - let bytes = match hex::decode(&payload.key_hex) { Ok(b) => b, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error":"key_hex inválido"})) }; - if bytes.len() != 32 { return HttpResponse::BadRequest().json(serde_json::json!({"error":"chave deve ter 32 bytes"})); } - key.copy_from_slice(&bytes); - *lock = Some(key); - if let Err(e) = std::fs::write(&state.crypto.key_path, &key) { return HttpResponse::InternalServerError().json(serde_json::json!({"error":format!("falha ao salvar chave: {}", e)})); } - info("Chave rotacionada."); - HttpResponse::Ok().json(SimpleOk { ok: true }) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CryptoPayload { data_base64: String } - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CryptoResult { nonce_base64: String, ciphertext_base64: String } - -fn get_cipher(state: &AppState) -> Result { - let lock = state.crypto.key.lock().unwrap(); - if let Some(key_bytes) = *lock { - let key = Key::::from_slice(&key_bytes); - Ok(Aes256Gcm::new(key)) - } else { Err("chave não definida".into()) } -} - -async fn api_encrypt(state: web::Data, payload: web::Json) -> impl Responder { - let cipher = match get_cipher(&state) { Ok(c) => c, Err(e) => return HttpResponse::BadRequest().json(serde_json::json!({"error":e})) }; - let mut nonce_bytes = [0u8;12]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - let data = match BASE64.decode(&payload.data_base64) { Ok(d) => d, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error":"data_base64 inválido"})) }; - match cipher.encrypt(nonce, data.as_ref()) { - Ok(ct) => HttpResponse::Ok().json(CryptoResult { nonce_base64: BASE64.encode(nonce_bytes), ciphertext_base64: BASE64.encode(ct) }), - Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error":"falha ao criptografar"})), - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CryptoDecryptPayload { nonce_base64: String, ciphertext_base64: String } - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct DecryptResult { data_base64: String } - -async fn api_decrypt(state: web::Data, payload: web::Json) -> impl Responder { - let cipher = match get_cipher(&state) { Ok(c) => c, Err(e) => return HttpResponse::BadRequest().json(serde_json::json!({"error":e})) }; - let nonce_bytes = match BASE64.decode(&payload.nonce_base64) { Ok(b) => b, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error":"nonce_base64 inválido"})) }; - let ct = match BASE64.decode(&payload.ciphertext_base64) { Ok(b) => b, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error":"ciphertext_base64 inválido"})) }; - if nonce_bytes.len() != 12 { return HttpResponse::BadRequest().json(serde_json::json!({"error":"nonce deve ter 12 bytes"})); } - let nonce = Nonce::from_slice(&nonce_bytes); - match cipher.decrypt(nonce, ct.as_ref()) { - Ok(pt) => HttpResponse::Ok().json(DecryptResult { data_base64: BASE64.encode(pt) }), - Err(_) => HttpResponse::BadRequest().json(serde_json::json!({"error":"falha ao descriptografar ou tag inválida"})), - } -} - -// ===== Testes ===== -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn crypto_roundtrip() { - let key_path = std::path::PathBuf::from("./test.key"); - let state = AppState { tx: tokio::sync::broadcast::channel(8).0, crypto: CryptoState { key: std::sync::Arc::new(std::sync::Mutex::new(None)), key_path } }; - { - let mut lock = state.crypto.key.lock().unwrap(); - let mut k = [0u8;32]; - for i in 0..32 { k[i] = i as u8; } - *lock = Some(k); - } - let cipher = get_cipher(&state).unwrap(); - let nonce = Nonce::from_slice(&[0u8;12]); - let pt = b"hello"; - let ct = cipher.encrypt(nonce, pt.as_ref()).unwrap(); - let out = cipher.decrypt(nonce, ct.as_ref()).unwrap(); - assert_eq!(out, pt); - } - - #[test] - fn models_read_ok() { - let path = std::path::Path::new("modelList.json"); - let content = std::fs::read_to_string(path).expect("modelList.json presente"); - let v: serde_json::Value = serde_json::from_str(&content).expect("json válido"); - assert!(v.get("models").is_some()); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct InstallCheckResult { - installed: bool, - in_path: bool, - version: Option, - service_running: bool, - api_status: ApiStatus, - details: HashMap, - issues: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct InstallResult { - initiated: bool, - installed: bool, - service_started: bool, - final_api_status: ApiStatus, - details: HashMap, - issues: Vec, - timing_ms: HashMap, - aborted: Option, - manual_instructions: Option, -} - -async fn install_check() -> impl Responder { - let platform = detect_platform(); - let client = Client::builder().timeout(Duration::from_secs(3)).finish(); - let mut issues: Vec = vec![]; - let mut details: HashMap = HashMap::new(); - - // Binário em PATH e versão - let native = check_native(&platform, &mut issues); - let installed = native.is_some(); - let mut in_path = false; - let mut version: Option = None; - if let Some(n) = &native { - in_path = true; - if let Some(v) = n.details.get("version") { - version = Some(v.clone()); - } - // adiciona detalhes capturados - for (k, v) in &n.details { - details.insert(format!("native_{}", k), v.clone()); - } - } - - // Status da API - let (api_status, api_method) = check_api(&client, &mut issues).await; - if let Some(m) = api_method { - for (k, v) in m.details { - details.insert(format!("api_{}", k), v); - } - } - - // Integra detalhes de segurança/antivírus ao relatório - let av_details = check_antivirus(&platform, &mut issues); - for (k, v) in av_details { - details.insert(format!("security_{}", k), v); - } - - // Detalhes Docker (se aplicável) - if let Some(docker) = check_docker(&mut issues) { - for (k, v) in docker.details { - details.insert(format!("docker_{}", k), v); - } - } - - // Status do serviço por SO (faz sentido após checar API) - let service_running = match &platform { - Platform::Linux { .. } => { - match run_cmd("systemctl", &["is-active", "ollama"]) { - Ok(s) => s.trim() == "active", - Err(e) => { - issues.push(format!("systemctl is-active falhou: {}", e)); - false - } - } - } - Platform::Windows => { - let mut running = false; - match run_cmd( - "powershell", - &["-NoProfile", "-Command", "Get-Process -Name ollama -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { $_.Name }"], - ) { - Ok(p) => running = !p.trim().is_empty(), - Err(e) => issues.push(format!("Get-Process ollama falhou: {}", e)), - } - running || !matches!(api_status, ApiStatus::Unreachable) - } - Platform::Macos => { - // No macOS consideramos serviço rodando se a API está acessível - !matches!(api_status, ApiStatus::Unreachable) - } - Platform::Unknown(_) => !matches!(api_status, ApiStatus::Unreachable), - }; - - add_doc_fallbacks(&platform, &mut details); - - let result = InstallCheckResult { - installed, - in_path, - version, - service_running, - api_status, - details, - issues, - }; - - HttpResponse::Ok().json(result) -} - -async fn install_ollama() -> impl Responder { - let platform = detect_platform(); - let client = Client::builder().timeout(Duration::from_secs(5)).finish(); - let mut issues: Vec = vec![]; - let mut details: HashMap = HashMap::new(); - let mut timing_ms: HashMap = HashMap::new(); - let deadline = Instant::now() + Duration::from_secs(30); - - // snapshot recursos - let mut sys = System::new(); - sys.refresh_cpu(); - sys.refresh_memory(); - details.insert("resources_start_cpu".into(), format!("{:.2}", sys.global_cpu_info().cpu_usage())); - details.insert("resources_start_mem_used_mb".into(), format!("{}", sys.used_memory() / 1024)); - details.insert("resources_start_mem_total_mb".into(), format!("{}", sys.total_memory() / 1024)); - - // Já instalado? - let t_check_native = Instant::now(); - let native = check_native(&platform, &mut issues); - timing_ms.insert("check_native".into(), t_check_native.elapsed().as_millis()); - let mut installed = native.is_some(); - if installed { - info("Ollama já instalado; garantindo inicialização do serviço..."); - } else { - info("Ollama não instalado; iniciando instalação automática..."); - let t_install = Instant::now(); - let install_res = match &platform { - Platform::Linux { .. } => linux_install(&mut details, &mut issues), - Platform::Windows => windows_install(&mut details, &mut issues), - Platform::Macos => macos_install(&mut details, &mut issues), - Platform::Unknown(_) => { - issues.push("Plataforma desconhecida; instalação não suportada".into()); - Err("unsupported_platform".into()) - } - }; - match install_res { - Ok(_) => { - installed = true; - info("Instalação concluída (ou instruções fornecidas)."); - timing_ms.insert("install".into(), t_install.elapsed().as_millis()); - } - Err(e) => { - error(&format!("Instalação falhou: {}", e)); - // rollback básico - rollback(&platform, &mut details, &mut issues); - let manual = manual_instructions(&platform); - return HttpResponse::InternalServerError().json(InstallResult { - initiated: true, - installed: false, - service_started: false, - final_api_status: ApiStatus::Unreachable, - details, - issues, - timing_ms, - aborted: Some("install_failed".into()), - manual_instructions: Some(manual), - }); - } - } - } - - // Iniciar serviço e checar API - if Instant::now() > deadline { - warn("Tempo total de inicialização excedeu 30s; abortando."); - let manual = manual_instructions(&platform); - timing_ms.insert("total".into(), 30_000); - return HttpResponse::RequestTimeout().json(InstallResult { - initiated: true, - installed, - service_started: false, - final_api_status: ApiStatus::Unreachable, - details, - issues, - timing_ms, - aborted: Some("timeout_exceeded".into()), - manual_instructions: Some(manual), - }); - } - - let t_service = Instant::now(); - let service_started = ensure_ollama_running(&client, &platform, &mut issues).await; - timing_ms.insert("ensure_service".into(), t_service.elapsed().as_millis()); - - let t_api = Instant::now(); - let (final_api_status, api_method) = check_api(&client, &mut issues).await; - timing_ms.insert("check_api_final".into(), t_api.elapsed().as_millis()); - if let Some(m) = api_method { - for (k, v) in m.details { - details.insert(format!("api_{}", k), v); - } - } - - add_doc_fallbacks(&platform, &mut details); - - // recursos finais - sys.refresh_cpu(); - sys.refresh_memory(); - details.insert("resources_end_cpu".into(), format!("{:.2}", sys.global_cpu_info().cpu_usage())); - details.insert("resources_end_mem_used_mb".into(), format!("{}", sys.used_memory() / 1024)); - details.insert("resources_end_mem_total_mb".into(), format!("{}", sys.total_memory() / 1024)); - let total_elapsed = 30_000.min(deadline.duration_since(Instant::now()).as_millis() as u128); - timing_ms.insert("total".into(), total_elapsed); - - HttpResponse::Ok().json(InstallResult { - initiated: true, - installed, - service_started, - final_api_status, - details, - issues, - timing_ms, - aborted: None, - manual_instructions: None, - }) -} - -fn rollback(platform: &Platform, details: &mut HashMap, issues: &mut Vec) { - match platform { - Platform::Linux { .. } => { - info("Rollback: tentando parar e desabilitar serviço ollama..."); - if let Err(e) = run_cmd("systemctl", &["stop", "ollama"]) { - issues.push(format!("rollback stop falhou: {}", e)); - } - if let Err(e) = run_cmd("systemctl", &["disable", "ollama"]) { - issues.push(format!("rollback disable falhou: {}", e)); - } - details.insert("rollback_linux".into(), "service_stop_disable".into()); - } - Platform::Windows => { - info("Rollback: nada destrutivo executado no Windows (installer GUI)."); - details.insert("rollback_windows".into(), "no_op".into()); - } - Platform::Macos => { - info("Rollback: nada destrutivo executado no macOS."); - details.insert("rollback_macos".into(), "no_op".into()); - } - Platform::Unknown(_) => {} - } -} - -fn linux_install(details: &mut HashMap, issues: &mut Vec) -> Result<(), String> { - info("Linux: executando script oficial de instalação do Ollama..."); - match run_cmd("sh", &["-lc", "curl -fsSL https://ollama.com/install.sh | sh"]) { - Ok(out) => { - details.insert("linux_install_output".into(), out); - } - Err(e) => { - issues.push(format!("install.sh falhou: {}", e)); - return Err("install_script_failed".into()); - } - } - // Habilitar e iniciar serviço - if let Err(e) = run_cmd("systemctl", &["daemon-reload"]) { - issues.push(format!("daemon-reload falhou: {}", e)); - } - if let Err(e) = run_cmd("systemctl", &["enable", "ollama"]) { - issues.push(format!("enable falhou: {}", e)); - } - match run_cmd("systemctl", &["start", "ollama"]) { - Ok(_) => info("Serviço ollama iniciado."), - Err(e) => { - issues.push(format!("start falhou: {}", e)); - } - } - Ok(()) -} - -fn windows_install(details: &mut HashMap, issues: &mut Vec) -> Result<(), String> { - info("Windows: tentando baixar e executar o instalador do Ollama..."); - // Tenta baixar OllamaSetup.exe - let ps_download = r#" - $url = 'https://ollama.com/download/OllamaSetup.exe'; - $out = "$env:TEMP\\OllamaSetup.exe"; - try { - Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing; - Write-Output $out; - } catch { - Write-Error $_.Exception.Message; - exit 1; - } - "#; - let dl = run_cmd("powershell", &["-NoProfile", "-Command", ps_download]); - let installer_path = match dl { - Ok(p) => { - let path = p.trim().lines().last().unwrap_or("").trim().to_string(); - details.insert("windows_installer_path".into(), path.clone()); - path - } - Err(e) => { - issues.push(format!("download installer falhou: {}", e)); - // Fallback: tentar baixar zip standalone - let ps_zip = r#" - $url = 'https://ollama.com/download/ollama-windows-amd64.zip'; - $out = "$env:TEMP\\ollama-windows-amd64.zip"; - try { - Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing; - Write-Output $out; - } catch { - Write-Error $_.Exception.Message; - exit 1; - } - "#; - match run_cmd("powershell", &["-NoProfile", "-Command", ps_zip]) { - Ok(zip_path_out) => { - let zip_path = zip_path_out.trim().lines().last().unwrap_or("").trim().to_string(); - details.insert("windows_zip_path".into(), zip_path.clone()); - // Expand-Archive para pasta padrão - let ps_expand = format!( - "Expand-Archive -LiteralPath '{}' -DestinationPath '$env:LOCALAPPDATA\\Programs\\Ollama' -Force", - zip_path - ); - if let Err(e2) = run_cmd("powershell", &["-NoProfile", "-Command", &ps_expand]) { - issues.push(format!("Expand-Archive falhou: {}", e2)); - return Err("zip_expand_failed".into()); - } - // OK: instalado via zip - return Ok(()); - } - Err(e2) => { - issues.push(format!("download zip falhou: {}", e2)); - return Err("installer_download_failed".into()); - } - } - } - }; - - // Executa instalador (pode abrir GUI; tentamos aguardar término) - let ps_run = format!( - "Start-Process -FilePath '{}' -ArgumentList '/SILENT' -Wait", - installer_path - ); - match run_cmd("powershell", &["-NoProfile", "-Command", &ps_run]) { - Ok(_) => { - info("Instalador executado."); - Ok(()) - } - Err(e) => { - issues.push(format!("exec installer falhou: {}", e)); - Err("installer_exec_failed".into()) - } - } -} - -fn macos_install(details: &mut HashMap, issues: &mut Vec) -> Result<(), String> { - info("macOS: tentando instalar via Homebrew (se disponível)..."); - match run_cmd("brew", &["--version"]) { - Ok(_) => { - match run_cmd("brew", &["install", "ollama"]) { - Ok(out) => { - details.insert("brew_install_output".into(), out); - } - Err(e) => { - issues.push(format!("brew install falhou: {}", e)); - return Err("brew_install_failed".into()); - } - } - Ok(()) - } - Err(_) => { - issues.push("Homebrew não encontrado; favor instalar manualmente via https://ollama.com/download/".into()); - Err("no_brew".into()) - } - } -} - -fn manual_instructions(platform: &Platform) -> String { - match platform { - Platform::Linux { .. } => { - "Instalação manual: execute 'curl -fsSL https://ollama.com/install.sh | sh', depois 'sudo systemctl enable --now ollama'. Verifique com 'systemctl status ollama' e acesse http://localhost:11434".into() - } - Platform::Windows => { - "Instalação manual: baixe 'OllamaSetup.exe' em https://ollama.com/download/ e execute. Alternativa: baixe 'ollama-windows-amd64.zip', extraia em %LOCALAPPDATA%\\Programs\\Ollama e rode 'ollama serve'. Verifique http://localhost:11434".into() - } - Platform::Macos => { - "Instalação manual: se tiver Homebrew, 'brew install ollama'. Alternativa: baixe o app em https://ollama.com/download/ e inicie. Verifique http://localhost:11434".into() - } - Platform::Unknown(_) => { - "Consulte https://ollama.com/download/ para instruções do seu sistema. Verifique a API em http://localhost:11434".into() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use actix_web::{test, App}; - - #[actix_rt::test] - async fn test_install_check_endpoint() { - let app = test::init_service(App::new().route("/ollama/install-check", web::get().to(install_check))).await; - let req = test::TestRequest::get().uri("/ollama/install-check").to_request(); - let resp = test::call_and_read_body(&app, req).await; - assert!(!resp.is_empty()); - } -} diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index 11d8625..a5c022f 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -7,6 +7,30 @@ use serde::Deserialize; use crate::state::AppContext; +/// Adiciona headers CORS a uma resposta HTTP +fn add_cors_headers(mut res: HttpResponse) -> HttpResponse { + let allowed_origin = std::env::var("ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + + res.headers_mut().insert( + header::HeaderName::from_static("access-control-allow-origin"), + header::HeaderValue::from_str(&allowed_origin).unwrap_or_else(|_| header::HeaderValue::from_static("*")) + ); + res.headers_mut().insert( + header::HeaderName::from_static("access-control-allow-credentials"), + header::HeaderValue::from_static("true") + ); + res.headers_mut().insert( + header::HeaderName::from_static("access-control-allow-methods"), + header::HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS, PATCH") + ); + res.headers_mut().insert( + header::HeaderName::from_static("access-control-allow-headers"), + header::HeaderValue::from_static("Content-Type, Authorization, Accept, Accept-Language, Accept-Encoding") + ); + res +} + #[derive(Debug, Deserialize)] struct Claims { sub: String, exp: usize, role: Option } @@ -14,15 +38,40 @@ pub async fn jwt_auth(req: ServiceRequest, next: Next) -> Result>().cloned(); - let Some(state) = ctx else { return Ok(req.into_response(HttpResponse::InternalServerError().finish())); }; + let Some(state) = ctx else { + let res = add_cors_headers(HttpResponse::InternalServerError().finish()); + return Ok(req.into_response(res)); + }; let Some(hdr) = req.headers().get(header::AUTHORIZATION) else { - return Ok(req.into_response(HttpResponse::Unauthorized().finish())); + let allowed_origin = std::env::var("ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + let res = HttpResponse::Unauthorized() + .insert_header(("access-control-allow-origin", allowed_origin)) + .insert_header(("access-control-allow-credentials", "true")) + .insert_header(("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")) + .insert_header(("access-control-allow-headers", "Content-Type, Authorization, Accept, Accept-Language, Accept-Encoding")) + .finish(); + return Ok(req.into_response(res)); }; let token = hdr.to_str().unwrap_or(""); let token = token.strip_prefix("Bearer ").unwrap_or(token); @@ -33,6 +82,18 @@ where let res = next.call(req).await?; Ok(res.map_into_boxed_body()) } - Err(_) => Ok(req.into_response(HttpResponse::Unauthorized().finish())), + Err(e) => { + // Log do erro para debug (pode remover depois) + eprintln!("[JWT Auth] Erro ao decodificar token: {:?}", e); + let allowed_origin = std::env::var("ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + let res = HttpResponse::Unauthorized() + .insert_header(("access-control-allow-origin", allowed_origin)) + .insert_header(("access-control-allow-credentials", "true")) + .insert_header(("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")) + .insert_header(("access-control-allow-headers", "Content-Type, Authorization, Accept, Accept-Language, Accept-Encoding")) + .finish(); + Ok(req.into_response(res)) + } } } \ No newline at end of file diff --git a/src/middlewares/cors.rs b/src/middlewares/cors.rs new file mode 100644 index 0000000..117f2e5 --- /dev/null +++ b/src/middlewares/cors.rs @@ -0,0 +1,26 @@ +use actix_cors::Cors; +use actix_web::http::header; + +pub fn configure_cors() -> Cors { + // Permitir origem do frontend em desenvolvimento + let allowed_origin = std::env::var("ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + + Cors::default() + .allowed_origin(&allowed_origin) + .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]) + .allowed_headers(vec![ + header::CONTENT_TYPE, + header::AUTHORIZATION, + header::ACCEPT, + header::ACCEPT_LANGUAGE, + header::ACCEPT_ENCODING, + ]) + .supports_credentials() + .max_age(3600) + .expose_headers(vec![ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + ]) +} + diff --git a/src/middlewares/logging.rs b/src/middlewares/logging.rs new file mode 100644 index 0000000..9cf6b89 --- /dev/null +++ b/src/middlewares/logging.rs @@ -0,0 +1,145 @@ +use actix_web::{ + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, +}; +use std::{ + future::{ready, Ready}, + pin::Pin, + rc::Rc, + time::Instant, +}; +use crate::utils::log; + +pub struct LoggingMiddleware; + +impl Transform for LoggingMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = LoggingMiddlewareService; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(LoggingMiddlewareService { + service: Rc::new(service), + })) + } +} + +pub struct LoggingMiddlewareService { + service: Rc, +} + +impl Service for LoggingMiddlewareService +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = Pin>>>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let start = Instant::now(); + let method = req.method().clone(); + let path = req.path().to_string(); + let query_string = req.query_string(); + let full_path = if query_string.is_empty() { + path.clone() + } else { + format!("{}?{}", path, query_string) + }; + + // Obter IP do cliente + let peer_addr = req.peer_addr() + .map(|addr| addr.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + // Obter User-Agent + let user_agent = req.headers() + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + + // Obter Content-Type + let content_type = req.headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + + // Obter Content-Length + let content_length = req.headers() + .get("content-length") + .and_then(|h| h.to_str().ok()) + .unwrap_or("0") + .to_string(); + + // Obter Authorization header (mascarado) + let auth_header = req.headers() + .get("authorization") + .and_then(|h| h.to_str().ok()) + .map(|h| { + if h.starts_with("Bearer ") { + let token = &h[7..]; + if token.len() > 10 { + format!("Bearer {}...", &token[..10]) + } else { + "Bearer [token]".to_string() + } + } else { + "[present]".to_string() + } + }) + .unwrap_or_else(|| "none".to_string()); + + let service = self.service.clone(); + Box::pin(async move { + let res = service.call(req).await?; + let elapsed_ms = start.elapsed().as_millis(); + let status = res.status(); + let status_code = status.as_u16(); + + // Obter tamanho da resposta (se disponível) + let response_size = res.response().headers() + .get("content-length") + .and_then(|h| h.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + + // Log formatado completo + let log_message = format!( + "API ACCESS | {} {} | Status: {} | IP: {} | Time: {}ms | UA: {} | CT: {} | CL: {} | Auth: {} | RespSize: {}", + method, + full_path, + status_code, + peer_addr, + elapsed_ms, + user_agent, + content_type, + content_length, + auth_header, + response_size + ); + + // Log de erro para status >= 400 + if status_code >= 400 { + log::error(&log_message); + } else { + log::info(&log_message); + } + + Ok(res) + }) + } +} + diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs index 3ca2ddf..4f5dca9 100644 --- a/src/middlewares/mod.rs +++ b/src/middlewares/mod.rs @@ -1,2 +1,4 @@ pub mod auth; -pub mod error; \ No newline at end of file +pub mod cors; +pub mod error; +pub mod logging; \ No newline at end of file diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 19dc59b..f6f8f7b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -15,20 +15,46 @@ use crate::state::AppContext; crate::controllers::user_controller::create_user, crate::controllers::user_controller::update_user, crate::controllers::user_controller::delete_user, + crate::api::health, + crate::api::models_list, + crate::api::models_install, + crate::api::ollama_check, + crate::api::ollama_install, + crate::api::crypto_set_key, + crate::api::crypto_rotate_key, + crate::api::crypto_encrypt, + crate::api::crypto_decrypt, ), components(schemas( crate::models::user::User, crate::models::user::CreateUserRequest, crate::models::user::UpdateUserRequest, crate::middlewares::error::ApiErrorBody, + crate::core::models::ModelInfo, + crate::core::models::InstallCheckResult, + crate::core::models::InstallStarted, + crate::core::models::ApiStatus, + crate::api::HealthResponse, + crate::api::InstallReq, + crate::api::ModelInstallStarted, + crate::api::OllamaInstallReq, + crate::api::OllamaInstallStarted, + crate::api::SetKeyReq, + crate::api::SimpleOk, + crate::api::RotateKeyResponse, + crate::api::CryptReq, + crate::api::EncryptResponse, + crate::api::DecryptResponse, )), tags( (name = "users", description = "Gerenciamento de usuários"), + (name = "v2", description = "APIs v2: saúde, modelos, ollama, criptografia"), ) )] pub struct ApiDoc; pub fn configure_v1(cfg: &mut web::ServiceConfig, _state: web::Data) { + cfg.app_data(_state.clone()); let repo = InMemoryUserRepository::new(); let svc = UserService::new(repo); cfg.app_data(web::Data::new(svc)); diff --git a/src/services/install/linux.rs b/src/services/install/linux.rs new file mode 100644 index 0000000..2ad838b --- /dev/null +++ b/src/services/install/linux.rs @@ -0,0 +1,37 @@ +use super::InstallStrategy; +use crate::ws::{WsEvent, WsEventKind}; +use std::collections::HashMap; +use std::process::Command; +use tokio::sync::broadcast; + +pub struct LinuxInstallStrategy; + +impl InstallStrategy for LinuxInstallStrategy { + fn install( + &self, + details: &mut HashMap, + issues: &mut Vec, + broker: &broadcast::Sender, + ) -> Result<(), String> { + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Iniciando instalação no Linux...".into(), + }); + + // Script oficial de instalação + let install_cmd = "curl -fsSL https://ollama.com/install.sh | sh"; + + let status = Command::new("sh") + .arg("-c") + .arg(install_cmd) + .status() + .map_err(|e| format!("Falha ao executar script de instalação: {}", e))?; + + if !status.success() { + return Err("Script de instalação falhou".into()); + } + + details.insert("method".to_string(), "curl_script".to_string()); + Ok(()) + } +} diff --git a/src/services/install/macos.rs b/src/services/install/macos.rs new file mode 100644 index 0000000..0601f42 --- /dev/null +++ b/src/services/install/macos.rs @@ -0,0 +1,56 @@ +use super::InstallStrategy; +use crate::ws::{WsEvent, WsEventKind}; +use std::collections::HashMap; +use std::process::Command; +use tokio::sync::broadcast; + +pub struct MacOsInstallStrategy; + +impl InstallStrategy for MacOsInstallStrategy { + fn install( + &self, + details: &mut HashMap, + issues: &mut Vec, + broker: &broadcast::Sender, + ) -> Result<(), String> { + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Iniciando instalação no macOS...".into(), + }); + + // Download do zip + let url = "https://ollama.com/download/Ollama-darwin.zip"; + let zip_path = "/tmp/Ollama-darwin.zip"; + + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Baixando Ollama...".into(), + }); + + let status = Command::new("curl") + .args(&["-L", url, "-o", zip_path]) + .status() + .map_err(|e| format!("Falha ao baixar: {}", e))?; + + if !status.success() { + return Err("Falha no download".into()); + } + + // Unzip + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Extraindo...".into(), + }); + let unzip_status = Command::new("unzip") + .args(&["-o", zip_path, "-d", "/Applications"]) + .status() + .map_err(|e| format!("Falha ao extrair: {}", e))?; + + if !unzip_status.success() { + return Err("Falha na extração".into()); + } + + details.insert("method".to_string(), "zip_install".to_string()); + Ok(()) + } +} diff --git a/src/services/install/mod.rs b/src/services/install/mod.rs new file mode 100644 index 0000000..834ffce --- /dev/null +++ b/src/services/install/mod.rs @@ -0,0 +1,16 @@ +use crate::ws::WsEvent; +use std::collections::HashMap; +use tokio::sync::broadcast; + +pub mod linux; +pub mod macos; +pub mod windows; + +pub trait InstallStrategy: Send + Sync { + fn install( + &self, + details: &mut HashMap, + issues: &mut Vec, + broker: &broadcast::Sender, + ) -> Result<(), String>; +} diff --git a/src/services/install/windows.rs b/src/services/install/windows.rs new file mode 100644 index 0000000..d001e0b --- /dev/null +++ b/src/services/install/windows.rs @@ -0,0 +1,57 @@ +use super::InstallStrategy; +use crate::ws::{WsEvent, WsEventKind}; +use std::collections::HashMap; +use std::process::Command; +use tokio::sync::broadcast; + +pub struct WindowsInstallStrategy; + +impl InstallStrategy for WindowsInstallStrategy { + fn install( + &self, + details: &mut HashMap, + issues: &mut Vec, + broker: &broadcast::Sender, + ) -> Result<(), String> { + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Iniciando instalação no Windows...".into(), + }); + + // 1. Baixar instalador + let installer_url = "https://ollama.com/download/OllamaSetup.exe"; + let installer_path = std::env::temp_dir().join("OllamaSetup.exe"); + + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Baixando instalador...".into(), + }); + + let status = Command::new("curl") + .args(&["-L", installer_url, "-o", installer_path.to_str().unwrap()]) + .status() + .map_err(|e| format!("Falha ao baixar instalador: {}", e))?; + + if !status.success() { + return Err("Falha no download do instalador".into()); + } + + // 2. Executar instalador silencioso + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Executando instalador...".into(), + }); + + let install_status = Command::new(installer_path) + .arg("/silent") + .status() + .map_err(|e| format!("Falha ao executar instalador: {}", e))?; + + if !install_status.success() { + return Err("Instalador falhou".into()); + } + + details.insert("method".to_string(), "native_installer".to_string()); + Ok(()) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 84ac9b5..387bfc6 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,6 @@ -pub mod system; +pub mod install; pub mod ollama; -pub mod user_service; \ No newline at end of file +pub mod ollama_install; +pub mod system; +pub mod user_service; +pub mod windows_permissions; diff --git a/src/services/ollama_install.rs b/src/services/ollama_install.rs new file mode 100644 index 0000000..922c87d --- /dev/null +++ b/src/services/ollama_install.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use actix_rt::time::sleep; +use awc::Client; +use uuid::Uuid; + +use super::install::{ + linux::LinuxInstallStrategy, macos::MacOsInstallStrategy, windows::WindowsInstallStrategy, + InstallStrategy, +}; +use crate::core::models::{ApiStatus, InstallCheckResult, InstallResult}; +use crate::services::system::Platform; +use crate::ws::{WsEvent, WsEventKind}; + +pub struct OllamaInstallService; + +impl OllamaInstallService { + pub async fn check_installation(base_url: &str) -> Result { + let mut issues = Vec::new(); + let platform = Platform::detect(); + let client = Client::default(); + + // 1. Check if API is running + let api_status = check_api(&client, base_url, &mut issues).await; + + // 2. Check native installation + let (in_path, version) = check_native(&platform, &mut issues); + + // 3. Check service running (simplified) + let service_running = api_status_is_running(&api_status); + + let installed = in_path || service_running; + + Ok(InstallCheckResult { + installed, + in_path, + version, + service_running, + api_status, + details: HashMap::new(), + issues, + }) + } + + pub async fn install( + base_url: &str, + broker: &tokio::sync::broadcast::Sender, + _operation_id: Uuid, + ) -> Result { + let platform = Platform::detect(); + let mut details = HashMap::new(); + let mut issues = Vec::new(); + + let strategy: Box = match platform { + Platform::Windows => Box::new(WindowsInstallStrategy), + Platform::Linux { .. } => Box::new(LinuxInstallStrategy), + Platform::Macos => Box::new(MacOsInstallStrategy), + Platform::Unknown(os) => { + return Err(format!("Sistema operacional não suportado: {}", os)) + } + }; + + // Execute installation strategy + strategy.install(&mut details, &mut issues, broker)?; + + // Verify installation + let _ = broker.send(WsEvent { + kind: WsEventKind::Log, + message: "Verificando instalação...".into(), + }); + let success = + ensure_ollama_running(&Client::default(), base_url, &mut issues, broker).await; + + let final_api_status = if success { + ApiStatus::Reachable + } else { + ApiStatus::Unreachable + }; + + if success { + Ok(InstallResult { + initiated: true, + installed: true, + service_started: true, + final_api_status, + details, + issues, + timing_ms: HashMap::new(), + aborted: None, + manual_instructions: None, + }) + } else { + Err("Ollama não respondeu após a instalação".to_string()) + } + } +} + +// Helper functions + +fn check_native(platform: &Platform, issues: &mut Vec) -> (bool, Option) { + let cmd = match platform { + Platform::Windows => "ollama.exe", + _ => "ollama", + }; + + match std::process::Command::new(cmd).arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (true, Some(version)) + } + _ => { + issues.push("Ollama nativo não encontrado no PATH".to_string()); + (false, None) + } + } +} + +async fn check_api(client: &Client, base_url: &str, issues: &mut Vec) -> ApiStatus { + match client.get(base_url).send().await { + Ok(resp) => { + if resp.status().is_success() { + ApiStatus::Reachable + } else { + issues.push(format!("API respondeu com erro: {}", resp.status())); + ApiStatus::Degraded + } + } + Err(e) => { + issues.push(format!("API inacessível: {}", e)); + ApiStatus::Unreachable + } + } +} + +fn api_status_is_running(status: &ApiStatus) -> bool { + matches!(status, ApiStatus::Reachable | ApiStatus::Degraded) +} + +async fn ensure_ollama_running( + client: &Client, + base_url: &str, + issues: &mut Vec, + broker: &tokio::sync::broadcast::Sender, +) -> bool { + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(60) { + if let Ok(resp) = client.get(base_url).send().await { + if resp.status().is_success() { + let _ = broker.send(WsEvent { + kind: WsEventKind::Done, + message: "Ollama detectado online!".into(), + }); + return true; + } + } + sleep(Duration::from_secs(2)).await; + } + issues.push("Timeout aguardando Ollama iniciar".to_string()); + false +} diff --git a/src/services/system.rs b/src/services/system.rs index 789185f..417f466 100644 --- a/src/services/system.rs +++ b/src/services/system.rs @@ -1,27 +1,68 @@ +use std::env; +use std::fs; +use std::path::PathBuf; use std::process::Command; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum Platform { + Linux { distro: Option }, Windows, - Linux, - MacOS, + Macos, + Unknown(String), } -pub fn detect_platform() -> Platform { - if cfg!(target_os = "windows") { - Platform::Windows - } else if cfg!(target_os = "macos") { - Platform::MacOS - } else { - Platform::Linux +impl Platform { + pub fn detect() -> Self { + let os = env::consts::OS; + match os { + "linux" => Platform::Linux { distro: Self::detect_linux_distro() }, + "windows" => Platform::Windows, + "macos" => Platform::Macos, + other => Platform::Unknown(other.to_string()), + } + } + + fn detect_linux_distro() -> Option { + let content = fs::read_to_string("/etc/os-release").ok()?; + for line in content.lines() { + if let Some(stripped) = line.strip_prefix("ID=") { + return Some(stripped.trim_matches('\"').to_string()); + } + } + None } } pub fn run_cmd(cmd: &str, args: &[&str]) -> Result { - let output = Command::new(cmd).args(args).output().map_err(|e| e.to_string())?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - Err(String::from_utf8_lossy(&output.stderr).to_string()) + let output = Command::new(cmd).args(args).output(); + match output { + Ok(out) => { + if out.status.success() { + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + Ok(stdout) + } else { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + Err(format!("command failed: {} {:?} -> {}", cmd, args, stderr)) + } + } + Err(e) => Err(format!("command error: {} {:?} -> {}", cmd, args, e)), } +} + +pub fn path_exists(path: &PathBuf) -> bool { + fs::metadata(path).is_ok() +} + +pub fn info(msg: &str) { + println!("[INFO] {}", msg); +} + +pub fn warn(msg: &str) { + println!("[AVISO] {}", msg); +} + +pub fn error(msg: &str) { + eprintln!("[ERRO] {}", msg); } \ No newline at end of file diff --git a/src/services/windows_permissions.rs b/src/services/windows_permissions.rs new file mode 100644 index 0000000..6831d3b --- /dev/null +++ b/src/services/windows_permissions.rs @@ -0,0 +1,261 @@ +use std::env; +use std::path::PathBuf; +use crate::services::system; + +#[derive(Debug, Clone)] +pub struct PermissionStatus { + pub admin: bool, + pub firewall_configured: bool, + pub defender_exception: bool, + pub network_ok: bool, + pub issues: Vec, +} + +pub struct WindowsPermissions; + +impl WindowsPermissions { + /// Verifica se o processo está rodando com privilégios de administrador + pub fn check_admin() -> bool { + let ps_cmd = r#"[Security.Principal.WindowsPrincipal]::Current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#; + match system::run_cmd("powershell", &["-NoProfile", "-Command", ps_cmd]) { + Ok(output) => { + let trimmed = output.trim().to_lowercase(); + trimmed == "true" + } + Err(_) => false, + } + } + + /// Verifica se uma regra de firewall já existe + fn firewall_rule_exists(display_name: &str) -> bool { + let ps_cmd = format!( + r#"Get-NetFirewallRule -DisplayName '{}' -ErrorAction SilentlyContinue | Select-Object -First 1"#, + display_name + ); + match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) { + Ok(output) => !output.trim().is_empty(), + Err(_) => false, + } + } + + /// Adiciona regra no firewall do Windows para uma porta específica + pub fn request_firewall_rule(port: u16, name: &str) -> Result<(), String> { + let display_name = format!("OllaHubBackend {}", name); + + // Verificar se já existe + if Self::firewall_rule_exists(&display_name) { + system::info(&format!("Regra de firewall '{}' já existe", display_name)); + return Ok(()); + } + + // Verificar se tem privilégios de admin + if !Self::check_admin() { + return Err(format!( + "Privilégios de administrador necessários para adicionar regra de firewall na porta {}. Execute como Administrador.", + port + )); + } + + // Criar regra de firewall + let ps_cmd = format!( + r#"New-NetFirewallRule -DisplayName '{}' -Direction Inbound -LocalPort {} -Protocol TCP -Action Allow -Profile Domain,Private,Public"#, + display_name, port + ); + + match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) { + Ok(_) => { + system::info(&format!("Regra de firewall criada para porta {} ({})", port, name)); + Ok(()) + } + Err(e) => Err(format!("Falha ao criar regra de firewall: {}", e)), + } + } + + /// Obtém o caminho do executável atual + fn get_executable_path() -> Result { + env::current_exe().map_err(|e| format!("Não foi possível obter caminho do executável: {}", e)) + } + + /// Verifica se exceção já existe no Windows Defender + fn defender_exception_exists(path: &str) -> bool { + let ps_cmd = format!( + r#"Get-MpPreference | Select-Object -ExpandProperty ExclusionPath | Where-Object {{ $_ -eq '{}' }}"#, + path.replace('\\', "\\\\") + ); + match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) { + Ok(output) => !output.trim().is_empty(), + Err(_) => false, + } + } + + /// Adiciona exceção no Windows Defender para o executável + pub fn request_defender_exception(exe_path: &str) -> Result<(), String> { + // Verificar se já existe + if Self::defender_exception_exists(exe_path) { + system::info(&format!("Exceção do Windows Defender já existe para: {}", exe_path)); + return Ok(()); + } + + // Verificar se tem privilégios de admin + if !Self::check_admin() { + return Err(format!( + "Privilégios de administrador necessários para adicionar exceção no Windows Defender. Execute como Administrador." + )); + } + + // Adicionar exceção de caminho + let ps_cmd = format!( + r#"Add-MpPreference -ExclusionPath '{}'"#, + exe_path.replace('\'', "''") + ); + + match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) { + Ok(_) => { + system::info(&format!("Exceção do Windows Defender adicionada para: {}", exe_path)); + Ok(()) + } + Err(e) => Err(format!("Falha ao adicionar exceção no Windows Defender: {}", e)), + } + } + + /// Verifica permissões de rede (básico) + pub fn request_network_permissions() -> Result<(), String> { + // Verificação básica: tentar criar um socket de teste + // Se falhar, pode indicar problemas de permissão + // Por enquanto, apenas retorna Ok se estiver no Windows + if cfg!(target_os = "windows") { + Ok(()) + } else { + Err("Verificação de permissões de rede apenas disponível no Windows".to_string()) + } + } + + /// Configura todas as permissões necessárias + pub fn setup_all_permissions() -> Result { + system::info("Verificando e configurando permissões do Windows..."); + + let mut status = PermissionStatus { + admin: Self::check_admin(), + firewall_configured: false, + defender_exception: false, + network_ok: false, + issues: Vec::new(), + }; + + // Log status de admin + system::info("[1/4] Verificando privilégios de administrador..."); + if status.admin { + system::info(" ✓ Executando com privilégios de administrador"); + } else { + system::warn(" ⚠ Não está executando como administrador. Algumas configurações podem falhar."); + status.issues.push("Execução sem privilégios de administrador".to_string()); + } + + // Configurar firewall para porta 8080 (API) + system::info("[2/4] Configurando regras de firewall..."); + system::info(" → Porta 8080 (API HTTP)..."); + match Self::request_firewall_rule(8080, "API") { + Ok(_) => { + status.firewall_configured = true; + system::info(" ✓ Regra de firewall criada para porta 8080"); + } + Err(e) => { + status.issues.push(format!("Firewall porta 8080: {}", e)); + system::warn(&format!(" ✗ Falha: {}", e)); + } + } + + // Configurar firewall para porta 11434 (Ollama) + system::info(" → Porta 11434 (Ollama)..."); + match Self::request_firewall_rule(11434, "Ollama") { + Ok(_) => { + // Se a porta 8080 foi configurada, manter firewall_configured como true + if !status.firewall_configured { + status.firewall_configured = true; + } + system::info(" ✓ Regra de firewall criada para porta 11434"); + } + Err(e) => { + status.issues.push(format!("Firewall porta 11434: {}", e)); + system::warn(&format!(" ✗ Falha: {}", e)); + } + } + + // Configurar exceção no Windows Defender + system::info("[3/4] Configurando exceção no Windows Defender..."); + match Self::get_executable_path() { + Ok(exe_path) => { + let exe_str = exe_path.to_string_lossy().to_string(); + system::info(&format!(" → Executável: {}", exe_str)); + match Self::request_defender_exception(&exe_str) { + Ok(_) => { + status.defender_exception = true; + system::info(" ✓ Exceção adicionada no Windows Defender"); + } + Err(e) => { + status.issues.push(format!("Windows Defender: {}", e)); + system::warn(&format!(" ✗ Falha: {}", e)); + } + } + } + Err(e) => { + status.issues.push(format!("Não foi possível obter caminho do executável: {}", e)); + system::warn(&format!(" ✗ Não foi possível configurar exceção no Windows Defender: {}", e)); + } + } + + // Verificar permissões de rede + system::info("[4/4] Verificando permissões de rede..."); + match Self::request_network_permissions() { + Ok(_) => { + status.network_ok = true; + system::info(" ✓ Permissões de rede OK"); + } + Err(e) => { + status.issues.push(format!("Permissões de rede: {}", e)); + system::warn(&format!(" ✗ Falha: {}", e)); + } + } + + // Resumo final + system::info("========================================"); + if status.issues.is_empty() { + system::info("✓ Todas as permissões foram configuradas com sucesso!"); + } else { + system::warn(&format!("⚠ {} problema(s) encontrado(s) durante configuração de permissões", status.issues.len())); + system::info(""); + system::info("📋 Instruções manuais para resolver problemas:"); + system::info(""); + if !status.admin { + system::info(" 1. PRIVILÉGIOS DE ADMINISTRADOR:"); + system::info(" → Clique com botão direito no executável"); + system::info(" → Selecione 'Executar como administrador'"); + system::info(""); + } + if !status.firewall_configured { + system::info(" 2. REGRAS DE FIREWALL:"); + system::info(" → Abra: Painel de Controle > Firewall do Windows > Configurações avançadas"); + system::info(" → Clique em 'Regras de entrada' > 'Nova regra...'"); + system::info(" → Tipo: Porta > TCP > Portas específicas: 8080, 11434"); + system::info(" → Ação: Permitir a conexão"); + system::info(" → Nome: 'OllaHubBackend API' e 'OllaHubBackend Ollama'"); + system::info(""); + } + if !status.defender_exception { + system::info(" 3. EXCEÇÃO NO WINDOWS DEFENDER:"); + if let Ok(exe_path) = Self::get_executable_path() { + system::info(" → Abra: Windows Security > Virus & threat protection"); + system::info(" → Clique em 'Manage settings' (Gerenciar configurações)"); + system::info(" → Role até 'Exclusions' > 'Add or remove exclusions'"); + system::info(" → Adicione exceção de 'File' com o caminho:"); + system::info(&format!(" {}", exe_path.to_string_lossy())); + } + system::info(""); + } + } + system::info("========================================"); + + Ok(status) + } +} + diff --git a/src/state.rs b/src/state.rs index f038613..ec8a3ad 100644 --- a/src/state.rs +++ b/src/state.rs @@ -16,10 +16,14 @@ pub struct AppContext { } impl AppContext { - pub fn new(ollama_base_url: String, model_list_path: String, key_path: PathBuf) -> Self { + pub fn new( + ollama_base_url: String, + model_list_path: String, + key_path: PathBuf, + jwt_secret: String, + ) -> Self { let broker = crate::ws::new_broker(); let crypto = Arc::new(CryptoState::new(key_path)); - let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme-dev-secret".into()); Self { broker, crypto, @@ -28,4 +32,4 @@ impl AppContext { jwt_secret, } } -} \ No newline at end of file +} diff --git a/src/ws/chat.rs b/src/ws/chat.rs new file mode 100644 index 0000000..d40b438 --- /dev/null +++ b/src/ws/chat.rs @@ -0,0 +1,378 @@ +use actix::{Actor, AsyncContext, Handler, Message, StreamHandler}; +use actix_web::{web, Error, HttpRequest, HttpResponse}; +use actix_web_actors::ws; +use awc::Client; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; + +use crate::core::models::{check_model_installed, pull_model}; +use crate::state::AppContext; +use crate::utils::log; +use crate::ws::WsEvent; + +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +const CLIENT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Deserialize)] +pub struct ChatRequest { + pub model: String, + pub messages: Vec, + #[serde(default)] + pub stream: Option, + #[serde(default)] + pub options: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct ChatResponse { + pub model: String, + pub created_at: String, + pub message: Option, + pub done: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub load_duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_eval_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_eval_duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub eval_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub eval_duration: Option, +} + +pub struct ChatSession { + pub hb: Instant, + pub ollama_url: String, + pub broker: tokio::sync::broadcast::Sender, +} + +impl Actor for ChatSession { + type Context = ws::WebsocketContext; + + fn started(&mut self, ctx: &mut Self::Context) { + self.hb(ctx); + } +} + +impl ChatSession { + fn hb(&self, ctx: &mut ::Context) { + ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { + if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT { + log::warn("WebSocket client heartbeat failed, disconnecting"); + ctx.close(None); + return; + } + ctx.ping(b""); + }); + } +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct ChatMessageRequest(pub ChatRequest); + +#[derive(Message)] +#[rtype(result = "()")] +pub struct SendTextMessage(pub String); + +impl Handler for ChatSession { + type Result = (); + + fn handle(&mut self, msg: ChatMessageRequest, ctx: &mut Self::Context) { + let ollama_url = self.ollama_url.clone(); + let broker = self.broker.clone(); + let addr = ctx.address(); + let addr_for_error = addr.clone(); + + actix_rt::spawn(async move { + // Verificar e instalar modelo se necessário + match ensure_model_installed(&ollama_url, &msg.0.model, broker.clone(), addr.clone()).await { + Ok(()) => { + // Modelo disponível, iniciar chat + if let Err(e) = stream_ollama_chat(&ollama_url, &msg.0, addr).await { + let error_msg = serde_json::json!({ + "error": e, + "done": true + }); + if let Ok(json) = serde_json::to_string(&error_msg) { + addr_for_error.do_send(SendTextMessage(json)); + } + } + } + Err(e) => { + // Erro na verificação/instalação + let error_msg = serde_json::json!({ + "status": "error", + "error": e, + "done": true + }); + if let Ok(json) = serde_json::to_string(&error_msg) { + addr_for_error.do_send(SendTextMessage(json)); + } + } + } + }); + } +} + +impl Handler for ChatSession { + type Result = (); + + fn handle(&mut self, msg: SendTextMessage, ctx: &mut Self::Context) { + ctx.text(msg.0); + } +} + +/// Garante que o modelo está instalado, instalando se necessário +async fn ensure_model_installed( + ollama_url: &str, + model_name: &str, + broker: tokio::sync::broadcast::Sender, + addr: actix::Addr, +) -> Result<(), String> { + // Enviar status: verificando + let checking_msg = serde_json::json!({ + "status": "checking", + "model": model_name + }); + if let Ok(json) = serde_json::to_string(&checking_msg) { + addr.do_send(SendTextMessage(json)); + } + + // Verificar se modelo está instalado + match check_model_installed(ollama_url, model_name).await { + Ok(true) => { + // Modelo já instalado + let ready_msg = serde_json::json!({ + "status": "ready", + "model": model_name + }); + if let Ok(json) = serde_json::to_string(&ready_msg) { + addr.do_send(SendTextMessage(json)); + } + return Ok(()); + } + Ok(false) => { + // Modelo não instalado, precisa instalar + } + Err(e) => { + return Err(format!("Erro ao verificar modelo: {}", e)); + } + } + + // Enviar status: instalando + let installing_msg = serde_json::json!({ + "status": "installing", + "model": model_name + }); + if let Ok(json) = serde_json::to_string(&installing_msg) { + addr.do_send(SendTextMessage(json)); + } + + // Criar subscriber para capturar eventos de instalação + let mut rx = broker.subscribe(); + let addr_clone = addr.clone(); + let model_name_clone = model_name.to_string(); + + // Spawn task para monitorar progresso da instalação + let progress_handle = actix_rt::spawn(async move { + while let Ok(event) = rx.recv().await { + // Converter eventos de instalação para mensagens WebSocket + let status_msg = serde_json::json!({ + "status": "installing", + "model": model_name_clone, + "progress": event.message + }); + if let Ok(json) = serde_json::to_string(&status_msg) { + addr_clone.do_send(SendTextMessage(json)); + } + + // Se for evento Done, parar de monitorar + if matches!(event.kind, crate::ws::WsEventKind::Done) { + break; + } + } + }); + + // Instalar modelo + match pull_model(ollama_url, model_name, &broker).await { + Ok(()) => { + // Aguardar task de progresso terminar + let _ = progress_handle.await; + + // Enviar status: instalado + let installed_msg = serde_json::json!({ + "status": "installed", + "model": model_name + }); + if let Ok(json) = serde_json::to_string(&installed_msg) { + addr.do_send(SendTextMessage(json)); + } + + // Aguardar um pouco para garantir que modelo está disponível + tokio::time::sleep(Duration::from_secs(1)).await; + + // Verificar novamente para confirmar + match check_model_installed(ollama_url, model_name).await { + Ok(true) => { + let ready_msg = serde_json::json!({ + "status": "ready", + "model": model_name + }); + if let Ok(json) = serde_json::to_string(&ready_msg) { + addr.do_send(SendTextMessage(json)); + } + Ok(()) + } + Ok(false) => Err("Modelo instalado mas não encontrado após verificação".to_string()), + Err(e) => Err(format!("Erro ao verificar modelo após instalação: {}", e)), + } + } + Err(e) => { + let error_msg = serde_json::json!({ + "status": "error", + "error": format!("Erro ao instalar modelo: {}", e), + "done": true + }); + if let Ok(json) = serde_json::to_string(&error_msg) { + addr.do_send(SendTextMessage(json)); + } + Err(format!("Falha ao instalar modelo: {}", e)) + } + } +} + +async fn stream_ollama_chat( + ollama_url: &str, + request: &ChatRequest, + addr: actix::Addr, +) -> Result<(), String> { + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .finish(); + + let url = format!("{}/api/chat", ollama_url.trim_end_matches('/')); + + let mut ollama_request = serde_json::json!({ + "model": request.model, + "messages": request.messages, + "stream": true + }); + + if let Some(ref options) = request.options { + ollama_request["options"] = options.clone(); + } + + let mut resp = client + .post(&url) + .insert_header(("Content-Type", "application/json")) + .insert_header(("User-Agent", "OllaHubBackend/1.0")) + .send_json(&ollama_request) + .await + .map_err(|e| format!("Erro ao conectar com Ollama: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.body().await.unwrap_or_default(); + return Err(format!("Ollama retornou erro {}: {}", status, String::from_utf8_lossy(&body))); + } + + // Ler o body completo (Ollama envia JSON lines) + let body_bytes = resp.body().await.map_err(|e| format!("Erro ao ler body: {}", e))?; + let body_text = String::from_utf8_lossy(&body_bytes); + + // Processar cada linha (JSON lines format) + for line in body_text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Parse JSON do Ollama + match serde_json::from_str::(trimmed) { + Ok(json) => { + // Enviar chunk para o cliente WebSocket + if let Ok(json_str) = serde_json::to_string(&json) { + addr.do_send(SendTextMessage(json_str)); + } + } + Err(e) => { + log::warn(&format!("Erro ao parsear JSON do Ollama: {} | Linha: {}", e, trimmed)); + } + } + } + + Ok(()) +} + +impl StreamHandler> for ChatSession { + fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { + match msg { + Ok(ws::Message::Ping(msg)) => { + self.hb = Instant::now(); + ctx.pong(&msg); + } + Ok(ws::Message::Pong(_)) => { + self.hb = Instant::now(); + } + Ok(ws::Message::Text(text)) => { + self.hb = Instant::now(); + + // Parse da requisição de chat + match serde_json::from_str::(&text) { + Ok(chat_req) => { + ctx.address().do_send(ChatMessageRequest(chat_req)); + } + Err(e) => { + let error = serde_json::json!({ + "error": format!("Erro ao parsear requisição: {}", e), + "done": true + }); + if let Ok(json) = serde_json::to_string(&error) { + ctx.text(json); + } + } + } + } + Ok(ws::Message::Binary(_)) => { + // Não suportamos binary para chat + let error = serde_json::json!({ + "error": "Binary messages not supported for chat", + "done": true + }); + if let Ok(json) = serde_json::to_string(&error) { + ctx.text(json); + } + } + Ok(ws::Message::Close(reason)) => { + log::info(&format!("Chat WebSocket closed: {:?}", reason)); + ctx.close(reason); + } + _ => {} + } + } +} + +#[actix_web::get("/ws/chat")] +pub async fn chat_ws_route( + req: HttpRequest, + stream: web::Payload, + state: web::Data, +) -> Result { + let session = ChatSession { + hb: Instant::now(), + ollama_url: state.ollama_base_url.clone(), + broker: state.broker.clone(), + }; + ws::start(session, &req, stream) +} + diff --git a/src/ws/mod.rs b/src/ws/mod.rs index 1129453..724540e 100644 --- a/src/ws/mod.rs +++ b/src/ws/mod.rs @@ -1,4 +1,4 @@ -use actix::{Actor, Context, Handler, ActorContext, Message}; +use actix::{Actor, Handler, ActorContext, Message}; use actix_web::{get, web, Error, HttpRequest, HttpResponse}; use actix_web_actors::ws; use serde::{Deserialize, Serialize}; @@ -6,6 +6,8 @@ use tokio::sync::broadcast; use crate::utils::log; +pub mod chat; + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum WsEventKind { Log, @@ -35,7 +37,7 @@ pub struct WsSession { impl Actor for WsSession { type Context = ws::WebsocketContext; - fn started(&mut self, ctx: &mut Self::Context) { + fn started(&mut self, _ctx: &mut Self::Context) { let mut rx = self.broker.subscribe(); // Forward broker messages to the websocket client actix_rt::spawn(async move {