feat: Implement initial backend structure with API, WebSocket, core models, services, and documentation, alongside dependency and gitignore updates.
This commit is contained in:
1 parent
f82191dd83
commit
7710be86a6
29 files changed
+3923
-1297
No files matched your search
@@ -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
|
||||||
@@ -19,3 +19,6 @@ target/
|
|||||||
# Added by cargo
|
# Added by cargo
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
Generated
+23
@@ -7,6 +7,7 @@ name = "OllaHubBackend"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
|
"actix-cors",
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-web-actors",
|
"actix-web-actors",
|
||||||
@@ -15,6 +16,7 @@ dependencies = [
|
|||||||
"awc",
|
"awc",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"dotenv",
|
||||||
"hex",
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
@@ -70,6 +72,21 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "actix-http"
|
name = "actix-http"
|
||||||
version = "3.11.2"
|
version = "3.11.2"
|
||||||
@@ -741,6 +758,12 @@ dependencies = [
|
|||||||
"syn 2.0.109",
|
"syn 2.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotenv"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ actix-rt = "2.11.0"
|
|||||||
actix-web = "4.11.0"
|
actix-web = "4.11.0"
|
||||||
actix-web-actors = "4.3.0"
|
actix-web-actors = "4.3.0"
|
||||||
actix-web-lab = "0.24.3"
|
actix-web-lab = "0.24.3"
|
||||||
|
actix-cors = "0.7"
|
||||||
awc = "3.8.1"
|
awc = "3.8.1"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -24,3 +25,4 @@ utoipa = { version = "4", features = ["actix_extras"] }
|
|||||||
utoipa-swagger-ui = { version = "4", features = ["actix-web"] }
|
utoipa-swagger-ui = { version = "4", features = ["actix-web"] }
|
||||||
validator = { version = "0.16", features = ["derive"] }
|
validator = { version = "0.16", features = ["derive"] }
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
dotenv = "0.15"
|
||||||
+1316
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -1,33 +1,52 @@
|
|||||||
use actix_web::{get, post, web, HttpResponse, Responder};
|
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::core::models::{pull_model, read_model_list};
|
||||||
use crate::crypto::CryptoState;
|
use crate::services::ollama_install::OllamaInstallService;
|
||||||
use crate::state::AppContext;
|
use crate::state::AppContext;
|
||||||
use crate::utils::log;
|
use crate::ws::{ws_route, BrokerAddr};
|
||||||
use crate::ws::{ws_route, BrokerAddr, WsEvent, WsEventKind};
|
|
||||||
use base64::{engine::general_purpose, Engine as _};
|
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")]
|
#[get("/health")]
|
||||||
pub async fn health() -> impl Responder {
|
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")]
|
#[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) {
|
match read_model_list(&state.model_list_path) {
|
||||||
Ok(list) => HttpResponse::Ok().json(list),
|
Ok(list) => Ok(HttpResponse::Ok().json(list)),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
|
Err(e) => Err(ApiError::Internal(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct InstallReq {
|
pub struct InstallReq { pub name: String }
|
||||||
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")]
|
#[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 name = payload.name.clone();
|
||||||
let tx = state.broker.clone();
|
let tx = state.broker.clone();
|
||||||
let base = state.ollama_base_url.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 {
|
actix_rt::spawn(async move {
|
||||||
let _ = pull_model(&base, &value, &tx).await;
|
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 }
|
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")]
|
#[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) {
|
match state.crypto.set_key_hex(&payload.hex_key) {
|
||||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"status":"ok"})),
|
Ok(()) => Ok(HttpResponse::Ok().json(SimpleOk { ok: true })),
|
||||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
|
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")]
|
#[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() {
|
match state.crypto.rotate_key() {
|
||||||
Ok(hex) => HttpResponse::Ok().json(serde_json::json!({"key": hex})),
|
Ok(hex) => Ok(HttpResponse::Ok().json(RotateKeyResponse { key: hex })),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
|
Err(e) => Err(ApiError::Internal(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct CryptReq { pub data: String }
|
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")]
|
#[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()) {
|
match state.crypto.encrypt(payload.data.as_bytes()) {
|
||||||
Ok(ct) => HttpResponse::Ok().json(serde_json::json!({"ciphertext": general_purpose::STANDARD.encode(ct)})),
|
Ok(ct) => Ok(HttpResponse::Ok().json(EncryptResponse { ciphertext: general_purpose::STANDARD.encode(ct) })),
|
||||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
|
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")]
|
#[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) {
|
match general_purpose::STANDARD.decode(&payload.data) {
|
||||||
Ok(bytes) => match state.crypto.decrypt(&bytes) {
|
Ok(bytes) => match state.crypto.decrypt(&bytes) {
|
||||||
Ok(pt) => HttpResponse::Ok().json(serde_json::json!({"plaintext": String::from_utf8_lossy(&pt)})),
|
Ok(pt) => Ok(HttpResponse::Ok().json(DecryptResponse { plaintext: String::from_utf8_lossy(&pt).to_string() })),
|
||||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
|
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>) {
|
pub fn configure(cfg: &mut web::ServiceConfig, state: web::Data<AppContext>) {
|
||||||
cfg.app_data(state);
|
cfg.app_data(state);
|
||||||
cfg.service(health);
|
cfg.service(health);
|
||||||
cfg.service(models_list);
|
cfg.service(models_list);
|
||||||
cfg.service(models_install);
|
cfg.service(models_install);
|
||||||
|
cfg.service(ollama_check);
|
||||||
|
cfg.service(ollama_install);
|
||||||
cfg.service(crypto_set_key);
|
cfg.service(crypto_set_key);
|
||||||
cfg.service(crypto_rotate_key);
|
cfg.service(crypto_rotate_key);
|
||||||
cfg.service(crypto_encrypt);
|
cfg.service(crypto_encrypt);
|
||||||
@@ -94,3 +200,8 @@ pub fn ws_config(cfg: &mut web::ServiceConfig, broker: actix_web::web::Data<Brok
|
|||||||
cfg.app_data(broker);
|
cfg.app_data(broker);
|
||||||
cfg.service(ws_route);
|
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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
use actix_web::{get, post, put, delete, web, HttpResponse, Responder};
|
use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
|
||||||
use utoipa::{IntoParams, ToSchema};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::middlewares::error::ApiError;
|
use crate::middlewares::error::ApiError;
|
||||||
use crate::models::user::{CreateUserRequest, UpdateUserRequest, User};
|
use crate::models::user::{CreateUserRequest, UpdateUserRequest};
|
||||||
use crate::services::user_service::UserService;
|
use crate::services::user_service::UserService;
|
||||||
|
|
||||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||||
pub struct UserPath { pub id: String }
|
pub struct UserPath {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -18,7 +20,9 @@ pub struct UserPath { pub id: String }
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#[get("/api/v1/users")]
|
#[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())
|
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}")]
|
#[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()) {
|
match svc.get(path.id.clone()) {
|
||||||
Some(u) => Ok(HttpResponse::Ok().json(u)),
|
Some(u) => Ok(HttpResponse::Ok().json(u)),
|
||||||
None => Err(ApiError::NotFound("Usuário não encontrado".into())),
|
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")]
|
#[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> {
|
pub async fn create_user(
|
||||||
let mut req = payload.into_inner();
|
payload: web::Json<CreateUserRequest>,
|
||||||
if let Err(e) = req.validate() { return Err(ApiError::BadRequest(format!("validação: {}", e))); }
|
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);
|
let user = svc.create(req);
|
||||||
Ok(HttpResponse::Created().json(user))
|
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}")]
|
#[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> {
|
pub async fn update_user(
|
||||||
let mut req = payload.into_inner();
|
path: web::Path<UserPath>,
|
||||||
if let Err(e) = req.validate() { return Err(ApiError::BadRequest(format!("validação: {}", e))); }
|
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) {
|
match svc.update(path.id.clone(), req) {
|
||||||
Some(u) => Ok(HttpResponse::Ok().json(u)),
|
Some(u) => Ok(HttpResponse::Ok().json(u)),
|
||||||
None => Err(ApiError::NotFound("Usuário não encontrado".into())),
|
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}")]
|
#[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> {
|
pub async fn delete_user(
|
||||||
if svc.delete(path.id.clone()) { Ok(HttpResponse::NoContent().finish()) } else { Err(ApiError::NotFound("Usuário não encontrado".into())) }
|
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
@@ -1,25 +1,134 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use actix_web::http::header::USER_AGENT;
|
use actix_web::http::header::USER_AGENT;
|
||||||
use awc::Client;
|
use awc::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::utils::log;
|
use crate::utils::log;
|
||||||
use crate::ws::{WsEvent, WsEventKind};
|
use crate::ws::{WsEvent, WsEventKind};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ModelInfo {
|
pub struct ModelInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<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> {
|
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 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)
|
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(
|
pub async fn pull_model(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
|
|||||||
+66
-1226
File diff suppressed because it is too large.
Load diff
+66
-5
@@ -7,6 +7,30 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::state::AppContext;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct Claims { sub: String, exp: usize, role: Option<String> }
|
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
|
where
|
||||||
B: MessageBody + 'static,
|
B: MessageBody + 'static,
|
||||||
{
|
{
|
||||||
// Apenas protege rotas da API v1; demais passam direto
|
// Permitir requisições OPTIONS (preflight CORS) sem autenticação
|
||||||
if !req.path().starts_with("/api/v1/") {
|
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?;
|
let res = next.call(req).await?;
|
||||||
return Ok(res.map_into_boxed_body());
|
return Ok(res.map_into_boxed_body());
|
||||||
}
|
}
|
||||||
let ctx = req.app_data::<actix_web::web::Data<AppContext>>().cloned();
|
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 {
|
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 = hdr.to_str().unwrap_or("");
|
||||||
let token = token.strip_prefix("Bearer ").unwrap_or(token);
|
let token = token.strip_prefix("Bearer ").unwrap_or(token);
|
||||||
@@ -33,6 +82,18 @@ where
|
|||||||
let res = next.call(req).await?;
|
let res = next.call(req).await?;
|
||||||
Ok(res.map_into_boxed_body())
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod cors;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod logging;
|
||||||
@@ -15,20 +15,46 @@ use crate::state::AppContext;
|
|||||||
crate::controllers::user_controller::create_user,
|
crate::controllers::user_controller::create_user,
|
||||||
crate::controllers::user_controller::update_user,
|
crate::controllers::user_controller::update_user,
|
||||||
crate::controllers::user_controller::delete_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(
|
components(schemas(
|
||||||
crate::models::user::User,
|
crate::models::user::User,
|
||||||
crate::models::user::CreateUserRequest,
|
crate::models::user::CreateUserRequest,
|
||||||
crate::models::user::UpdateUserRequest,
|
crate::models::user::UpdateUserRequest,
|
||||||
crate::middlewares::error::ApiErrorBody,
|
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(
|
tags(
|
||||||
(name = "users", description = "Gerenciamento de usuários"),
|
(name = "users", description = "Gerenciamento de usuários"),
|
||||||
|
(name = "v2", description = "APIs v2: saúde, modelos, ollama, criptografia"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub struct ApiDoc;
|
pub struct ApiDoc;
|
||||||
|
|
||||||
pub fn configure_v1(cfg: &mut web::ServiceConfig, _state: web::Data<AppContext>) {
|
pub fn configure_v1(cfg: &mut web::ServiceConfig, _state: web::Data<AppContext>) {
|
||||||
|
cfg.app_data(_state.clone());
|
||||||
let repo = InMemoryUserRepository::new();
|
let repo = InMemoryUserRepository::new();
|
||||||
let svc = UserService::new(repo);
|
let svc = UserService::new(repo);
|
||||||
cfg.app_data(web::Data::new(svc));
|
cfg.app_data(web::Data::new(svc));
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
pub mod system;
|
pub mod install;
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
|
pub mod ollama_install;
|
||||||
|
pub mod system;
|
||||||
pub mod user_service;
|
pub mod user_service;
|
||||||
|
pub mod windows_permissions;
|
||||||
@@ -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
@@ -1,27 +1,68 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
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 {
|
pub enum Platform {
|
||||||
|
Linux { distro: Option<String> },
|
||||||
Windows,
|
Windows,
|
||||||
Linux,
|
Macos,
|
||||||
MacOS,
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect_platform() -> Platform {
|
impl Platform {
|
||||||
if cfg!(target_os = "windows") {
|
pub fn detect() -> Self {
|
||||||
Platform::Windows
|
let os = env::consts::OS;
|
||||||
} else if cfg!(target_os = "macos") {
|
match os {
|
||||||
Platform::MacOS
|
"linux" => Platform::Linux { distro: Self::detect_linux_distro() },
|
||||||
} else {
|
"windows" => Platform::Windows,
|
||||||
Platform::Linux
|
"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> {
|
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())?;
|
let output = Command::new(cmd).args(args).output();
|
||||||
if output.status.success() {
|
match output {
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
Ok(out) => {
|
||||||
} else {
|
if out.status.success() {
|
||||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+6
-2
@@ -16,10 +16,14 @@ pub struct AppContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 broker = crate::ws::new_broker();
|
||||||
let crypto = Arc::new(CryptoState::new(key_path));
|
let crypto = Arc::new(CryptoState::new(key_path));
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme-dev-secret".into());
|
|
||||||
Self {
|
Self {
|
||||||
broker,
|
broker,
|
||||||
crypto,
|
crypto,
|
||||||
|
|||||||
+378
@@ -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
@@ -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::{get, web, Error, HttpRequest, HttpResponse};
|
||||||
use actix_web_actors::ws;
|
use actix_web_actors::ws;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -6,6 +6,8 @@ use tokio::sync::broadcast;
|
|||||||
|
|
||||||
use crate::utils::log;
|
use crate::utils::log;
|
||||||
|
|
||||||
|
pub mod chat;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum WsEventKind {
|
pub enum WsEventKind {
|
||||||
Log,
|
Log,
|
||||||
@@ -35,7 +37,7 @@ pub struct WsSession {
|
|||||||
impl Actor for WsSession {
|
impl Actor for WsSession {
|
||||||
type Context = ws::WebsocketContext<Self>;
|
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();
|
let mut rx = self.broker.subscribe();
|
||||||
// Forward broker messages to the websocket client
|
// Forward broker messages to the websocket client
|
||||||
actix_rt::spawn(async move {
|
actix_rt::spawn(async move {
|
||||||
|
|||||||
Reference in new issue
Block a user