feat: Implement initial backend structure with API, WebSocket, core models, services, and documentation, alongside dependency and gitignore updates.

This commit is contained in:
Evandro Fonseca Junior committed 2025-11-18 15:59:48 -03:00
1 parent f82191dd83
commit 7710be86a6
29 files changed
+3927 -1301

No files matched your search

+1
View File
@@ -0,0 +1 @@
JWT_SECRET=5741f9da3be6007674a5e10edab7b124
+14
View File
@@ -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
+3
View File
@@ -19,3 +19,6 @@ target/
# Added by cargo
/target
# Environment variables
.env
Generated
+23
View File
@@ -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"
+2
View File
@@ -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"
+1316
View File
File diff suppressed because it is too large. Load diff
+796
View File
@@ -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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
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<Array<{ name: string; description?: string }>> {
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<Array<{ id: string; name: string; email: string }>> {
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<void> {
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<void>((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: '[email protected]',
});
console.log('Usuário criado:', user1);
const user2 = await client.createUser({
name: 'Maria Santos',
email: '[email protected]',
});
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: '[email protected]',
});
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<T>(
fn: () => Promise<T>,
retries = 3
): Promise<T> {
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<WsEvent[]>([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(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 (
<div>
<button onClick={() => handleInstall('llama2:7b')}>
Instalar Modelo
</button>
<div>
Status: {connected ? 'Conectado' : 'Desconectado'}
</div>
<div>
{events.map((event, i) => (
<div key={i}>
<strong>{event.kind}:</strong> {event.message}
</div>
))}
</div>
</div>
);
}
```
---
### 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<Array<{ name: string; description?: string }>> {
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
+66
View File
@@ -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
+140 -29
View File
@@ -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<AppContext>) -> impl Responder {
pub async fn models_list(state: web::Data<AppContext>) -> Result<impl Responder, ApiError> {
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<AppContext>, payload: web::Json<InstallReq>) -> impl Responder {
pub async fn models_install(state: web::Data<AppContext>, payload: web::Json<InstallReq>) -> Result<impl Responder, ApiError> {
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<AppContext>, payload: web::Json<Ins
actix_rt::spawn(async move {
let _ = pull_model(&base, &value, &tx).await;
});
HttpResponse::Accepted().json(serde_json::json!({"status":"started","model":name}))
Ok(HttpResponse::Accepted().json(ModelInstallStarted { status: "started".into(), model: name }))
}
#[derive(Deserialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ModelInstallStarted { pub status: String, pub model: String }
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct OllamaInstallStarted { pub status: String, pub operation_id: String }
#[derive(Deserialize, ToSchema)]
pub struct SetKeyReq { pub hex_key: String }
#[utoipa::path(
post,
path = "/v2/api/crypto/set-key",
request_body = SetKeyReq,
responses((status = 200, description = "Chave definida", body = SimpleOk), (status = 400, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
)]
#[post("/api/crypto/set-key")]
pub async fn crypto_set_key(state: web::Data<AppContext>, payload: web::Json<SetKeyReq>) -> impl Responder {
pub async fn crypto_set_key(state: web::Data<AppContext>, payload: web::Json<SetKeyReq>) -> Result<impl Responder, ApiError> {
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<AppContext>) -> impl Responder {
pub async fn crypto_rotate_key(state: web::Data<AppContext>) -> Result<impl Responder, ApiError> {
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<AppContext>, payload: web::Json<CryptReq>) -> impl Responder {
pub async fn crypto_encrypt(state: web::Data<AppContext>, payload: web::Json<CryptReq>) -> Result<impl Responder, ApiError> {
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<AppContext>, payload: web::Json<CryptReq>) -> impl Responder {
pub async fn crypto_decrypt(state: web::Data<AppContext>, payload: web::Json<CryptReq>) -> Result<impl Responder, ApiError> {
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<AppContext>) -> Result<impl Responder, ApiError> {
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<String>,
#[serde(default)]
pub platform: Option<String>,
}
#[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<AppContext>, _payload: web::Json<OllamaInstallReq>) -> Result<impl Responder, ApiError> {
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<AppContext>) {
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<AppContext>) {
pub fn ws_config(cfg: &mut web::ServiceConfig, broker: actix_web::web::Data<BrokerAddr>) {
cfg.app_data(broker);
cfg.service(ws_route);
}
pub fn chat_ws_config(cfg: &mut web::ServiceConfig, state: web::Data<AppContext>) {
cfg.app_data(state);
cfg.service(crate::ws::chat::chat_ws_route);
}
+44
View File
@@ -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,
}
}
}
+40 -15
View File
@@ -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<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> impl Responder {
pub async fn list_users(
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
) -> impl Responder {
HttpResponse::Ok().json(svc.list())
}
@@ -32,7 +36,10 @@ pub async fn list_users(svc: web::Data<UserService<crate::repositories::user_rep
)
)]
#[get("/api/v1/users/{id}")]
pub async fn get_user(path: web::Path<UserPath>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
pub async fn get_user(
path: web::Path<UserPath>,
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
) -> Result<impl Responder, ApiError> {
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<UserPath>, svc: web::Data<UserService<crat
)
)]
#[post("/api/v1/users")]
pub async fn create_user(payload: web::Json<CreateUserRequest>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
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<CreateUserRequest>,
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
) -> Result<impl Responder, ApiError> {
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<CreateUserRequest>, svc: web::Data<U
)
)]
#[put("/api/v1/users/{id}")]
pub async fn update_user(path: web::Path<UserPath>, payload: web::Json<UpdateUserRequest>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
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<UserPath>,
payload: web::Json<UpdateUserRequest>,
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
) -> Result<impl Responder, ApiError> {
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<UserPath>, payload: web::Json<UpdateUse
)
)]
#[delete("/api/v1/users/{id}")]
pub async fn delete_user(path: web::Path<UserPath>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
if svc.delete(path.id.clone()) { Ok(HttpResponse::NoContent().finish()) } else { Err(ApiError::NotFound("Usuário não encontrado".into())) }
}
pub async fn delete_user(
path: web::Path<UserPath>,
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
) -> Result<impl Responder, ApiError> {
if svc.delete(path.id.clone()) {
Ok(HttpResponse::NoContent().finish())
} else {
Err(ApiError::NotFound("Usuário não encontrado".into()))
}
}
+112 -3
View File
@@ -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<String>,
}
#[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<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct InstallCheckResult {
pub installed: bool,
pub in_path: bool,
pub version: Option<String>,
pub service_running: bool,
pub api_status: ApiStatus,
pub details: HashMap<String, String>,
pub issues: Vec<String>,
}
#[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<String, String>,
pub issues: Vec<String>,
pub timing_ms: HashMap<String, u128>,
pub aborted: Option<String>,
pub manual_instructions: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct InstallStarted {
pub status: String,
pub operation_id: String,
}
#[derive(Debug, Deserialize)]
struct ModelListFile {
models: Vec<ModelInfo>,
}
pub fn read_model_list(path: &str) -> Result<Vec<ModelInfo>, String> {
let data = fs::read_to_string(path).map_err(|e| e.to_string())?;
let list: Vec<ModelInfo> = 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::<ModelListFile>(&data) {
return Ok(file.models);
}
// Se falhar, tentar como array direto (formato antigo)
let list: Vec<ModelInfo> = 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<OllamaModelInfo>,
}
#[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<bool, String> {
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,
+66 -1226
View File
File diff suppressed because it is too large. Load diff
+66 -5
View File
@@ -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<String> }
@@ -14,15 +38,40 @@ pub async fn jwt_auth<B>(req: ServiceRequest, next: Next<B>) -> Result<ServiceRe
where
B: MessageBody + 'static,
{
// Apenas protege rotas da API v1; demais passam direto
if !req.path().starts_with("/api/v1/") {
// Permitir requisições OPTIONS (preflight CORS) sem autenticação
if req.method() == actix_web::http::Method::OPTIONS {
let res = next.call(req).await?;
return Ok(res.map_into_boxed_body());
}
let p = req.path();
// Excluir WebSocket do middleware JWT (precisa upgrade HTTP antes de autenticação)
if p.starts_with("/v2/ws") || p.starts_with("/ws") {
let res = next.call(req).await?;
return Ok(res.map_into_boxed_body());
}
let protected = p.starts_with("/api/v1/") || p.starts_with("/v2/api/crypto") || p.starts_with("/v2/api/models");
if !protected {
let res = next.call(req).await?;
return Ok(res.map_into_boxed_body());
}
let ctx = req.app_data::<actix_web::web::Data<AppContext>>().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))
}
}
}
+26
View File
@@ -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,
])
}
+145
View File
@@ -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<S, B> Transform<S, ServiceRequest> for LoggingMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = LoggingMiddlewareService<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(LoggingMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct LoggingMiddlewareService<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for LoggingMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>>>>;
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)
})
}
}
+3 -1
View File
@@ -1,2 +1,4 @@
pub mod auth;
pub mod error;
pub mod cors;
pub mod error;
pub mod logging;
+26
View File
@@ -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<AppContext>) {
cfg.app_data(_state.clone());
let repo = InMemoryUserRepository::new();
let svc = UserService::new(repo);
cfg.app_data(web::Data::new(svc));
+37
View File
@@ -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<String, String>,
issues: &mut Vec<String>,
broker: &broadcast::Sender<WsEvent>,
) -> 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(())
}
}
+56
View File
@@ -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<String, String>,
issues: &mut Vec<String>,
broker: &broadcast::Sender<WsEvent>,
) -> 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(())
}
}
+16
View File
@@ -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<String, String>,
issues: &mut Vec<String>,
broker: &broadcast::Sender<WsEvent>,
) -> Result<(), String>;
}
+57
View File
@@ -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<String, String>,
issues: &mut Vec<String>,
broker: &broadcast::Sender<WsEvent>,
) -> 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(())
}
}
+5 -2
View File
@@ -1,3 +1,6 @@
pub mod system;
pub mod install;
pub mod ollama;
pub mod user_service;
pub mod ollama_install;
pub mod system;
pub mod user_service;
pub mod windows_permissions;
+161
View File
@@ -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<InstallCheckResult, String> {
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<WsEvent>,
_operation_id: Uuid,
) -> Result<InstallResult, String> {
let platform = Platform::detect();
let mut details = HashMap::new();
let mut issues = Vec::new();
let strategy: Box<dyn InstallStrategy> = 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<String>) -> (bool, Option<String>) {
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<String>) -> 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<String>,
broker: &tokio::sync::broadcast::Sender<WsEvent>,
) -> 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
}
+56 -15
View File
@@ -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<String> },
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<String> {
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<String, String> {
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);
}
+261
View File
@@ -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<String>,
}
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<PathBuf, String> {
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<PermissionStatus, String> {
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)
}
}
+7 -3
View File
@@ -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,
}
}
}
}
+378
View File
@@ -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<ChatMessage>,
#[serde(default)]
pub stream: Option<bool>,
#[serde(default)]
pub options: Option<serde_json::Value>,
}
#[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<ChatMessage>,
pub done: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_duration: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub load_duration: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_eval_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_eval_duration: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub eval_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub eval_duration: Option<u64>,
}
pub struct ChatSession {
pub hb: Instant,
pub ollama_url: String,
pub broker: tokio::sync::broadcast::Sender<WsEvent>,
}
impl Actor for ChatSession {
type Context = ws::WebsocketContext<Self>;
fn started(&mut self, ctx: &mut Self::Context) {
self.hb(ctx);
}
}
impl ChatSession {
fn hb(&self, ctx: &mut <Self as Actor>::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<ChatMessageRequest> 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<SendTextMessage> 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<WsEvent>,
addr: actix::Addr<ChatSession>,
) -> 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<ChatSession>,
) -> 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::<serde_json::Value>(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<Result<ws::Message, ws::ProtocolError>> for ChatSession {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, 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::<ChatRequest>(&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<AppContext>,
) -> Result<HttpResponse, Error> {
let session = ChatSession {
hb: Instant::now(),
ollama_url: state.ollama_base_url.clone(),
broker: state.broker.clone(),
};
ws::start(session, &req, stream)
}
+4 -2
View File
@@ -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<Self>;
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 {