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
+3927
-1301
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
|
||||
|
||||
/target
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
Generated
+23
@@ -7,6 +7,7 @@ name = "OllaHubBackend"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-cors",
|
||||
"actix-rt",
|
||||
"actix-web",
|
||||
"actix-web-actors",
|
||||
@@ -15,6 +16,7 @@ dependencies = [
|
||||
"awc",
|
||||
"base64 0.21.7",
|
||||
"dirs",
|
||||
"dotenv",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
"rand 0.8.5",
|
||||
@@ -70,6 +72,21 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-cors"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
|
||||
dependencies = [
|
||||
"actix-utils",
|
||||
"actix-web",
|
||||
"derive_more",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-http"
|
||||
version = "3.11.2"
|
||||
@@ -741,6 +758,12 @@ dependencies = [
|
||||
"syn 2.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
|
||||
@@ -9,6 +9,7 @@ actix-rt = "2.11.0"
|
||||
actix-web = "4.11.0"
|
||||
actix-web-actors = "4.3.0"
|
||||
actix-web-lab = "0.24.3"
|
||||
actix-cors = "0.7"
|
||||
awc = "3.8.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -24,3 +25,4 @@ utoipa = { version = "4", features = ["actix_extras"] }
|
||||
utoipa-swagger-ui = { version = "4", features = ["actix-web"] }
|
||||
validator = { version = "0.16", features = ["derive"] }
|
||||
jsonwebtoken = "9"
|
||||
dotenv = "0.15"
|
||||
+1316
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 serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::models::{pull_model, read_model_list};
|
||||
use crate::crypto::CryptoState;
|
||||
use crate::services::ollama_install::OllamaInstallService;
|
||||
use crate::state::AppContext;
|
||||
use crate::utils::log;
|
||||
use crate::ws::{ws_route, BrokerAddr, WsEvent, WsEventKind};
|
||||
use crate::ws::{ws_route, BrokerAddr};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use crate::middlewares::error::ApiError;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v2/health",
|
||||
responses((status = 200, description = "Saúde", body = HealthResponse))
|
||||
)]
|
||||
#[get("/health")]
|
||||
pub async fn health() -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({"status":"ok"}))
|
||||
HttpResponse::Ok().json(HealthResponse { status: "ok".into() })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct HealthResponse { pub status: String }
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v2/api/models",
|
||||
responses((status = 200, description = "Lista de modelos", body = [crate::core::models::ModelInfo]), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
|
||||
)]
|
||||
#[get("/api/models")]
|
||||
pub async fn models_list(state: web::Data<AppContext>) -> impl Responder {
|
||||
pub async fn models_list(state: web::Data<AppContext>) -> Result<impl Responder, ApiError> {
|
||||
match read_model_list(&state.model_list_path) {
|
||||
Ok(list) => HttpResponse::Ok().json(list),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
|
||||
Ok(list) => Ok(HttpResponse::Ok().json(list)),
|
||||
Err(e) => Err(ApiError::Internal(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InstallReq {
|
||||
pub name: String,
|
||||
}
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct InstallReq { pub name: String }
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v2/api/models/install",
|
||||
request_body = InstallReq,
|
||||
responses((status = 202, description = "Instalação iniciada", body = ModelInstallStarted))
|
||||
)]
|
||||
#[post("/api/models/install")]
|
||||
pub async fn models_install(state: web::Data<AppContext>, payload: web::Json<InstallReq>) -> impl Responder {
|
||||
pub async fn models_install(state: web::Data<AppContext>, payload: web::Json<InstallReq>) -> Result<impl Responder, ApiError> {
|
||||
let name = payload.name.clone();
|
||||
let tx = state.broker.clone();
|
||||
let base = state.ollama_base_url.clone();
|
||||
@@ -35,55 +54,142 @@ pub async fn models_install(state: web::Data<AppContext>, payload: web::Json<Ins
|
||||
actix_rt::spawn(async move {
|
||||
let _ = pull_model(&base, &value, &tx).await;
|
||||
});
|
||||
HttpResponse::Accepted().json(serde_json::json!({"status":"started","model":name}))
|
||||
Ok(HttpResponse::Accepted().json(ModelInstallStarted { status: "started".into(), model: name }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ModelInstallStarted { pub status: String, pub model: String }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct OllamaInstallStarted { pub status: String, pub operation_id: String }
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SetKeyReq { pub hex_key: String }
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v2/api/crypto/set-key",
|
||||
request_body = SetKeyReq,
|
||||
responses((status = 200, description = "Chave definida", body = SimpleOk), (status = 400, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
|
||||
)]
|
||||
#[post("/api/crypto/set-key")]
|
||||
pub async fn crypto_set_key(state: web::Data<AppContext>, payload: web::Json<SetKeyReq>) -> impl Responder {
|
||||
pub async fn crypto_set_key(state: web::Data<AppContext>, payload: web::Json<SetKeyReq>) -> Result<impl Responder, ApiError> {
|
||||
match state.crypto.set_key_hex(&payload.hex_key) {
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"status":"ok"})),
|
||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
|
||||
Ok(()) => Ok(HttpResponse::Ok().json(SimpleOk { ok: true })),
|
||||
Err(e) => Err(ApiError::BadRequest(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SimpleOk { pub ok: bool }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct RotateKeyResponse { pub key: String }
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v2/api/crypto/rotate-key",
|
||||
responses((status = 200, description = "Chave rotacionada", body = RotateKeyResponse), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
|
||||
)]
|
||||
#[post("/api/crypto/rotate-key")]
|
||||
pub async fn crypto_rotate_key(state: web::Data<AppContext>) -> impl Responder {
|
||||
pub async fn crypto_rotate_key(state: web::Data<AppContext>) -> Result<impl Responder, ApiError> {
|
||||
match state.crypto.rotate_key() {
|
||||
Ok(hex) => HttpResponse::Ok().json(serde_json::json!({"key": hex})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e})),
|
||||
Ok(hex) => Ok(HttpResponse::Ok().json(RotateKeyResponse { key: hex })),
|
||||
Err(e) => Err(ApiError::Internal(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CryptReq { pub data: String }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct EncryptResponse { pub ciphertext: String }
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v2/api/crypto/encrypt",
|
||||
request_body = CryptReq,
|
||||
responses((status = 200, description = "Criptografia", body = EncryptResponse), (status = 400, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
|
||||
)]
|
||||
#[post("/api/crypto/encrypt")]
|
||||
pub async fn crypto_encrypt(state: web::Data<AppContext>, payload: web::Json<CryptReq>) -> impl Responder {
|
||||
pub async fn crypto_encrypt(state: web::Data<AppContext>, payload: web::Json<CryptReq>) -> Result<impl Responder, ApiError> {
|
||||
match state.crypto.encrypt(payload.data.as_bytes()) {
|
||||
Ok(ct) => HttpResponse::Ok().json(serde_json::json!({"ciphertext": general_purpose::STANDARD.encode(ct)})),
|
||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
|
||||
Ok(ct) => Ok(HttpResponse::Ok().json(EncryptResponse { ciphertext: general_purpose::STANDARD.encode(ct) })),
|
||||
Err(e) => Err(ApiError::BadRequest(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct DecryptResponse { pub plaintext: String }
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v2/api/crypto/decrypt",
|
||||
request_body = CryptReq,
|
||||
responses((status = 200, description = "Descriptografia", body = DecryptResponse), (status = 400, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
|
||||
)]
|
||||
#[post("/api/crypto/decrypt")]
|
||||
pub async fn crypto_decrypt(state: web::Data<AppContext>, payload: web::Json<CryptReq>) -> impl Responder {
|
||||
pub async fn crypto_decrypt(state: web::Data<AppContext>, payload: web::Json<CryptReq>) -> Result<impl Responder, ApiError> {
|
||||
match general_purpose::STANDARD.decode(&payload.data) {
|
||||
Ok(bytes) => match state.crypto.decrypt(&bytes) {
|
||||
Ok(pt) => HttpResponse::Ok().json(serde_json::json!({"plaintext": String::from_utf8_lossy(&pt)})),
|
||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})),
|
||||
Ok(pt) => Ok(HttpResponse::Ok().json(DecryptResponse { plaintext: String::from_utf8_lossy(&pt).to_string() })),
|
||||
Err(e) => Err(ApiError::BadRequest(e)),
|
||||
},
|
||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": format!("base64 decode: {}", e)})),
|
||||
Err(e) => Err(ApiError::BadRequest(format!("base64 decode: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v2/api/ollama/check",
|
||||
responses((status = 200, description = "Verificação de instalação", body = InstallCheckResult), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
|
||||
)]
|
||||
#[get("/api/ollama/check")]
|
||||
pub async fn ollama_check(state: web::Data<AppContext>) -> Result<impl Responder, ApiError> {
|
||||
match OllamaInstallService::check_installation(&state.ollama_base_url).await {
|
||||
Ok(result) => Ok(HttpResponse::Ok().json(result)),
|
||||
Err(e) => Err(ApiError::Internal(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct OllamaInstallReq {
|
||||
#[serde(default)]
|
||||
pub method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v2/api/ollama/install",
|
||||
request_body = OllamaInstallReq,
|
||||
responses((status = 202, description = "Instalação iniciada", body = OllamaInstallStarted), (status = 500, description = "Erro", body = crate::middlewares::error::ApiErrorBody))
|
||||
)]
|
||||
#[post("/api/ollama/install")]
|
||||
pub async fn ollama_install(state: web::Data<AppContext>, _payload: web::Json<OllamaInstallReq>) -> Result<impl Responder, ApiError> {
|
||||
let operation_id = Uuid::new_v4();
|
||||
let base_url = state.ollama_base_url.clone();
|
||||
let broker = state.broker.clone();
|
||||
let op_id = operation_id;
|
||||
|
||||
actix_rt::spawn(async move {
|
||||
let _ = OllamaInstallService::install(&base_url, &broker, op_id).await;
|
||||
});
|
||||
|
||||
Ok(HttpResponse::Accepted().json(OllamaInstallStarted {
|
||||
status: "started".into(),
|
||||
operation_id: operation_id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig, state: web::Data<AppContext>) {
|
||||
cfg.app_data(state);
|
||||
cfg.service(health);
|
||||
cfg.service(models_list);
|
||||
cfg.service(models_install);
|
||||
cfg.service(ollama_check);
|
||||
cfg.service(ollama_install);
|
||||
cfg.service(crypto_set_key);
|
||||
cfg.service(crypto_rotate_key);
|
||||
cfg.service(crypto_encrypt);
|
||||
@@ -93,4 +199,9 @@ pub fn configure(cfg: &mut web::ServiceConfig, state: web::Data<AppContext>) {
|
||||
pub fn ws_config(cfg: &mut web::ServiceConfig, broker: actix_web::web::Data<BrokerAddr>) {
|
||||
cfg.app_data(broker);
|
||||
cfg.service(ws_route);
|
||||
}
|
||||
|
||||
pub fn chat_ws_config(cfg: &mut web::ServiceConfig, state: web::Data<AppContext>) {
|
||||
cfg.app_data(state);
|
||||
cfg.service(crate::ws::chat::chat_ws_route);
|
||||
}
|
||||
@@ -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 utoipa::{IntoParams, ToSchema};
|
||||
use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
|
||||
use serde::Deserialize;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::middlewares::error::ApiError;
|
||||
use crate::models::user::{CreateUserRequest, UpdateUserRequest, User};
|
||||
use crate::models::user::{CreateUserRequest, UpdateUserRequest};
|
||||
use crate::services::user_service::UserService;
|
||||
|
||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||
pub struct UserPath { pub id: String }
|
||||
pub struct UserPath {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -18,7 +20,9 @@ pub struct UserPath { pub id: String }
|
||||
)
|
||||
)]
|
||||
#[get("/api/v1/users")]
|
||||
pub async fn list_users(svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> impl Responder {
|
||||
pub async fn list_users(
|
||||
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
|
||||
) -> impl Responder {
|
||||
HttpResponse::Ok().json(svc.list())
|
||||
}
|
||||
|
||||
@@ -32,7 +36,10 @@ pub async fn list_users(svc: web::Data<UserService<crate::repositories::user_rep
|
||||
)
|
||||
)]
|
||||
#[get("/api/v1/users/{id}")]
|
||||
pub async fn get_user(path: web::Path<UserPath>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
|
||||
pub async fn get_user(
|
||||
path: web::Path<UserPath>,
|
||||
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
|
||||
) -> Result<impl Responder, ApiError> {
|
||||
match svc.get(path.id.clone()) {
|
||||
Some(u) => Ok(HttpResponse::Ok().json(u)),
|
||||
None => Err(ApiError::NotFound("Usuário não encontrado".into())),
|
||||
@@ -49,9 +56,14 @@ pub async fn get_user(path: web::Path<UserPath>, svc: web::Data<UserService<crat
|
||||
)
|
||||
)]
|
||||
#[post("/api/v1/users")]
|
||||
pub async fn create_user(payload: web::Json<CreateUserRequest>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
|
||||
let mut req = payload.into_inner();
|
||||
if let Err(e) = req.validate() { return Err(ApiError::BadRequest(format!("validação: {}", e))); }
|
||||
pub async fn create_user(
|
||||
payload: web::Json<CreateUserRequest>,
|
||||
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
|
||||
) -> Result<impl Responder, ApiError> {
|
||||
let req = payload.into_inner();
|
||||
if let Err(e) = req.validate() {
|
||||
return Err(ApiError::BadRequest(format!("validação: {}", e)));
|
||||
}
|
||||
let user = svc.create(req);
|
||||
Ok(HttpResponse::Created().json(user))
|
||||
}
|
||||
@@ -68,9 +80,15 @@ pub async fn create_user(payload: web::Json<CreateUserRequest>, svc: web::Data<U
|
||||
)
|
||||
)]
|
||||
#[put("/api/v1/users/{id}")]
|
||||
pub async fn update_user(path: web::Path<UserPath>, payload: web::Json<UpdateUserRequest>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
|
||||
let mut req = payload.into_inner();
|
||||
if let Err(e) = req.validate() { return Err(ApiError::BadRequest(format!("validação: {}", e))); }
|
||||
pub async fn update_user(
|
||||
path: web::Path<UserPath>,
|
||||
payload: web::Json<UpdateUserRequest>,
|
||||
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
|
||||
) -> Result<impl Responder, ApiError> {
|
||||
let req = payload.into_inner();
|
||||
if let Err(e) = req.validate() {
|
||||
return Err(ApiError::BadRequest(format!("validação: {}", e)));
|
||||
}
|
||||
match svc.update(path.id.clone(), req) {
|
||||
Some(u) => Ok(HttpResponse::Ok().json(u)),
|
||||
None => Err(ApiError::NotFound("Usuário não encontrado".into())),
|
||||
@@ -87,6 +105,13 @@ pub async fn update_user(path: web::Path<UserPath>, payload: web::Json<UpdateUse
|
||||
)
|
||||
)]
|
||||
#[delete("/api/v1/users/{id}")]
|
||||
pub async fn delete_user(path: web::Path<UserPath>, svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>) -> Result<impl Responder, ApiError> {
|
||||
if svc.delete(path.id.clone()) { Ok(HttpResponse::NoContent().finish()) } else { Err(ApiError::NotFound("Usuário não encontrado".into())) }
|
||||
}
|
||||
pub async fn delete_user(
|
||||
path: web::Path<UserPath>,
|
||||
svc: web::Data<UserService<crate::repositories::user_repository::InMemoryUserRepository>>,
|
||||
) -> Result<impl Responder, ApiError> {
|
||||
if svc.delete(path.id.clone()) {
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
} else {
|
||||
Err(ApiError::NotFound("Usuário não encontrado".into()))
|
||||
}
|
||||
}
|
||||
+112
-3
@@ -1,25 +1,134 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use actix_web::http::header::USER_AGENT;
|
||||
use awc::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::utils::log;
|
||||
use crate::ws::{WsEvent, WsEventKind};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ModelInfo {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApiStatus {
|
||||
Reachable,
|
||||
Unreachable,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MethodKind {
|
||||
Native,
|
||||
Docker,
|
||||
Api,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DetectedMethod {
|
||||
pub kind: MethodKind,
|
||||
pub details: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct InstallCheckResult {
|
||||
pub installed: bool,
|
||||
pub in_path: bool,
|
||||
pub version: Option<String>,
|
||||
pub service_running: bool,
|
||||
pub api_status: ApiStatus,
|
||||
pub details: HashMap<String, String>,
|
||||
pub issues: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct InstallResult {
|
||||
pub initiated: bool,
|
||||
pub installed: bool,
|
||||
pub service_started: bool,
|
||||
pub final_api_status: ApiStatus,
|
||||
pub details: HashMap<String, String>,
|
||||
pub issues: Vec<String>,
|
||||
pub timing_ms: HashMap<String, u128>,
|
||||
pub aborted: Option<String>,
|
||||
pub manual_instructions: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct InstallStarted {
|
||||
pub status: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelListFile {
|
||||
models: Vec<ModelInfo>,
|
||||
}
|
||||
|
||||
pub fn read_model_list(path: &str) -> Result<Vec<ModelInfo>, String> {
|
||||
let data = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let list: Vec<ModelInfo> = serde_json::from_str(&data).map_err(|e| e.to_string())?;
|
||||
|
||||
// Tentar deserializar como objeto com campo "models" primeiro
|
||||
if let Ok(file) = serde_json::from_str::<ModelListFile>(&data) {
|
||||
return Ok(file.models);
|
||||
}
|
||||
|
||||
// Se falhar, tentar como array direto (formato antigo)
|
||||
let list: Vec<ModelInfo> = serde_json::from_str(&data).map_err(|e| {
|
||||
format!("Erro ao deserializar modelList.json: {}. Formato esperado: objeto com campo 'models' ou array direto.", e)
|
||||
})?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaTagsResponse {
|
||||
models: Vec<OllamaModelInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaModelInfo {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Verifica se um modelo está instalado no Ollama
|
||||
pub async fn check_model_installed(base_url: &str, model_name: &str) -> Result<bool, String> {
|
||||
let client = Client::default();
|
||||
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
|
||||
|
||||
let mut resp = client
|
||||
.get(&url)
|
||||
.insert_header((USER_AGENT, "OllaHubBackend/1.0"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Erro ao conectar com Ollama: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Ollama API retornou erro: {}", resp.status()));
|
||||
}
|
||||
|
||||
let body = resp.body().await.map_err(|e| format!("Erro ao ler resposta: {}", e))?;
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
|
||||
let tags: OllamaTagsResponse = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("Erro ao parsear resposta do Ollama: {}", e))?;
|
||||
|
||||
// Verificar se o modelo está na lista (comparação exata ou parcial)
|
||||
let installed = tags.models.iter().any(|m| {
|
||||
m.name == model_name ||
|
||||
m.name.starts_with(&format!("{}:", model_name)) ||
|
||||
m.name == format!("{}:latest", model_name)
|
||||
});
|
||||
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
pub async fn pull_model(
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
|
||||
+66
-1226
File diff suppressed because it is too large.
Load diff
+66
-5
@@ -7,6 +7,30 @@ use serde::Deserialize;
|
||||
|
||||
use crate::state::AppContext;
|
||||
|
||||
/// Adiciona headers CORS a uma resposta HTTP
|
||||
fn add_cors_headers(mut res: HttpResponse) -> HttpResponse {
|
||||
let allowed_origin = std::env::var("ALLOWED_ORIGINS")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
|
||||
res.headers_mut().insert(
|
||||
header::HeaderName::from_static("access-control-allow-origin"),
|
||||
header::HeaderValue::from_str(&allowed_origin).unwrap_or_else(|_| header::HeaderValue::from_static("*"))
|
||||
);
|
||||
res.headers_mut().insert(
|
||||
header::HeaderName::from_static("access-control-allow-credentials"),
|
||||
header::HeaderValue::from_static("true")
|
||||
);
|
||||
res.headers_mut().insert(
|
||||
header::HeaderName::from_static("access-control-allow-methods"),
|
||||
header::HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||
);
|
||||
res.headers_mut().insert(
|
||||
header::HeaderName::from_static("access-control-allow-headers"),
|
||||
header::HeaderValue::from_static("Content-Type, Authorization, Accept, Accept-Language, Accept-Encoding")
|
||||
);
|
||||
res
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Claims { sub: String, exp: usize, role: Option<String> }
|
||||
|
||||
@@ -14,15 +38,40 @@ pub async fn jwt_auth<B>(req: ServiceRequest, next: Next<B>) -> Result<ServiceRe
|
||||
where
|
||||
B: MessageBody + 'static,
|
||||
{
|
||||
// Apenas protege rotas da API v1; demais passam direto
|
||||
if !req.path().starts_with("/api/v1/") {
|
||||
// Permitir requisições OPTIONS (preflight CORS) sem autenticação
|
||||
if req.method() == actix_web::http::Method::OPTIONS {
|
||||
let res = next.call(req).await?;
|
||||
return Ok(res.map_into_boxed_body());
|
||||
}
|
||||
|
||||
let p = req.path();
|
||||
|
||||
// Excluir WebSocket do middleware JWT (precisa upgrade HTTP antes de autenticação)
|
||||
if p.starts_with("/v2/ws") || p.starts_with("/ws") {
|
||||
let res = next.call(req).await?;
|
||||
return Ok(res.map_into_boxed_body());
|
||||
}
|
||||
|
||||
let protected = p.starts_with("/api/v1/") || p.starts_with("/v2/api/crypto") || p.starts_with("/v2/api/models");
|
||||
if !protected {
|
||||
let res = next.call(req).await?;
|
||||
return Ok(res.map_into_boxed_body());
|
||||
}
|
||||
let ctx = req.app_data::<actix_web::web::Data<AppContext>>().cloned();
|
||||
let Some(state) = ctx else { return Ok(req.into_response(HttpResponse::InternalServerError().finish())); };
|
||||
let Some(state) = ctx else {
|
||||
let res = add_cors_headers(HttpResponse::InternalServerError().finish());
|
||||
return Ok(req.into_response(res));
|
||||
};
|
||||
let Some(hdr) = req.headers().get(header::AUTHORIZATION) else {
|
||||
return Ok(req.into_response(HttpResponse::Unauthorized().finish()));
|
||||
let allowed_origin = std::env::var("ALLOWED_ORIGINS")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let res = HttpResponse::Unauthorized()
|
||||
.insert_header(("access-control-allow-origin", allowed_origin))
|
||||
.insert_header(("access-control-allow-credentials", "true"))
|
||||
.insert_header(("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH"))
|
||||
.insert_header(("access-control-allow-headers", "Content-Type, Authorization, Accept, Accept-Language, Accept-Encoding"))
|
||||
.finish();
|
||||
return Ok(req.into_response(res));
|
||||
};
|
||||
let token = hdr.to_str().unwrap_or("");
|
||||
let token = token.strip_prefix("Bearer ").unwrap_or(token);
|
||||
@@ -33,6 +82,18 @@ where
|
||||
let res = next.call(req).await?;
|
||||
Ok(res.map_into_boxed_body())
|
||||
}
|
||||
Err(_) => Ok(req.into_response(HttpResponse::Unauthorized().finish())),
|
||||
Err(e) => {
|
||||
// Log do erro para debug (pode remover depois)
|
||||
eprintln!("[JWT Auth] Erro ao decodificar token: {:?}", e);
|
||||
let allowed_origin = std::env::var("ALLOWED_ORIGINS")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let res = HttpResponse::Unauthorized()
|
||||
.insert_header(("access-control-allow-origin", allowed_origin))
|
||||
.insert_header(("access-control-allow-credentials", "true"))
|
||||
.insert_header(("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH"))
|
||||
.insert_header(("access-control-allow-headers", "Content-Type, Authorization, Accept, Accept-Language, Accept-Encoding"))
|
||||
.finish();
|
||||
Ok(req.into_response(res))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 error;
|
||||
pub mod cors;
|
||||
pub mod error;
|
||||
pub mod logging;
|
||||
@@ -15,20 +15,46 @@ use crate::state::AppContext;
|
||||
crate::controllers::user_controller::create_user,
|
||||
crate::controllers::user_controller::update_user,
|
||||
crate::controllers::user_controller::delete_user,
|
||||
crate::api::health,
|
||||
crate::api::models_list,
|
||||
crate::api::models_install,
|
||||
crate::api::ollama_check,
|
||||
crate::api::ollama_install,
|
||||
crate::api::crypto_set_key,
|
||||
crate::api::crypto_rotate_key,
|
||||
crate::api::crypto_encrypt,
|
||||
crate::api::crypto_decrypt,
|
||||
),
|
||||
components(schemas(
|
||||
crate::models::user::User,
|
||||
crate::models::user::CreateUserRequest,
|
||||
crate::models::user::UpdateUserRequest,
|
||||
crate::middlewares::error::ApiErrorBody,
|
||||
crate::core::models::ModelInfo,
|
||||
crate::core::models::InstallCheckResult,
|
||||
crate::core::models::InstallStarted,
|
||||
crate::core::models::ApiStatus,
|
||||
crate::api::HealthResponse,
|
||||
crate::api::InstallReq,
|
||||
crate::api::ModelInstallStarted,
|
||||
crate::api::OllamaInstallReq,
|
||||
crate::api::OllamaInstallStarted,
|
||||
crate::api::SetKeyReq,
|
||||
crate::api::SimpleOk,
|
||||
crate::api::RotateKeyResponse,
|
||||
crate::api::CryptReq,
|
||||
crate::api::EncryptResponse,
|
||||
crate::api::DecryptResponse,
|
||||
)),
|
||||
tags(
|
||||
(name = "users", description = "Gerenciamento de usuários"),
|
||||
(name = "v2", description = "APIs v2: saúde, modelos, ollama, criptografia"),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
pub fn configure_v1(cfg: &mut web::ServiceConfig, _state: web::Data<AppContext>) {
|
||||
cfg.app_data(_state.clone());
|
||||
let repo = InMemoryUserRepository::new();
|
||||
let svc = UserService::new(repo);
|
||||
cfg.app_data(web::Data::new(svc));
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
pub mod system;
|
||||
pub mod install;
|
||||
pub mod ollama;
|
||||
pub mod user_service;
|
||||
pub mod ollama_install;
|
||||
pub mod system;
|
||||
pub mod user_service;
|
||||
pub mod windows_permissions;
|
||||
@@ -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 serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Platform {
|
||||
Linux { distro: Option<String> },
|
||||
Windows,
|
||||
Linux,
|
||||
MacOS,
|
||||
Macos,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub fn detect_platform() -> Platform {
|
||||
if cfg!(target_os = "windows") {
|
||||
Platform::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Platform::MacOS
|
||||
} else {
|
||||
Platform::Linux
|
||||
impl Platform {
|
||||
pub fn detect() -> Self {
|
||||
let os = env::consts::OS;
|
||||
match os {
|
||||
"linux" => Platform::Linux { distro: Self::detect_linux_distro() },
|
||||
"windows" => Platform::Windows,
|
||||
"macos" => Platform::Macos,
|
||||
other => Platform::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_linux_distro() -> Option<String> {
|
||||
let content = fs::read_to_string("/etc/os-release").ok()?;
|
||||
for line in content.lines() {
|
||||
if let Some(stripped) = line.strip_prefix("ID=") {
|
||||
return Some(stripped.trim_matches('\"').to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_cmd(cmd: &str, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new(cmd).args(args).output().map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
let output = Command::new(cmd).args(args).output();
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if out.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
Ok(stdout)
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
Err(format!("command failed: {} {:?} -> {}", cmd, args, stderr))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("command error: {} {:?} -> {}", cmd, args, e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_exists(path: &PathBuf) -> bool {
|
||||
fs::metadata(path).is_ok()
|
||||
}
|
||||
|
||||
pub fn info(msg: &str) {
|
||||
println!("[INFO] {}", msg);
|
||||
}
|
||||
|
||||
pub fn warn(msg: &str) {
|
||||
println!("[AVISO] {}", msg);
|
||||
}
|
||||
|
||||
pub fn error(msg: &str) {
|
||||
eprintln!("[ERRO] {}", msg);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use crate::services::system;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionStatus {
|
||||
pub admin: bool,
|
||||
pub firewall_configured: bool,
|
||||
pub defender_exception: bool,
|
||||
pub network_ok: bool,
|
||||
pub issues: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct WindowsPermissions;
|
||||
|
||||
impl WindowsPermissions {
|
||||
/// Verifica se o processo está rodando com privilégios de administrador
|
||||
pub fn check_admin() -> bool {
|
||||
let ps_cmd = r#"[Security.Principal.WindowsPrincipal]::Current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
|
||||
match system::run_cmd("powershell", &["-NoProfile", "-Command", ps_cmd]) {
|
||||
Ok(output) => {
|
||||
let trimmed = output.trim().to_lowercase();
|
||||
trimmed == "true"
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica se uma regra de firewall já existe
|
||||
fn firewall_rule_exists(display_name: &str) -> bool {
|
||||
let ps_cmd = format!(
|
||||
r#"Get-NetFirewallRule -DisplayName '{}' -ErrorAction SilentlyContinue | Select-Object -First 1"#,
|
||||
display_name
|
||||
);
|
||||
match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) {
|
||||
Ok(output) => !output.trim().is_empty(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adiciona regra no firewall do Windows para uma porta específica
|
||||
pub fn request_firewall_rule(port: u16, name: &str) -> Result<(), String> {
|
||||
let display_name = format!("OllaHubBackend {}", name);
|
||||
|
||||
// Verificar se já existe
|
||||
if Self::firewall_rule_exists(&display_name) {
|
||||
system::info(&format!("Regra de firewall '{}' já existe", display_name));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Verificar se tem privilégios de admin
|
||||
if !Self::check_admin() {
|
||||
return Err(format!(
|
||||
"Privilégios de administrador necessários para adicionar regra de firewall na porta {}. Execute como Administrador.",
|
||||
port
|
||||
));
|
||||
}
|
||||
|
||||
// Criar regra de firewall
|
||||
let ps_cmd = format!(
|
||||
r#"New-NetFirewallRule -DisplayName '{}' -Direction Inbound -LocalPort {} -Protocol TCP -Action Allow -Profile Domain,Private,Public"#,
|
||||
display_name, port
|
||||
);
|
||||
|
||||
match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) {
|
||||
Ok(_) => {
|
||||
system::info(&format!("Regra de firewall criada para porta {} ({})", port, name));
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Falha ao criar regra de firewall: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtém o caminho do executável atual
|
||||
fn get_executable_path() -> Result<PathBuf, String> {
|
||||
env::current_exe().map_err(|e| format!("Não foi possível obter caminho do executável: {}", e))
|
||||
}
|
||||
|
||||
/// Verifica se exceção já existe no Windows Defender
|
||||
fn defender_exception_exists(path: &str) -> bool {
|
||||
let ps_cmd = format!(
|
||||
r#"Get-MpPreference | Select-Object -ExpandProperty ExclusionPath | Where-Object {{ $_ -eq '{}' }}"#,
|
||||
path.replace('\\', "\\\\")
|
||||
);
|
||||
match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) {
|
||||
Ok(output) => !output.trim().is_empty(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adiciona exceção no Windows Defender para o executável
|
||||
pub fn request_defender_exception(exe_path: &str) -> Result<(), String> {
|
||||
// Verificar se já existe
|
||||
if Self::defender_exception_exists(exe_path) {
|
||||
system::info(&format!("Exceção do Windows Defender já existe para: {}", exe_path));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Verificar se tem privilégios de admin
|
||||
if !Self::check_admin() {
|
||||
return Err(format!(
|
||||
"Privilégios de administrador necessários para adicionar exceção no Windows Defender. Execute como Administrador."
|
||||
));
|
||||
}
|
||||
|
||||
// Adicionar exceção de caminho
|
||||
let ps_cmd = format!(
|
||||
r#"Add-MpPreference -ExclusionPath '{}'"#,
|
||||
exe_path.replace('\'', "''")
|
||||
);
|
||||
|
||||
match system::run_cmd("powershell", &["-NoProfile", "-Command", &ps_cmd]) {
|
||||
Ok(_) => {
|
||||
system::info(&format!("Exceção do Windows Defender adicionada para: {}", exe_path));
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Falha ao adicionar exceção no Windows Defender: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica permissões de rede (básico)
|
||||
pub fn request_network_permissions() -> Result<(), String> {
|
||||
// Verificação básica: tentar criar um socket de teste
|
||||
// Se falhar, pode indicar problemas de permissão
|
||||
// Por enquanto, apenas retorna Ok se estiver no Windows
|
||||
if cfg!(target_os = "windows") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Verificação de permissões de rede apenas disponível no Windows".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Configura todas as permissões necessárias
|
||||
pub fn setup_all_permissions() -> Result<PermissionStatus, String> {
|
||||
system::info("Verificando e configurando permissões do Windows...");
|
||||
|
||||
let mut status = PermissionStatus {
|
||||
admin: Self::check_admin(),
|
||||
firewall_configured: false,
|
||||
defender_exception: false,
|
||||
network_ok: false,
|
||||
issues: Vec::new(),
|
||||
};
|
||||
|
||||
// Log status de admin
|
||||
system::info("[1/4] Verificando privilégios de administrador...");
|
||||
if status.admin {
|
||||
system::info(" ✓ Executando com privilégios de administrador");
|
||||
} else {
|
||||
system::warn(" ⚠ Não está executando como administrador. Algumas configurações podem falhar.");
|
||||
status.issues.push("Execução sem privilégios de administrador".to_string());
|
||||
}
|
||||
|
||||
// Configurar firewall para porta 8080 (API)
|
||||
system::info("[2/4] Configurando regras de firewall...");
|
||||
system::info(" → Porta 8080 (API HTTP)...");
|
||||
match Self::request_firewall_rule(8080, "API") {
|
||||
Ok(_) => {
|
||||
status.firewall_configured = true;
|
||||
system::info(" ✓ Regra de firewall criada para porta 8080");
|
||||
}
|
||||
Err(e) => {
|
||||
status.issues.push(format!("Firewall porta 8080: {}", e));
|
||||
system::warn(&format!(" ✗ Falha: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Configurar firewall para porta 11434 (Ollama)
|
||||
system::info(" → Porta 11434 (Ollama)...");
|
||||
match Self::request_firewall_rule(11434, "Ollama") {
|
||||
Ok(_) => {
|
||||
// Se a porta 8080 foi configurada, manter firewall_configured como true
|
||||
if !status.firewall_configured {
|
||||
status.firewall_configured = true;
|
||||
}
|
||||
system::info(" ✓ Regra de firewall criada para porta 11434");
|
||||
}
|
||||
Err(e) => {
|
||||
status.issues.push(format!("Firewall porta 11434: {}", e));
|
||||
system::warn(&format!(" ✗ Falha: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Configurar exceção no Windows Defender
|
||||
system::info("[3/4] Configurando exceção no Windows Defender...");
|
||||
match Self::get_executable_path() {
|
||||
Ok(exe_path) => {
|
||||
let exe_str = exe_path.to_string_lossy().to_string();
|
||||
system::info(&format!(" → Executável: {}", exe_str));
|
||||
match Self::request_defender_exception(&exe_str) {
|
||||
Ok(_) => {
|
||||
status.defender_exception = true;
|
||||
system::info(" ✓ Exceção adicionada no Windows Defender");
|
||||
}
|
||||
Err(e) => {
|
||||
status.issues.push(format!("Windows Defender: {}", e));
|
||||
system::warn(&format!(" ✗ Falha: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
status.issues.push(format!("Não foi possível obter caminho do executável: {}", e));
|
||||
system::warn(&format!(" ✗ Não foi possível configurar exceção no Windows Defender: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar permissões de rede
|
||||
system::info("[4/4] Verificando permissões de rede...");
|
||||
match Self::request_network_permissions() {
|
||||
Ok(_) => {
|
||||
status.network_ok = true;
|
||||
system::info(" ✓ Permissões de rede OK");
|
||||
}
|
||||
Err(e) => {
|
||||
status.issues.push(format!("Permissões de rede: {}", e));
|
||||
system::warn(&format!(" ✗ Falha: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Resumo final
|
||||
system::info("========================================");
|
||||
if status.issues.is_empty() {
|
||||
system::info("✓ Todas as permissões foram configuradas com sucesso!");
|
||||
} else {
|
||||
system::warn(&format!("⚠ {} problema(s) encontrado(s) durante configuração de permissões", status.issues.len()));
|
||||
system::info("");
|
||||
system::info("📋 Instruções manuais para resolver problemas:");
|
||||
system::info("");
|
||||
if !status.admin {
|
||||
system::info(" 1. PRIVILÉGIOS DE ADMINISTRADOR:");
|
||||
system::info(" → Clique com botão direito no executável");
|
||||
system::info(" → Selecione 'Executar como administrador'");
|
||||
system::info("");
|
||||
}
|
||||
if !status.firewall_configured {
|
||||
system::info(" 2. REGRAS DE FIREWALL:");
|
||||
system::info(" → Abra: Painel de Controle > Firewall do Windows > Configurações avançadas");
|
||||
system::info(" → Clique em 'Regras de entrada' > 'Nova regra...'");
|
||||
system::info(" → Tipo: Porta > TCP > Portas específicas: 8080, 11434");
|
||||
system::info(" → Ação: Permitir a conexão");
|
||||
system::info(" → Nome: 'OllaHubBackend API' e 'OllaHubBackend Ollama'");
|
||||
system::info("");
|
||||
}
|
||||
if !status.defender_exception {
|
||||
system::info(" 3. EXCEÇÃO NO WINDOWS DEFENDER:");
|
||||
if let Ok(exe_path) = Self::get_executable_path() {
|
||||
system::info(" → Abra: Windows Security > Virus & threat protection");
|
||||
system::info(" → Clique em 'Manage settings' (Gerenciar configurações)");
|
||||
system::info(" → Role até 'Exclusions' > 'Add or remove exclusions'");
|
||||
system::info(" → Adicione exceção de 'File' com o caminho:");
|
||||
system::info(&format!(" {}", exe_path.to_string_lossy()));
|
||||
}
|
||||
system::info("");
|
||||
}
|
||||
}
|
||||
system::info("========================================");
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -16,10 +16,14 @@ pub struct AppContext {
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
pub fn new(ollama_base_url: String, model_list_path: String, key_path: PathBuf) -> Self {
|
||||
pub fn new(
|
||||
ollama_base_url: String,
|
||||
model_list_path: String,
|
||||
key_path: PathBuf,
|
||||
jwt_secret: String,
|
||||
) -> Self {
|
||||
let broker = crate::ws::new_broker();
|
||||
let crypto = Arc::new(CryptoState::new(key_path));
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "changeme-dev-secret".into());
|
||||
Self {
|
||||
broker,
|
||||
crypto,
|
||||
@@ -28,4 +32,4 @@ impl AppContext {
|
||||
jwt_secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+378
@@ -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_actors::ws;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -6,6 +6,8 @@ use tokio::sync::broadcast;
|
||||
|
||||
use crate::utils::log;
|
||||
|
||||
pub mod chat;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum WsEventKind {
|
||||
Log,
|
||||
@@ -35,7 +37,7 @@ pub struct WsSession {
|
||||
impl Actor for WsSession {
|
||||
type Context = ws::WebsocketContext<Self>;
|
||||
|
||||
fn started(&mut self, ctx: &mut Self::Context) {
|
||||
fn started(&mut self, _ctx: &mut Self::Context) {
|
||||
let mut rx = self.broker.subscribe();
|
||||
// Forward broker messages to the websocket client
|
||||
actix_rt::spawn(async move {
|
||||
|
||||
Reference in new issue
Block a user