chore: mover docs, scripts e arquivos obsoletos para dental-server/bkp/
continuous-integration/webhook Deploy concluido (rx.scoreodonto.com)
continuous-integration/webhook Deploy concluido (rx.scoreodonto.com)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
═══════════════════════════════════════════════════════════
|
||||
ARQUIVOS FINAIS DO SERVIDOR - DENTAL SERVER v2.0
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
✅ TOTAL: 19 ARQUIVOS CRIADOS
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📁 ESTRUTURA COMPLETA
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
dental-server/
|
||||
│
|
||||
├── 📄 server.js ← ARQUIVO PRINCIPAL
|
||||
├── 📄 auth.js ← Autenticação JWT
|
||||
├── 📄 database.js ← Conexão SQLite
|
||||
├── 📄 image-processor.js ← Processamento Sharp
|
||||
├── 📄 package.json ← Dependências
|
||||
├── 📄 migration-v2.sql ← Migration do banco
|
||||
│
|
||||
├── 📄 ENV-CONFIG.txt ← Modelo do .env
|
||||
├── 📄 COMO-USAR-ENV.txt ← Como criar .env
|
||||
│
|
||||
├── 📁 auth/
|
||||
│ └── login.html ← Página de login
|
||||
│
|
||||
├── 📁 public/
|
||||
│ ├── index.html ← Interface web
|
||||
│ ├── style.css ← Estilos
|
||||
│ └── app.js ← JavaScript
|
||||
│
|
||||
├── 📁 routes/
|
||||
│ ├── auth.js ← Rotas de autenticação
|
||||
│ └── images.js ← API de imagens (8 endpoints)
|
||||
│
|
||||
├── 📁 installer/
|
||||
│ └── check-installation.js ← Verificação de instalação
|
||||
│
|
||||
└── 📁 DOCUMENTAÇÃO/
|
||||
├── README.md ← Visão geral
|
||||
├── INSTALACAO-DENTAL-SERVER.md ← Manual completo
|
||||
├── INSTRUCOES-RAPIDAS.txt ← Guia rápido
|
||||
├── CHECKLIST-IMPLEMENTACAO.md ← Checklist
|
||||
├── RESUMO-IMPLEMENTACAO.md ← Resumo executivo
|
||||
├── LISTA-ARQUIVOS-CRIADOS.txt ← Lista de arquivos
|
||||
└── ARQUIVOS-FINAIS.txt ← Este arquivo
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📝 ARQUIVOS QUE VOCÊ PRECISA CRIAR NO SERVIDOR
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
⚠️ O arquivo .env é BLOQUEADO por segurança
|
||||
|
||||
Crie manualmente seguindo COMO-USAR-ENV.txt:
|
||||
|
||||
1. No servidor, crie um arquivo chamado .env
|
||||
2. Copie o conteúdo de ENV-CONFIG.txt
|
||||
3. Ajuste os valores conforme seu setup
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🚀 COMO INICIAR O SERVIDOR
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. Upload de TODOS os arquivos para /www/wwwroot/dental-server/
|
||||
2. Criar arquivo .env (ver COMO-USAR-ENV.txt)
|
||||
3. npm install
|
||||
4. sqlite3 database.sqlite < migration-v2.sql
|
||||
5. pm2 start server.js --name dental-server
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
✅ FUNCIONALIDADES IMPLEMENTADAS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
✅ Servidor Express + Socket.IO
|
||||
✅ API REST completa (8 endpoints)
|
||||
✅ Interface web responsiva
|
||||
✅ Sistema de transformações
|
||||
✅ Gerenciamento de pacientes
|
||||
✅ Download personalizado
|
||||
✅ Autenticação JWT
|
||||
✅ Banco de dados SQLite
|
||||
✅ Upload e processamento de imagens
|
||||
✅ Socket.IO em tempo real
|
||||
✅ Documentação completa
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📊 ESTATÍSTICAS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Total de arquivos: 19
|
||||
Total de linhas: ~3.500+
|
||||
- Backend: ~1.500 linhas
|
||||
- Frontend: ~1.500 linhas
|
||||
- Documentação: ~1.500 linhas
|
||||
|
||||
Endpoints API: 8
|
||||
Rotas de autenticação: 3
|
||||
Métodos de processamento: 9
|
||||
Páginas web: 2 (login + gerenciador)
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🎉 PROJETO 100% COMPLETO
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Status: ✅ IMPLEMENTAÇÃO COMPLETA
|
||||
Próximo: Upload e deploy no VPS
|
||||
Data: 2025-01-26
|
||||
Versão: 2.0.0
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
@@ -0,0 +1,163 @@
|
||||
# ✅ CHECKLIST DE IMPLEMENTAÇÃO
|
||||
|
||||
## 📦 Arquivos Criados
|
||||
|
||||
### ✅ BACKEND
|
||||
- [x] `routes/images.js` - API completa de imagens
|
||||
- [x] `image-processor.js` - Processamento (rotação, flip, etc)
|
||||
- [x] `installer/check-installation.js` - Verificação de instalação
|
||||
- [x] `migration-v2.sql` - Migration do banco de dados
|
||||
- [x] `package.json` - Dependências do servidor
|
||||
|
||||
### ✅ FRONTEND
|
||||
- [x] `public/index.html` - Interface web principal
|
||||
- [x] `public/style.css` - Estilos responsivos
|
||||
- [x] `public/app.js` - Lógica JavaScript
|
||||
|
||||
### ✅ DOCUMENTAÇÃO
|
||||
- [x] `README.md` - Documentação geral
|
||||
- [x] `INSTALACAO-DENTAL-SERVER.md` - Manual de instalação
|
||||
- [x] `CHECKLIST-IMPLEMENTACAO.md` - Este arquivo
|
||||
|
||||
## 🚧 Arquivos que Ainda Precisam Ser Criados/Atualizados
|
||||
|
||||
### ⚠️ CRIAR NO SERVIDOR (Se não existirem)
|
||||
- [ ] `server.js` - Arquivo principal
|
||||
- [ ] `database.js` - Conexão SQLite
|
||||
- [ ] `auth.js` - Autenticação JWT
|
||||
- [ ] `.env` - Configurações
|
||||
|
||||
### ⚠️ ATUALIZAR NO SERVIDOR (Se já existirem)
|
||||
|
||||
#### `server.js` - ADICIONAR:
|
||||
```javascript
|
||||
// Importar rotas
|
||||
const imageRoutes = require('./routes/images');
|
||||
app.use('/api/images', imageRoutes);
|
||||
|
||||
// Static files
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Routes existentes
|
||||
app.get('/', authenticateToken, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
```
|
||||
|
||||
#### `image-processor.js` - ADICIONAR:
|
||||
Os métodos novos já foram criados no arquivo gerado:
|
||||
- ✅ `rotate()`
|
||||
- ✅ `flip()`
|
||||
- ✅ `rotateAndFlip()`
|
||||
- ✅ `getThumbnail()`
|
||||
- ✅ `getBase64Preview()`
|
||||
|
||||
Se já existe um arquivo `image-processor.js`, **adicione os novos métodos** ou **substitua** pelo novo.
|
||||
|
||||
## 🔄 Próximos Passos
|
||||
|
||||
### 1. Upload no VPS
|
||||
Faça upload de TODOS os arquivos para `/www/wwwroot/dental-server`
|
||||
|
||||
### 2. Verificar Dependências
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Executar Migration
|
||||
```bash
|
||||
sqlite3 database.sqlite < migration-v2.sql
|
||||
```
|
||||
|
||||
### 4. Atualizar server.js
|
||||
Certifique-se de incluir as rotas e middlewares necessários
|
||||
|
||||
### 5. Configurar Nginx
|
||||
Adicionar proxy para Socket.IO
|
||||
|
||||
### 6. Testar
|
||||
- Login
|
||||
- Upload de imagem
|
||||
- Transformações
|
||||
- Download
|
||||
- Habilitar/Desabilitar
|
||||
|
||||
## 📋 Verificação Final
|
||||
|
||||
Antes de considerar concluído, verifique:
|
||||
|
||||
- [ ] Todas as rotas funcionam
|
||||
- [ ] Socket.IO conecta corretamente
|
||||
- [ ] Imagens são processadas
|
||||
- [ ] Transformações funcionam
|
||||
- [ ] Download gera nome correto
|
||||
- [ ] Habilitar/Desabilitar funciona
|
||||
- [ ] Apagar versões funciona
|
||||
- [ ] Interface é responsiva
|
||||
- [ ] Migrations executadas
|
||||
- [ ] PM2 rodando corretamente
|
||||
- [ ] Nginx configurado
|
||||
- [ ] SSL funcionando
|
||||
- [ ] Logs sem erros críticos
|
||||
|
||||
## 🎯 Funcionalidades Implementadas
|
||||
|
||||
### Gerenciador de Imagens
|
||||
- [x] Listagem de imagens
|
||||
- [x] Grid responsivo
|
||||
- [x] Busca em tempo real
|
||||
- [x] Filtro habilitadas/desabilitadas
|
||||
- [x] Preview de imagens
|
||||
- [x] Informações do paciente
|
||||
|
||||
### Sistema de Transformações
|
||||
- [x] 5 opções de transformação
|
||||
- [x] Preview antes de salvar
|
||||
- [x] Processamento server-side
|
||||
- [x] Salvar múltiplas versões
|
||||
|
||||
### Gerenciamento de Pacientes
|
||||
- [x] Configurar dados do dente
|
||||
- [x] Nome resumido
|
||||
- [x] Número do dente
|
||||
- [x] Lado inicial/final
|
||||
- [x] Download personalizado
|
||||
|
||||
### Controle de Versões
|
||||
- [x] Manter original sempre
|
||||
- [x] Excluir versões
|
||||
- [x] Habilitar/Desabilitar
|
||||
- [x] Rastreamento de transformações
|
||||
|
||||
### Sistema de Notificações
|
||||
- [x] Toast notifications
|
||||
- [x] Socket.IO updates
|
||||
- [x] Auto-refresh
|
||||
|
||||
### Segurança
|
||||
- [x] JWT authentication
|
||||
- [x] Validação de inputs
|
||||
- [x] Proteção de rotas
|
||||
- [x] SQL injection prevention
|
||||
|
||||
## 📝 Notas Importantes
|
||||
|
||||
1. **Backup do banco ANTES de executar migrations**
|
||||
2. **Teste em ambiente de desenvolvimento primeiro**
|
||||
3. **Verifique logs do PM2 regularmente**
|
||||
4. **Monitore uso de memória e CPU**
|
||||
5. **Faça backup periódico das imagens**
|
||||
|
||||
## 🆘 Em Caso de Problemas
|
||||
|
||||
1. Ver logs: `pm2 logs dental-server`
|
||||
2. Verificar banco: `sqlite3 database.sqlite "SELECT * FROM images LIMIT 5;"`
|
||||
3. Verificar Nginx: `tail -f /var/log/nginx/error.log`
|
||||
4. Reiniciar: `pm2 restart dental-server`
|
||||
5. Consultar: `INSTALACAO-DENTAL-SERVER.md`
|
||||
|
||||
---
|
||||
|
||||
**Status:** Implementação básica completa ✅
|
||||
**Próximo:** Upload e testes no VPS
|
||||
**Data:** 2025-01-26
|
||||
@@ -0,0 +1,79 @@
|
||||
═══════════════════════════════════════════════════════════
|
||||
COMO CRIAR O ARQUIVO .env
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
O arquivo .env é BLOQUEADO pelo sistema de segurança do Cursor.
|
||||
|
||||
POR ISSO, você precisa criar manualmente no servidor.
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📝 MÉTODO 1: Via Terminal (SSH no VPS)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. Conecte-se ao VPS via SSH
|
||||
2. Navegue até a pasta:
|
||||
cd /www/wwwroot/dental-server
|
||||
|
||||
3. Crie o arquivo .env:
|
||||
nano .env
|
||||
|
||||
4. Cole o conteúdo do arquivo ENV-CONFIG.txt
|
||||
(está na mesma pasta)
|
||||
|
||||
5. Ajuste os valores:
|
||||
- ALLOWED_ORIGINS=https://seu-dominio-real.com
|
||||
- JWT_SECRET=sua_chave_secreta_aleatoria
|
||||
- SESSION_SECRET=outra_chave_aleatoria
|
||||
- Caminhos se necessário
|
||||
|
||||
6. Salve: Ctrl+O, Enter, Ctrl+X
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📝 MÉTODO 2: Via aaPanel (File Manager)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. Acesse aaPanel → File Manager
|
||||
2. Navegue até: /www/wwwroot/dental-server/
|
||||
3. Clique em "New File"
|
||||
4. Nome: .env
|
||||
5. Clique em "Edit"
|
||||
6. Cole o conteúdo do arquivo ENV-CONFIG.txt
|
||||
7. Ajuste os valores
|
||||
8. Salve
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📝 MÉTODO 3: Via Upload
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. No Windows, crie um arquivo chamado .env (ou env.txt)
|
||||
2. Cole o conteúdo do ENV-CONFIG.txt
|
||||
3. Ajuste os valores
|
||||
4. Faça upload via FTP/SFTP para /www/wwwroot/dental-server/
|
||||
5. Renomeie para .env (sem extensão)
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
⚠️ IMPORTANTE
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
- O arquivo deve se chamar EXATAMENTE: .env
|
||||
- Não pode ter extensão (.txt, etc)
|
||||
- Deve estar na raiz do dental-server/
|
||||
- Conteúdo do arquivo ENV-CONFIG.txt está como referência
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Valores que DEVEM ser alterados:
|
||||
✅ ALLOWED_ORIGINS (seu domínio real)
|
||||
✅ JWT_SECRET (chave aleatória)
|
||||
✅ SESSION_SECRET (chave aleatória)
|
||||
✅ DB_PASSWORD (senha do MySQL)
|
||||
|
||||
Valores que podem manter:
|
||||
✅ PORT=3000 (padrão)
|
||||
✅ DB_HOST=localhost
|
||||
✅ DB_PORT=3306
|
||||
✅ DB_USER=root
|
||||
✅ DB_NAME=dental_images
|
||||
✅ UPLOAD_DIR e PROCESSED_DIR (ajustar se necessário)
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
@@ -0,0 +1,67 @@
|
||||
# ================================================================
|
||||
# DENTAL IMAGE SERVER - CONFIGURAÇÕES (.env)
|
||||
# ================================================================
|
||||
# CONFIGURADO PARA: https://chat.consulttclinic.com
|
||||
# Banco de dados: dental_images
|
||||
# ================================================================
|
||||
|
||||
# Porta do servidor (padrão 3000, ajustar se necessário)
|
||||
PORT=3000
|
||||
|
||||
# Ambiente
|
||||
NODE_ENV=production
|
||||
|
||||
# Chaves JWT de segurança (já geradas, manter)
|
||||
JWT_SECRET=9f8e7d6c5b4a39281802384f5e6d7c8b9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5
|
||||
SESSION_SECRET=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
|
||||
|
||||
# ================================================================
|
||||
# BANCO DE DADOS MySQL
|
||||
# ================================================================
|
||||
# Configure sua senha MySQL abaixo
|
||||
# ================================================================
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=SUA_SENHA_AQUI
|
||||
DB_NAME=dental_images
|
||||
|
||||
# ================================================================
|
||||
# DIRETÓRIOS DE UPLOAD
|
||||
# ================================================================
|
||||
|
||||
UPLOAD_DIR=/www/wwwroot/dental-server/uploads
|
||||
PROCESSED_DIR=/www/wwwroot/dental-server/processed
|
||||
|
||||
# ================================================================
|
||||
# DOMÍNIO E SSL
|
||||
# ================================================================
|
||||
|
||||
ALLOWED_ORIGINS=https://chat.consulttclinic.com
|
||||
TRUST_PROXY=true
|
||||
|
||||
# ================================================================
|
||||
# CONFIGURAÇÕES ADICIONAIS
|
||||
# ================================================================
|
||||
|
||||
# Timeout para operações assíncronas (ms)
|
||||
SERVER_TIMEOUT=30000
|
||||
|
||||
# Tamanho máximo de upload (bytes) - 50MB
|
||||
MAX_UPLOAD_SIZE=52428800
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS
|
||||
CORS_CREDENTIALS=true
|
||||
|
||||
# ================================================================
|
||||
# NOTAS IMPORTANTES
|
||||
# ================================================================
|
||||
# 1. AJUSTE APENAS: DB_PASSWORD (sua senha do MySQL)
|
||||
# 2. Mantenha as chaves JWT como estão (já geradas)
|
||||
# 3. O servidor cria o banco automaticamente se não existir
|
||||
# 4. Certifique-se de que MySQL está rodando
|
||||
# ================================================================
|
||||
@@ -0,0 +1,36 @@
|
||||
Listagem de caminhos de pasta
|
||||
O n£mero de s‚rie do volume ‚ FEE1-3812
|
||||
C:.
|
||||
| .env.example
|
||||
| ARQUIVOS-FINAIS.txt
|
||||
| auth.js
|
||||
| CHECKLIST-IMPLEMENTACAO.md
|
||||
| COMO-USAR-ENV.txt
|
||||
| database.js
|
||||
| ENV-CONFIG.txt
|
||||
| ESTRUTURA-COMPLETA.txt
|
||||
| image-processor.js
|
||||
| INSTALACAO-DENTAL-SERVER.md
|
||||
| INSTRUCOES-RAPIDAS.txt
|
||||
| LISTA-ARQUIVOS-CRIADOS.txt
|
||||
| migration-v2.sql
|
||||
| package.json
|
||||
| README.md
|
||||
| RESUMO-IMPLEMENTACAO.md
|
||||
| server.js
|
||||
|
|
||||
+---auth
|
||||
| login.html
|
||||
|
|
||||
+---installer
|
||||
| check-installation.js
|
||||
|
|
||||
+---public
|
||||
| app.js
|
||||
| index.html
|
||||
| style.css
|
||||
|
|
||||
\---routes
|
||||
auth.js
|
||||
images.js
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# ================================================================
|
||||
# EXEMPLO DE .env - COPIE E AJUSTE
|
||||
# ================================================================
|
||||
# CONFIGURADO PARA: https://chat.consulttclinic.com
|
||||
# Crie um arquivo chamado .env na raiz do dental-server/
|
||||
# Copie este conteúdo e ajuste apenas a senha MySQL
|
||||
# ================================================================
|
||||
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# Chaves JWT já geradas (64 caracteres hex cada)
|
||||
JWT_SECRET=9f8e7d6c5b4a39281802384f5e6d7c8b9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5
|
||||
SESSION_SECRET=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
|
||||
|
||||
# Configure seu MySQL (AJUSTE APENAS A SENHA!)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=SUA_SENHA_MYSQL_AQUI
|
||||
DB_NAME=dental_images
|
||||
|
||||
# Caminhos de upload
|
||||
UPLOAD_DIR=/www/wwwroot/dental-server/uploads
|
||||
PROCESSED_DIR=/www/wwwroot/dental-server/processed
|
||||
|
||||
# Domínio configurado
|
||||
ALLOWED_ORIGINS=https://chat.consulttclinic.com
|
||||
TRUST_PROXY=true
|
||||
|
||||
# Configurações adicionais (opcional)
|
||||
SERVER_TIMEOUT=30000
|
||||
MAX_UPLOAD_SIZE=52428800
|
||||
LOG_LEVEL=info
|
||||
CORS_CREDENTIALS=true
|
||||
|
||||
# ================================================================
|
||||
# INSTRUÇÕES RÁPIDAS
|
||||
# ================================================================
|
||||
|
||||
# 1. COPIAR:
|
||||
# cp EXEMPLO-ENV.txt .env
|
||||
|
||||
# 2. EDITAR E AJUSTAR SENHA:
|
||||
# nano .env
|
||||
# Mude apenas: DB_PASSWORD=sua_senha_real
|
||||
|
||||
# 3. SALVAR:
|
||||
# Ctrl+O, Enter, Ctrl+X
|
||||
|
||||
# 4. INICIAR:
|
||||
# pm2 start server.js --name dental-server
|
||||
|
||||
# ================================================================
|
||||
# IMPORTANTE
|
||||
# ================================================================
|
||||
# - Apenas mude DB_PASSWORD
|
||||
# - Domínio já está configurado
|
||||
# - Chaves JWT prontas
|
||||
# - Servidor cria banco automaticamente
|
||||
# ================================================================
|
||||
@@ -0,0 +1,76 @@
|
||||
═══════════════════════════════════════════════════════════
|
||||
🔐 COMO GERAR CHAVES JWT E SESSION SEGURAS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
As chaves JWT_SECRET e SESSION_SECRET DEVEM ser aleatórias
|
||||
e diferentes uma da outra para segurança máxima.
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🛠️ MÉTODO 1: Via Terminal Linux (RECOMENDADO)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Execute estes comandos no terminal do seu VPS:
|
||||
|
||||
# Gerar JWT_SECRET
|
||||
openssl rand -hex 32
|
||||
|
||||
# Gerar SESSION_SECRET
|
||||
openssl rand -hex 32
|
||||
|
||||
Você verá algo assim:
|
||||
a8f5f167f44f4964e6c998dee827110c7c8d0c88e04d89e1c3b9b8e1f8e9e8a1
|
||||
b4c7f8a9e3d2f1a8b7c6d5e4f3a2b1c8d7e6f5a4b3c2d1e8f7a6b5c4d3e2f1
|
||||
|
||||
Copie cada uma e cole no .env:
|
||||
JWT_SECRET=a8f5f167f44f4964e6c998dee827110c7c8d0c88e04d89e1c3b9b8e1f8e9e8a1
|
||||
SESSION_SECRET=b4c7f8a9e3d2f1a8b7c6d5e4f3a2b1c8d7e6f5a4b3c2d1e8f7a6b5c4d3e2f1
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🛠️ MÉTODO 2: Via Node.js (RÁPIDO)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
No terminal, execute:
|
||||
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
Repita 2 vezes e copie cada resultado.
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🛠️ MÉTODO 3: Manualmente (SE NÃO TIVER OPENSSL)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Use QUALQUER texto longo e aleatório que você inventar:
|
||||
|
||||
BOM EXEMPLO:
|
||||
JWT_SECRET=MinhaChaveSuperSecretaParaJWT123456789AgoraEstamuitoSegura
|
||||
SESSION_SECRET=OutraChaveCompletamenteDiferenteParaSession987654321SeguraTambem
|
||||
|
||||
RUIM EXEMPLO (não usar):
|
||||
JWT_SECRET=senha123
|
||||
SESSION_SECRET=123456
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
✅ CHECKLIST
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
□ JWT_SECRET gerada (longa e aleatória)
|
||||
□ SESSION_SECRET gerada (diferente da JWT)
|
||||
□ Ambas são diferentes entre si
|
||||
□ Usei os métodos seguros acima
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
💡 DICAS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. Quanto mais longa, mais segura
|
||||
2. Use símbolos, números, letras
|
||||
3. Guarde as chaves em local seguro
|
||||
4. NUNCA compartilhe estas chaves
|
||||
5. Ambas DEVEM ser diferentes
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Versão: 2.0.1
|
||||
Data: 2025-01-26
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
@@ -0,0 +1,99 @@
|
||||
# 🚀 INÍCIO RÁPIDO - DENTAL SERVER v2.0
|
||||
|
||||
## ✅ PROJETO COMPLETO E PRONTO
|
||||
|
||||
Todos os arquivos necessários foram criados e estão prontos para upload no VPS.
|
||||
|
||||
---
|
||||
|
||||
## 📦 ARQUIVOS CRIADOS
|
||||
|
||||
### **CORE (4 arquivos)**
|
||||
- ✅ `server.js` - Servidor principal (Express + Socket.IO)
|
||||
- ✅ `auth.js` - Autenticação JWT
|
||||
- ✅ `database.js` - Conexão SQLite
|
||||
- ✅ `image-processor.js` - Processamento de imagens
|
||||
|
||||
### **ROTAS (2 arquivos)**
|
||||
- ✅ `routes/auth.js` - API de autenticação (login, verify, logout)
|
||||
- ✅ `routes/images.js` - API de imagens (8 endpoints)
|
||||
|
||||
### **FRONTEND (4 arquivos)**
|
||||
- ✅ `auth/login.html` - Página de login
|
||||
- ✅ `public/index.html` - Interface web principal
|
||||
- ✅ `public/style.css` - Estilos responsivos
|
||||
- ✅ `public/app.js` - JavaScript da interface
|
||||
|
||||
### **CONFIGURAÇÃO (2 arquivos)**
|
||||
- ✅ `package.json` - Dependências Node.js
|
||||
- ✅ `migration-v2.sql` - Migration do banco
|
||||
|
||||
### **DOCUMENTAÇÃO (8 arquivos)**
|
||||
- ✅ `README.md` - Visão geral
|
||||
- ✅ `INSTALACAO-DENTAL-SERVER.md` - Manual completo
|
||||
- ✅ `INSTRUCOES-RAPIDAS.txt` - Guia rápido
|
||||
- ✅ `CHECKLIST-IMPLEMENTACAO.md` - Checklist
|
||||
- ✅ `RESUMO-IMPLEMENTACAO.md` - Resumo executivo
|
||||
- ✅ `ARQUIVOS-FINAIS.txt` - Lista de arquivos
|
||||
- ✅ `COMO-USAR-ENV.txt` - Como criar .env
|
||||
- ✅ `ENV-CONFIG.txt` - Modelo de .env
|
||||
|
||||
### **INSTALER (1 arquivo)**
|
||||
- ✅ `installer/check-installation.js` - Verificação
|
||||
|
||||
**TOTAL: 21 arquivos**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 INSTALAÇÃO NO VPS
|
||||
|
||||
### **Passo 1: Upload**
|
||||
Faça upload de TODA a pasta `dental-server` para `/www/wwwroot/`
|
||||
|
||||
### **Passo 2: Criar .env**
|
||||
```bash
|
||||
cd /www/wwwroot/dental-server
|
||||
nano .env
|
||||
```
|
||||
Cole o conteúdo de `ENV-CONFIG.txt` e ajuste os valores
|
||||
|
||||
### **Passo 3: Instalar Dependências**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### **Passo 4: Migration do Banco**
|
||||
```bash
|
||||
sqlite3 database.sqlite < migration-v2.sql
|
||||
```
|
||||
|
||||
### **Passo 5: Iniciar PM2**
|
||||
```bash
|
||||
pm2 start server.js --name dental-server
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### **Passo 6: Configurar Nginx**
|
||||
Adicione proxy reverso (ver `INSTALACAO-DENTAL-SERVER.md`)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 PRONTO!
|
||||
|
||||
O servidor vai:
|
||||
1. ✅ Criar banco de dados automaticamente
|
||||
2. ✅ Criar usuário admin (rcesar / Rc362514)
|
||||
3. ✅ Iniciar na porta 3000
|
||||
4. ✅ Conectar Socket.IO
|
||||
5. ✅ Servir interface web
|
||||
|
||||
---
|
||||
|
||||
**Credenciais Iniciais:**
|
||||
- **Usuário:** rcesar
|
||||
- **Senha:** Rc362514
|
||||
- **Email:** rcesar@rcesar.com
|
||||
|
||||
---
|
||||
|
||||
**Para mais detalhes:** Leia `INSTALACAO-DENTAL-SERVER.md`
|
||||
@@ -0,0 +1,370 @@
|
||||
# 🦷 MANUAL DE INSTALAÇÃO - DENTAL SERVER v2.0
|
||||
|
||||
## 📋 SUMÁRIO
|
||||
1. [Pré-requisitos](#pré-requisitos)
|
||||
2. [Upload de Arquivos](#upload-de-arquivos)
|
||||
3. [Configuração no VPS](#configuração-no-vps)
|
||||
4. [Banco de Dados](#banco-de-dados)
|
||||
5. [PM2 e Nginx](#pm2-e-nginx)
|
||||
6. [Verificação](#verificação)
|
||||
|
||||
---
|
||||
|
||||
## ✅ PRÉ-REQUISITOS
|
||||
|
||||
### **No aaPanel:**
|
||||
- ✅ Node.js instalado (versão 16+)
|
||||
- ✅ PM2 instalado globalmente
|
||||
- ✅ Nginx configurado
|
||||
- ✅ SSL/HTTPS configurado (Let's Encrypt)
|
||||
- ✅ Porta 3000 liberada (ou outra porta configurada)
|
||||
|
||||
### **No terminal do VPS:**
|
||||
```bash
|
||||
# Verificar Node.js
|
||||
node --version
|
||||
|
||||
# Verificar PM2
|
||||
pm2 --version
|
||||
|
||||
# Se não tiver PM2:
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📤 UPLOAD DE ARQUIVOS
|
||||
|
||||
### **1. Estrutura no VPS:**
|
||||
Crie a pasta no seu VPS (normalmente em `/www/wwwroot/dental-server` ou similar):
|
||||
|
||||
```bash
|
||||
mkdir -p /www/wwwroot/dental-server
|
||||
cd /www/wwwroot/dental-server
|
||||
```
|
||||
|
||||
### **2. Arquivos para upload:**
|
||||
|
||||
Faça upload de TODOS estes arquivos para o servidor:
|
||||
|
||||
```
|
||||
dental-server/
|
||||
├── routes/
|
||||
│ └── images.js ← API de imagens
|
||||
├── public/
|
||||
│ ├── index.html ← Interface web
|
||||
│ ├── style.css ← Estilos
|
||||
│ └── app.js ← JavaScript
|
||||
├── installer/
|
||||
│ └── check-installation.js
|
||||
├── image-processor.js ← Processamento de imagens
|
||||
├── migration-v2.sql ← SQL para atualizar banco
|
||||
├── package.json ← Se não tiver, criar
|
||||
├── server.js ← Arquivo principal (existente)
|
||||
├── database.js ← Conexão MySQL (existente)
|
||||
└── INSTALACAO-DENTAL-SERVER.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ CONFIGURAÇÃO NO VPS
|
||||
|
||||
### **1. Instalar dependências:**
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/dental-server
|
||||
|
||||
# Instalar npm packages
|
||||
npm install
|
||||
```
|
||||
|
||||
Se o `package.json` não existir, crie:
|
||||
|
||||
```bash
|
||||
npm init -y
|
||||
npm install express socket.io sharp dotenv cors cookie-parser bcrypt jsonwebtoken mysql2
|
||||
```
|
||||
|
||||
### **2. Criar arquivo `.env`:**
|
||||
|
||||
```bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
Adicione (ajuste conforme seu setup):
|
||||
|
||||
```env
|
||||
# Porta do servidor
|
||||
PORT=3000
|
||||
|
||||
# Ambiente
|
||||
NODE_ENV=production
|
||||
|
||||
# JWT Secret (mude para algo seguro!)
|
||||
JWT_SECRET=sua_chave_secreta_super_segura_aqui
|
||||
|
||||
# Session Secret
|
||||
SESSION_SECRET=outra_chave_secreta
|
||||
|
||||
# Banco de dados MySQL
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=sua_senha_mysql
|
||||
DB_NAME=dental_images
|
||||
|
||||
# Uploads
|
||||
UPLOAD_DIR=/www/wwwroot/dental-server/uploads
|
||||
PROCESSED_DIR=/www/wwwroot/dental-server/processed
|
||||
|
||||
# SSL/Domínio
|
||||
ALLOWED_ORIGINS=https://seu-dominio.com
|
||||
TRUST_PROXY=true
|
||||
```
|
||||
|
||||
Salve com `Ctrl+O` e `Ctrl+X`.
|
||||
|
||||
### **3. Criar pastas necessárias:**
|
||||
|
||||
```bash
|
||||
mkdir -p uploads processed logs
|
||||
chmod 755 uploads processed logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ BANCO DE DADOS
|
||||
|
||||
### **Opção A: Via Terminal MySQL:**
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/dental-server
|
||||
|
||||
# Criar banco de dados (se não existir)
|
||||
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS dental_images CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
|
||||
# Executar migration
|
||||
mysql -u root -p dental_images < migration-simple.sql
|
||||
```
|
||||
|
||||
### **Opção B: Via phpMyAdmin ou MySQL Workbench:**
|
||||
|
||||
1. Crie o banco de dados `dental_images`
|
||||
2. Selecione o banco
|
||||
3. Importe o arquivo `migration-simple.sql`
|
||||
|
||||
### **Verificar estrutura:**
|
||||
|
||||
```bash
|
||||
mysql -u root -p dental_images -e "DESCRIBE images;"
|
||||
```
|
||||
|
||||
Deve mostrar todas as colunas incluindo as novas:
|
||||
- `enabled`, `tooth_number`, `tooth_side`, `patient_name_resumed`
|
||||
- `original_image_id`, `rotation`, `flip_horizontal`, `flip_vertical`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 PM2 E NGINX
|
||||
|
||||
### **1. Iniciar com PM2:**
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/dental-server
|
||||
|
||||
# Iniciar servidor
|
||||
pm2 start server.js --name dental-server
|
||||
|
||||
# Salvar configuração
|
||||
pm2 save
|
||||
|
||||
# Ver status
|
||||
pm2 status dental-server
|
||||
|
||||
# Ver logs
|
||||
pm2 logs dental-server
|
||||
```
|
||||
|
||||
### **2. Configurar Nginx (Proxy Reverso):**
|
||||
|
||||
No aaPanel:
|
||||
1. Vá em **Website** → **Sua lista de sites** → Clique em **Configuração**
|
||||
2. Vá em **Configuração** → **Painel de configuração**
|
||||
|
||||
Adicione este código ANTES do último `}`:
|
||||
|
||||
```nginx
|
||||
# Dental Server Proxy
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Socket.IO support
|
||||
location /socket.io/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
Salve e teste a configuração:
|
||||
|
||||
```bash
|
||||
nginx -t
|
||||
```
|
||||
|
||||
Se tudo OK, recarregue:
|
||||
|
||||
```bash
|
||||
nginx -s reload
|
||||
# ou
|
||||
service nginx reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ VERIFICAÇÃO
|
||||
|
||||
### **1. Testar servidor:**
|
||||
|
||||
```bash
|
||||
# Ver se está rodando
|
||||
pm2 status dental-server
|
||||
|
||||
# Ver logs em tempo real
|
||||
pm2 logs dental-server --lines 50
|
||||
|
||||
# Reiniciar se necessário
|
||||
pm2 restart dental-server
|
||||
```
|
||||
|
||||
### **2. Acessar no navegador:**
|
||||
|
||||
1. **Login:** `https://seu-dominio.com/login`
|
||||
- Usuário: `rcesar` ou `rcesar@rcesar.com`
|
||||
- Senha: `Rc362514`
|
||||
|
||||
2. **Interface:** `https://seu-dominio.com/`
|
||||
- Você verá o gerenciador de imagens
|
||||
|
||||
3. **Installer:** `https://seu-dominio.com/install`
|
||||
- Para reconfigurar ou atualizar
|
||||
|
||||
### **3. Testar upload do cliente:**
|
||||
|
||||
Configure o cliente Windows para apontar para `https://seu-dominio.com` e envie uma imagem de teste.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 COMANDOS ÚTEIS
|
||||
|
||||
```bash
|
||||
# Ver status PM2
|
||||
pm2 status
|
||||
|
||||
# Ver logs
|
||||
pm2 logs dental-server
|
||||
|
||||
# Reiniciar
|
||||
pm2 restart dental-server
|
||||
|
||||
# Parar
|
||||
pm2 stop dental-server
|
||||
|
||||
# Iniciar
|
||||
pm2 start dental-server
|
||||
|
||||
# Deletar
|
||||
pm2 delete dental-server
|
||||
|
||||
# Ver informações
|
||||
pm2 info dental-server
|
||||
|
||||
# Monitoramento
|
||||
pm2 monit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 TROUBLESHOOTING
|
||||
|
||||
### **Erro: "Cannot find module"**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### **Erro: "Port already in use"**
|
||||
```bash
|
||||
# Ver quem está usando a porta 3000
|
||||
lsof -i :3000
|
||||
|
||||
# Matar processo
|
||||
kill -9 <PID>
|
||||
|
||||
# Ou mudar porta no .env
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
### **Erro no banco de dados MySQL**
|
||||
```bash
|
||||
# Verificar conexão MySQL
|
||||
mysql -u root -p -e "USE dental_images; SHOW TABLES;"
|
||||
|
||||
# Ver estrutura
|
||||
mysql -u root -p dental_images -e "DESCRIBE images;"
|
||||
|
||||
# Verificar usuário e senha
|
||||
mysql -u root -p -e "SELECT User, Host FROM mysql.user;"
|
||||
```
|
||||
|
||||
### **Nginx não carrega**
|
||||
```bash
|
||||
# Verificar configuração
|
||||
nginx -t
|
||||
|
||||
# Ver logs do Nginx
|
||||
tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
### **Socket.IO não conecta**
|
||||
Verifique se adicionou as configurações de proxy do Socket.IO no Nginx e se o `cors` está configurado no `server.js`.
|
||||
|
||||
---
|
||||
|
||||
## 📝 PRÓXIMOS PASSOS
|
||||
|
||||
1. ✅ Configure o cliente Windows com o IP/domínio do servidor
|
||||
2. ✅ Faça upload de imagens de teste
|
||||
3. ✅ Teste todas as funcionalidades:
|
||||
- Transformações (rotação, flip)
|
||||
- Download com nome personalizado
|
||||
- Apagar versões
|
||||
- Habilitar/Desabilitar
|
||||
|
||||
---
|
||||
|
||||
## 📞 SUPORTE
|
||||
|
||||
Para problemas:
|
||||
1. Verifique os logs: `pm2 logs dental-server`
|
||||
2. Verifique o banco: `mysql -u root -p dental_images -e "SELECT * FROM images LIMIT 5;"`
|
||||
3. Verifique Nginx: `tail -f /var/log/nginx/error.log`
|
||||
|
||||
---
|
||||
|
||||
**Última atualização:** 2025-01-26
|
||||
**Versão:** 2.0.0
|
||||
**Autor:** Rcesar
|
||||
@@ -0,0 +1,137 @@
|
||||
═══════════════════════════════════════════════════════════
|
||||
INSTRUÇÕES RÁPIDAS - DENTAL SERVER v2.0
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
📦 ARQUIVOS NECESSÁRIOS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
TODOS estes arquivos devem estar no VPS em:
|
||||
/www/wwwroot/dental-server/
|
||||
|
||||
✅ CRIADOS (prontos para upload):
|
||||
- routes/images.js
|
||||
- public/index.html
|
||||
- public/style.css
|
||||
- public/app.js
|
||||
- image-processor.js
|
||||
- migration-v2.sql
|
||||
- package.json
|
||||
- README.md
|
||||
- INSTALACAO-DENTAL-SERVER.md
|
||||
- CHECKLIST-IMPLEMENTACAO.md
|
||||
|
||||
⚠️ NECESSÁRIOS NO SERVIDOR (criar ou atualizar):
|
||||
- server.js (arquivo principal)
|
||||
- database.js (conexão MySQL)
|
||||
- .env (configurações)
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🚀 PASSO A PASSO NO VPS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1️⃣ UPLOAD DE ARQUIVOS
|
||||
cd /www/wwwroot/
|
||||
# Faça upload de TODOS os arquivos
|
||||
|
||||
2️⃣ VERIFICAR MYSQL
|
||||
# O MySQL PRECISA estar rodando!
|
||||
sudo systemctl status mysql
|
||||
# Se não estiver, iniciar:
|
||||
sudo systemctl start mysql
|
||||
|
||||
3️⃣ INSTALAR DEPENDÊNCIAS
|
||||
cd /www/wwwroot/dental-server
|
||||
npm install
|
||||
|
||||
4️⃣ CRIAR .env
|
||||
nano .env
|
||||
|
||||
# Copie e ajuste:
|
||||
PORT=3000
|
||||
JWT_SECRET=SUA_CHAVE_SECRETA_AQUI
|
||||
SESSION_SECRET=OUTRA_CHAVE_SECRETA
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=sua_senha_mysql
|
||||
DB_NAME=dental_images
|
||||
UPLOAD_DIR=/www/wwwroot/dental-server/uploads
|
||||
PROCESSED_DIR=/www/wwwroot/dental-server/processed
|
||||
ALLOWED_ORIGINS=https://seu-dominio.com
|
||||
TRUST_PROXY=true
|
||||
|
||||
5️⃣ CRIAR PASTAS
|
||||
mkdir -p uploads processed logs
|
||||
chmod 755 uploads processed logs
|
||||
|
||||
6️⃣ INICIAR PM2
|
||||
pm2 start server.js --name dental-server
|
||||
pm2 save
|
||||
|
||||
7️⃣ CONFIGURAR NGINX
|
||||
# No aaPanel → Configuração do site
|
||||
# Adicionar proxy para localhost:3000
|
||||
# Ver INSTALACAO-DENTAL-SERVER.md
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
✅ VERIFICAÇÃO
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. PM2 rodando: pm2 status dental-server
|
||||
2. Ver logs: pm2 logs dental-server
|
||||
3. Acessar: https://seu-dominio.com/login
|
||||
4. Login: rcesar / Rc362514
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🐛 PROBLEMAS COMUNS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
❌ "Cannot find module"
|
||||
→ npm install
|
||||
|
||||
❌ "Port already in use"
|
||||
→ Verificar: lsof -i :3000
|
||||
→ Mudar PORT no .env
|
||||
|
||||
❌ "MySQL connection error"
|
||||
→ Verificar credenciais no .env
|
||||
→ mysql -u root -p (testar conexão)
|
||||
|
||||
❌ Socket.IO não conecta
|
||||
→ Verificar configuração Nginx
|
||||
→ Ver INSTALACAO-DENTAL-SERVER.md
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📞 DOCUMENTAÇÃO COMPLETA
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
📖 Leia: INSTALACAO-DENTAL-SERVER.md
|
||||
Para instruções detalhadas e troubleshooting
|
||||
|
||||
📖 Leia: README.md
|
||||
Para visão geral do projeto
|
||||
|
||||
📖 Leia: CHECKLIST-IMPLEMENTACAO.md
|
||||
Para verificar o que falta implementar
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🎯 FUNCIONALIDADES IMPLEMENTADAS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
✅ Interface web moderna e responsiva
|
||||
✅ Sistema de transformações (rotação/flip)
|
||||
✅ Gerenciamento de pacientes
|
||||
✅ Download personalizado
|
||||
✅ Habilitar/Desabilitar imagens
|
||||
✅ Busca em tempo real
|
||||
✅ Socket.IO em tempo real
|
||||
✅ Sistema de autenticação
|
||||
✅ Controle de versões
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Versão: 2.0.0
|
||||
Data: 2025-01-26
|
||||
Autor: Rcesar
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
@@ -0,0 +1,135 @@
|
||||
═══════════════════════════════════════════════════════════
|
||||
⚠️ CONFIGURAÇÃO OBRIGATÓRIA ANTES DE INICIAR
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
O servidor NÃO vai iniciar corretamente sem estas configurações!
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
1️⃣ CRIAR ARQUIVO .env (OBRIGATÓRIO)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Copie o arquivo ENV-CONFIG.txt para .env:
|
||||
|
||||
cp ENV-CONFIG.txt .env
|
||||
|
||||
OU crie manualmente:
|
||||
|
||||
nano .env
|
||||
|
||||
E cole este conteúdo (AJUSTE OS VALORES):
|
||||
|
||||
PORT=3000
|
||||
JWT_SECRET=SUA_CHAVE_SECRETA_AQUI
|
||||
SESSION_SECRET=OUTRA_CHAVE_SECRETA
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=SUA_SENHA_MYSQL ← MUDAR AQUI!
|
||||
DB_NAME=dental_images
|
||||
UPLOAD_DIR=/www/wwwroot/dental-server/uploads
|
||||
PROCESSED_DIR=/www/wwwroot/dental-server/processed
|
||||
ALLOWED_ORIGINS=https://seu-dominio.com ← MUDAR AQUI!
|
||||
TRUST_PROXY=true
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
2️⃣ VERIFICAR MYSQL (OBRIGATÓRIO)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
O servidor PRECISA de MySQL funcionando!
|
||||
|
||||
Verificar se MySQL está rodando:
|
||||
|
||||
sudo systemctl status mysql
|
||||
|
||||
OU
|
||||
|
||||
sudo systemctl status mariadb
|
||||
|
||||
Se não estiver rodando, iniciar:
|
||||
|
||||
sudo systemctl start mysql
|
||||
|
||||
OU
|
||||
|
||||
sudo systemctl start mariadb
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
3️⃣ TESTAR CONEXÃO MYSQL
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Testar conexão manualmente:
|
||||
|
||||
mysql -u root -p
|
||||
|
||||
Se funcionar, você verá o prompt do MySQL.
|
||||
Digite: exit
|
||||
|
||||
Se NÃO funcionar, você precisa corrigir o usuário/senha.
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
4️⃣ PERMISSÕES MYSQL
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
O usuário configurado no .env precisa de permissão para:
|
||||
- CREATE DATABASE
|
||||
- CREATE TABLE
|
||||
|
||||
Se usar root, normalmente já tem estas permissões.
|
||||
|
||||
Se usar outro usuário, dar permissões:
|
||||
|
||||
mysql -u root -p
|
||||
|
||||
GRANT ALL PRIVILEGES ON dental_images.* TO 'seu_usuario'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
exit
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
5️⃣ INICIAR SERVIDOR
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
DEPOIS de fazer todos os passos acima:
|
||||
|
||||
pm2 start server.js --name dental-server
|
||||
|
||||
Ver logs:
|
||||
|
||||
pm2 logs dental-server
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
❌ PROBLEMAS COMUNS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. "Erro ao conectar ao MySQL"
|
||||
→ MySQL não está rodando
|
||||
→ Verificar credenciais no .env
|
||||
|
||||
2. "Access denied"
|
||||
→ Senha incorreta no .env
|
||||
→ Testar: mysql -u root -p
|
||||
|
||||
3. "Cannot create database"
|
||||
→ Usuário sem permissão
|
||||
→ Usar root ou dar permissões
|
||||
|
||||
4. Servidor inicia mas cai logo
|
||||
→ Ver logs: pm2 logs dental-server
|
||||
→ Verificar todos os passos acima
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
✅ CHECKLIST ANTES DE INICIAR
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
□ Arquivo .env criado
|
||||
□ DB_PASSWORD configurada corretamente
|
||||
□ MySQL rodando
|
||||
□ Conexão MySQL testada manualmente
|
||||
□ PERMISSÕES OK
|
||||
□ npm install feito
|
||||
|
||||
Só DEPOIS disso tudo: pm2 start server.js
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Versão: 2.0.0
|
||||
Data: 2025-01-26
|
||||
@@ -0,0 +1,152 @@
|
||||
═══════════════════════════════════════════════════════════
|
||||
LISTA DE ARQUIVOS CRIADOS - DENTAL SERVER v2.0
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
✅ TOTAL: 14 arquivos criados
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📁 ESTRUTURA DE PASTAS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
dental-server/
|
||||
│
|
||||
├── 📄 CHECKLIST-IMPLEMENTACAO.md
|
||||
├── 📄 image-processor.js
|
||||
├── 📄 INSTALACAO-DENTAL-SERVER.md
|
||||
├── 📄 INSTRUCOES-RAPIDAS.txt
|
||||
├── 📄 migration-v2.sql
|
||||
├── 📄 package.json
|
||||
├── 📄 README.md
|
||||
├── 📄 RESUMO-IMPLEMENTACAO.md
|
||||
├── 📄 LISTA-ARQUIVOS-CRIADOS.txt
|
||||
│
|
||||
├── 📁 installer/
|
||||
│ └── check-installation.js
|
||||
│
|
||||
├── 📁 public/
|
||||
│ ├── app.js
|
||||
│ ├── index.html
|
||||
│ └── style.css
|
||||
│
|
||||
└── 📁 routes/
|
||||
└── images.js
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📝 DETALHES DOS ARQUIVOS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. 📄 CHECKLIST-IMPLEMENTACAO.md
|
||||
- Checklist completo de implementação
|
||||
- Verificação de funcionalidades
|
||||
- Troubleshooting
|
||||
|
||||
2. 📄 image-processor.js
|
||||
- Processamento de imagens com Sharp
|
||||
- Métodos: rotate, flip, resize, thumbnail, etc
|
||||
- ~250 linhas
|
||||
|
||||
3. 📄 INSTALACAO-DENTAL-SERVER.md
|
||||
- Manual COMPLETO de instalação
|
||||
- Pré-requisitos
|
||||
- Configuração Nginx
|
||||
- Troubleshooting detalhado
|
||||
|
||||
4. 📄 INSTRUCOES-RAPIDAS.txt
|
||||
- Guia rápido de instalação
|
||||
- Comandos essenciais
|
||||
- Verificação básica
|
||||
|
||||
5. 📄 migration-v2.sql
|
||||
- Migration do banco de dados
|
||||
- Adiciona 8 novas colunas
|
||||
- Cria 5 índices
|
||||
|
||||
6. 📄 package.json
|
||||
- Dependências Node.js
|
||||
- Scripts de execução
|
||||
- Metadata do projeto
|
||||
|
||||
7. 📄 README.md
|
||||
- Documentação geral
|
||||
- Visão do projeto
|
||||
- APIs disponíveis
|
||||
- Tecnologias usadas
|
||||
|
||||
8. 📄 RESUMO-IMPLEMENTACAO.md
|
||||
- Resumo executivo
|
||||
- Estatísticas
|
||||
- Funcionalidades
|
||||
- Endpoints
|
||||
|
||||
9. 📄 LISTA-ARQUIVOS-CRIADOS.txt
|
||||
- Este arquivo
|
||||
|
||||
10. 📄 installer/check-installation.js
|
||||
- Verificação de instalação existente
|
||||
- Controle de versões
|
||||
|
||||
11. 📄 public/app.js
|
||||
- JavaScript principal da interface
|
||||
- Socket.IO client
|
||||
- Handlers de eventos
|
||||
- ~350 linhas
|
||||
|
||||
12. 📄 public/index.html
|
||||
- Interface web HTML
|
||||
- Grid de imagens
|
||||
- Modals
|
||||
- Estrutura responsiva
|
||||
|
||||
13. 📄 public/style.css
|
||||
- Estilos CSS
|
||||
- Design moderno
|
||||
- Responsividade
|
||||
- ~700 linhas
|
||||
|
||||
14. 📄 routes/images.js
|
||||
- API de imagens
|
||||
- 8 endpoints REST
|
||||
- Validações
|
||||
- ~450 linhas
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📊 ESTATÍSTICAS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Total de linhas: ~2.500+
|
||||
Backend: ~700 linhas
|
||||
Frontend: ~1.200 linhas
|
||||
Documentação: ~1.000 linhas
|
||||
Configuração: ~100 linhas
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
🎯 FUNCIONALIDADES
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
✅ Interface web responsiva
|
||||
✅ Sistema de transformações
|
||||
✅ Gerenciamento de pacientes
|
||||
✅ Download personalizado
|
||||
✅ Habilitar/Desabilitar imagens
|
||||
✅ Busca em tempo real
|
||||
✅ Socket.IO em tempo real
|
||||
✅ Sistema de autenticação
|
||||
✅ Controle de versões
|
||||
✅ Documentação completa
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
📦 PRÓXIMOS PASSOS
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
1. Fazer upload dos arquivos para o VPS
|
||||
2. Seguir INSTALACAO-DENTAL-SERVER.md
|
||||
3. Testar todas as funcionalidades
|
||||
4. Configurar produção
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Status: ✅ COMPLETO
|
||||
Data: 2025-01-26
|
||||
Versão: 2.0.0
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
@@ -0,0 +1,188 @@
|
||||
# 🦷 Dental Image Server v2.0
|
||||
|
||||
Sistema completo de gerenciamento de imagens dentais com interface web moderna e responsiva.
|
||||
|
||||
## 🎯 Características
|
||||
|
||||
### ✅ Interface Web Completa
|
||||
- **Gerenciador de imagens** com grid responsivo
|
||||
- **Busca em tempo real** por paciente ou GUID
|
||||
- **Preview de imagens** com informações completas
|
||||
- **Design moderno** e totalmente responsivo
|
||||
|
||||
### ✅ Sistema de Transformações
|
||||
- **5 opções de transformação:**
|
||||
- Original
|
||||
- Rotação -90°
|
||||
- Rotação +90°
|
||||
- Flip Horizontal
|
||||
- Flip Vertical
|
||||
- **Processamento server-side** com Sharp
|
||||
- **Preview instantâneo** antes de salvar
|
||||
|
||||
### ✅ Gerenciamento de Pacientes
|
||||
- **Configuração de dados do dente:**
|
||||
- Nome resumido do paciente
|
||||
- Número do dente (1-52)
|
||||
- Lado (inicial/final)
|
||||
- **Download personalizado** com nome estruturado
|
||||
- **Controle de versões** (mantém sempre a original)
|
||||
|
||||
### ✅ Controle Total
|
||||
- **Habilitar/Desabilitar** imagens
|
||||
- **Excluir versões** (nunca a original)
|
||||
- **Filtrar por status** (habilitadas/desabilitadas)
|
||||
- **Estatísticas** em tempo real
|
||||
|
||||
### ✅ Socket.IO em Tempo Real
|
||||
- **Recebimento instantâneo** de novas imagens
|
||||
- **Notificações** ao usuário
|
||||
- **Auto-refresh** da interface
|
||||
|
||||
### ✅ Sistema de Autenticação
|
||||
- **Login seguro** com JWT
|
||||
- **Sessões** persistentes
|
||||
- **Proteção** de rotas administrativas
|
||||
|
||||
## 📦 Estrutura
|
||||
|
||||
```
|
||||
dental-server/
|
||||
├── server.js # Arquivo principal
|
||||
├── database.js # Conexão MySQL
|
||||
├── image-processor.js # Processamento de imagens
|
||||
├── package.json # Dependências
|
||||
├── .env # Configurações
|
||||
├── routes/
|
||||
│ └── images.js # API de imagens
|
||||
├── public/
|
||||
│ ├── index.html # Interface web
|
||||
│ ├── style.css # Estilos
|
||||
│ └── app.js # JavaScript
|
||||
├── installer/
|
||||
│ └── check-installation.js
|
||||
├── migration-v2.sql # Migration do banco
|
||||
└── INSTALACAO-DENTAL-SERVER.md
|
||||
```
|
||||
|
||||
## 🚀 Instalação Rápida
|
||||
|
||||
### 1. Upload de Arquivos
|
||||
Faça upload de todos os arquivos para `/www/wwwroot/dental-server`
|
||||
|
||||
### 2. Instalar Dependências
|
||||
```bash
|
||||
cd /www/wwwroot/dental-server
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Configurar `.env`
|
||||
```env
|
||||
PORT=3000
|
||||
JWT_SECRET=sua_chave_secreta
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=sua_senha_mysql
|
||||
DB_NAME=dental_images
|
||||
UPLOAD_DIR=/www/wwwroot/dental-server/uploads
|
||||
PROCESSED_DIR=/www/wwwroot/dental-server/processed
|
||||
```
|
||||
|
||||
### 4. Executar Migration
|
||||
```bash
|
||||
mysql -u root -p dental_images < migration-v2.sql
|
||||
```
|
||||
|
||||
### 5. Iniciar PM2
|
||||
```bash
|
||||
pm2 start server.js --name dental-server
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### 6. Configurar Nginx
|
||||
Adicione proxy reverso para `localhost:3000` e configuração para Socket.IO.
|
||||
|
||||
Ver instruções completas em **INSTALACAO-DENTAL-SERVER.md**
|
||||
|
||||
## 🔌 APIs Disponíveis
|
||||
|
||||
### Imagens
|
||||
- `GET /api/images` - Listar imagens
|
||||
- `GET /api/images/:id` - Buscar imagem
|
||||
- `GET /api/images/:id/transformations` - Gerar transformações
|
||||
- `POST /api/images/:id/transform` - Salvar transformação
|
||||
- `PUT /api/images/:id/patient-info` - Atualizar dados do paciente
|
||||
- `GET /api/images/:id/download` - Download da imagem
|
||||
- `DELETE /api/images/:id` - Apagar versão
|
||||
- `PUT /api/images/:id/toggle-enabled` - Habilitar/Desabilitar
|
||||
|
||||
### Socket.IO
|
||||
- `client-identify` - Identificar cliente
|
||||
- `send-image` - Enviar imagem
|
||||
- `new-image` - Nova imagem recebida
|
||||
|
||||
## 🛠️ Tecnologias
|
||||
|
||||
- **Node.js** 16+
|
||||
- **Express** 4.18+
|
||||
- **Socket.IO** 4.7+
|
||||
- **Sharp** 0.32+ (processamento de imagens)
|
||||
- **MySQL** (banco de dados)
|
||||
- **bcrypt** (senhas)
|
||||
- **JWT** (autenticação)
|
||||
|
||||
## 📱 Responsividade
|
||||
|
||||
Interface totalmente responsiva:
|
||||
- ✅ Desktop (1920px+)
|
||||
- ✅ Tablet (768px - 1919px)
|
||||
- ✅ Mobile (320px - 767px)
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
- ✅ Senhas hash com bcrypt
|
||||
- ✅ Autenticação JWT
|
||||
- ✅ Validação de uploads
|
||||
- ✅ Sanitização de inputs
|
||||
- ✅ Proteção CSRF
|
||||
|
||||
## 📊 Banco de Dados
|
||||
|
||||
### Tabela `images`
|
||||
```sql
|
||||
- id (INTEGER PRIMARY KEY)
|
||||
- image_guid (TEXT)
|
||||
- patient_name (TEXT)
|
||||
- original_filename (TEXT)
|
||||
- filename (TEXT)
|
||||
- enabled (INTEGER DEFAULT 1)
|
||||
- tooth_number (VARCHAR)
|
||||
- tooth_side (VARCHAR)
|
||||
- patient_name_resumed (VARCHAR)
|
||||
- original_image_id (INTEGER)
|
||||
- rotation (INTEGER)
|
||||
- flip_horizontal (INTEGER)
|
||||
- flip_vertical (INTEGER)
|
||||
- created_at (DATETIME)
|
||||
```
|
||||
|
||||
Execute `migration-v2.sql` no MySQL para criar/atualizar banco existente.
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
Ver seção **TROUBLESHOOTING** em INSTALACAO-DENTAL-SERVER.md
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
MIT
|
||||
|
||||
## 👨💻 Autor
|
||||
|
||||
**Rcesar**
|
||||
Desenvolvido para sistema de radiografia dental
|
||||
|
||||
---
|
||||
|
||||
**Versão:** 2.0.0
|
||||
**Última atualização:** 2025-01-26
|
||||
@@ -0,0 +1,246 @@
|
||||
# 📊 RESUMO DA IMPLEMENTAÇÃO
|
||||
|
||||
## ✅ ARQUIVOS CRIADOS (13 arquivos)
|
||||
|
||||
### **BACKEND (3 arquivos)**
|
||||
1. ✅ `routes/images.js` - API completa com 8 endpoints
|
||||
2. ✅ `image-processor.js` - Processamento de imagens (9 métodos)
|
||||
3. ✅ `installer/check-installation.js` - Verificação de instalação
|
||||
|
||||
### **FRONTEND (3 arquivos)**
|
||||
4. ✅ `public/index.html` - Interface web responsiva
|
||||
5. ✅ `public/style.css` - Estilos modernos
|
||||
6. ✅ `public/app.js` - Lógica JavaScript completa
|
||||
|
||||
### **CONFIGURAÇÃO (3 arquivos)**
|
||||
7. ✅ `package.json` - Dependências Node.js
|
||||
8. ✅ `migration-v2.sql` - Migration do banco de dados
|
||||
9. ✅ `.gitignore` - (implícito, não criado mas não necessário)
|
||||
|
||||
### **DOCUMENTAÇÃO (4 arquivos)**
|
||||
10. ✅ `README.md` - Documentação geral
|
||||
11. ✅ `INSTALACAO-DENTAL-SERVER.md` - Manual completo de instalação
|
||||
12. ✅ `CHECKLIST-IMPLEMENTACAO.md` - Checklist detalhado
|
||||
13. ✅ `INSTRUCOES-RAPIDAS.txt` - Guia rápido
|
||||
|
||||
---
|
||||
|
||||
## 🎯 FUNCIONALIDADES IMPLEMENTADAS
|
||||
|
||||
### ✅ **Gerenciador de Imagens**
|
||||
- Grid responsivo (desktop, tablet, mobile)
|
||||
- Busca em tempo real
|
||||
- Filtro habilitadas/desabilitadas
|
||||
- Preview de imagens
|
||||
- Informações do paciente
|
||||
|
||||
### ✅ **Sistema de Transformações**
|
||||
- 5 opções: Original, -90°, +90°, Flip H, Flip V
|
||||
- Preview antes de salvar
|
||||
- Processamento server-side com Sharp
|
||||
- Salvar múltiplas versões
|
||||
|
||||
### ✅ **Gerenciamento de Pacientes**
|
||||
- Configurar dados do dente
|
||||
- Nome resumido (com validação)
|
||||
- Número do dente (1-52)
|
||||
- Lado inicial/final
|
||||
- Download com nome estruturado
|
||||
|
||||
### ✅ **Controle de Versões**
|
||||
- Original sempre protegida
|
||||
- Excluir versões transformadas
|
||||
- Habilitar/Desabilitar imagens
|
||||
- Rastreamento de transformações
|
||||
|
||||
### ✅ **Real-time**
|
||||
- Socket.IO integrado
|
||||
- Notificações instantâneas
|
||||
- Auto-refresh da interface
|
||||
- Updates em tempo real
|
||||
|
||||
### ✅ **Segurança**
|
||||
- Autenticação JWT
|
||||
- Validação de inputs
|
||||
- Proteção de rotas
|
||||
- Sanitização SQL
|
||||
|
||||
---
|
||||
|
||||
## 📋 ENDPOINTS DA API
|
||||
|
||||
### **GET /api/images**
|
||||
- Listar imagens (habilitadas/desabilitadas)
|
||||
- Filtro por busca (paciente, GUID, filename)
|
||||
- Limite de 100 resultados
|
||||
- Ordenação por data DESC
|
||||
|
||||
### **GET /api/images/:id**
|
||||
- Buscar imagem específica por ID
|
||||
|
||||
### **GET /api/images/stats**
|
||||
- Estatísticas gerais
|
||||
- Total, habilitadas, desabilitadas
|
||||
- Com/sem dados do dente
|
||||
|
||||
### **GET /api/images/:id/transformations**
|
||||
- Gerar 5 transformações
|
||||
- Retorna base64 para preview
|
||||
|
||||
### **POST /api/images/:id/transform**
|
||||
- Salvar transformação
|
||||
- Body: `{ rotation, flipH, flipV }`
|
||||
- Retorna ID da nova imagem
|
||||
|
||||
### **PUT /api/images/:id/patient-info**
|
||||
- Atualizar dados do paciente
|
||||
- Body: `{ patientNameResumed, toothNumber, toothSide }`
|
||||
- Automaticamente habilita a imagem
|
||||
|
||||
### **GET /api/images/:id/download**
|
||||
- Download da imagem
|
||||
- Nome: `nome_resumido_numero_lado.png`
|
||||
- Headers apropriados
|
||||
|
||||
### **DELETE /api/images/:id**
|
||||
- Apagar versão
|
||||
- Protege a original
|
||||
- Remove arquivo e banco
|
||||
|
||||
### **PUT /api/images/:id/toggle-enabled**
|
||||
- Habilitar/Desabilitar
|
||||
- Retorna novo status
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ BANCO DE DADOS
|
||||
|
||||
### **Colunas Adicionadas:**
|
||||
- `enabled` (INTEGER DEFAULT 1)
|
||||
- `tooth_number` (VARCHAR 10)
|
||||
- `tooth_side` (VARCHAR 10)
|
||||
- `patient_name_resumed` (VARCHAR 50)
|
||||
- `original_image_id` (INTEGER)
|
||||
- `rotation` (INTEGER)
|
||||
- `flip_horizontal` (INTEGER)
|
||||
- `flip_vertical` (INTEGER)
|
||||
|
||||
### **Índices Criados:**
|
||||
- `idx_images_enabled`
|
||||
- `idx_images_original`
|
||||
- `idx_patient_name`
|
||||
- `idx_image_guid`
|
||||
- `idx_created_at`
|
||||
|
||||
---
|
||||
|
||||
## 🎨 INTERFACE WEB
|
||||
|
||||
### **Layout**
|
||||
- **Header:** Título + Busca + Toggle Disabled
|
||||
- **Grid:** Cards de imagens responsivos
|
||||
- **Modals:** Transformações + Dados do Paciente
|
||||
- **Toast:** Notificações de feedback
|
||||
|
||||
### **Responsividade**
|
||||
- **Mobile:** 1 coluna, modals fullscreen
|
||||
- **Tablet:** 2-3 colunas
|
||||
- **Desktop:** 4+ colunas
|
||||
|
||||
### **Interatividade**
|
||||
- Click na imagem → Modal de transformações
|
||||
- Transformação salva → Modal de paciente
|
||||
- Download → Arquivo personalizado
|
||||
- Excluir → Confirmação + Atualização
|
||||
|
||||
---
|
||||
|
||||
## 📦 PRÓXIMOS PASSOS
|
||||
|
||||
### **1. Upload no VPS**
|
||||
Fazer upload de TODOS os 13 arquivos criados
|
||||
|
||||
### **2. Ajustes Necessários**
|
||||
|
||||
**`server.js`** - Adicionar:
|
||||
```javascript
|
||||
const imageRoutes = require('./routes/images');
|
||||
app.use('/api/images', imageRoutes);
|
||||
app.use(express.static('public'));
|
||||
```
|
||||
|
||||
**`image-processor.js`** - Substituir ou mesclar métodos
|
||||
|
||||
**`.env`** - Criar configurações
|
||||
|
||||
### **3. Executar**
|
||||
```bash
|
||||
npm install
|
||||
sqlite3 database.sqlite < migration-v2.sql
|
||||
pm2 start server.js --name dental-server
|
||||
```
|
||||
|
||||
### **4. Configurar**
|
||||
- Nginx proxy
|
||||
- SSL/HTTPS
|
||||
- PM2 auto-start
|
||||
|
||||
### **5. Testar**
|
||||
- Login
|
||||
- Upload imagem
|
||||
- Transformações
|
||||
- Download
|
||||
- Controles
|
||||
|
||||
---
|
||||
|
||||
## 📊 ESTATÍSTICAS
|
||||
|
||||
- **Total de linhas:** ~2.500+
|
||||
- **JavaScript:** ~1.200 linhas
|
||||
- **HTML:** ~200 linhas
|
||||
- **CSS:** ~700 linhas
|
||||
- **SQL:** ~50 linhas
|
||||
- **Documentação:** ~1.000 linhas
|
||||
|
||||
---
|
||||
|
||||
## 🔍 VERIFICAÇÃO FINAL
|
||||
|
||||
### **Código Completo ✅**
|
||||
- [x] Todas as rotas implementadas
|
||||
- [x] Interface web completa
|
||||
- [x] Processamento de imagens
|
||||
- [x] Socket.IO integrado
|
||||
- [x] Validações e segurança
|
||||
|
||||
### **Documentação Completa ✅**
|
||||
- [x] README detalhado
|
||||
- [x] Manual de instalação
|
||||
- [x] Checklist de implementação
|
||||
- [x] Instruções rápidas
|
||||
- [x] Troubleshooting
|
||||
|
||||
### **Resto do Sistema ⚠️**
|
||||
- [ ] Adaptar server.js existente
|
||||
- [ ] Adaptar database.js existente
|
||||
- [ ] Criar .env
|
||||
- [ ] Configurar Nginx
|
||||
- [ ] Testes em produção
|
||||
|
||||
---
|
||||
|
||||
## ✅ CONCLUSÃO
|
||||
|
||||
**Implementação do zero concluída com sucesso!**
|
||||
|
||||
Todos os arquivos necessários para o **Dental Server v2.0** foram criados e estão prontos para upload no VPS.
|
||||
|
||||
**Próximo passo:** Fazer upload e seguir `INSTALACAO-DENTAL-SERVER.md`
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ COMPLETO
|
||||
**Data:** 2025-01-26
|
||||
**Versão:** 2.0.0
|
||||
**Autor:** Rcesar
|
||||
@@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Dental Server</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #667eea;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background: #efe;
|
||||
color: #3c3;
|
||||
border: 1px solid #cfc;
|
||||
}
|
||||
|
||||
#loginBtn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#loginBtn.loading {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#loginBtn.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #fff;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<h1>🦷 Dental Server</h1>
|
||||
<p>Área Administrativa</p>
|
||||
|
||||
<div class="alert error" id="errorAlert"></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Usuário ou Email</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
placeholder="rcesar ou rcesar@rcesar.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Senha</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="Digite sua senha"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" id="loginBtn">Entrar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
// Mostrar loading
|
||||
loginBtn.classList.add('loading');
|
||||
loginBtn.disabled = true;
|
||||
errorAlert.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
// Salvar token
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
|
||||
// Redirecionar usando replace para evitar loop no histórico
|
||||
window.location.replace('/');
|
||||
} else {
|
||||
throw new Error(data.error || 'Erro ao fazer login');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errorAlert.textContent = error.message;
|
||||
errorAlert.style.display = 'block';
|
||||
} finally {
|
||||
loginBtn.classList.remove('loading');
|
||||
loginBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Verificar se já está logado (desabilitado para evitar loops - usuário pode fazer login manualmente)
|
||||
// A verificação de autenticação será feita na página principal (app.js)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
Agora você tem duas opções de credenciais para fazer o login no servidor (http://localhost:3000/login):
|
||||
|
||||
Usuário padrão de instalação:
|
||||
|
||||
Usuário: rcesar
|
||||
Senha: Rc362514
|
||||
Usuário administrador padrão:
|
||||
|
||||
Usuário: admin
|
||||
Senha: admin1234
|
||||
Pode tentar fazer o login novamente com qualquer um desses dados!
|
||||
@@ -0,0 +1,62 @@
|
||||
-- ================================================================
|
||||
-- MIGRATION SIMPLES v2.0 - MySQL
|
||||
-- Dental Image Server
|
||||
-- ================================================================
|
||||
|
||||
-- Execute: mysql -u root -p dental_images < migration-simple.sql
|
||||
-- OU via phpMyAdmin: Import este arquivo
|
||||
|
||||
-- ================================================================
|
||||
-- CRIAR BANCO
|
||||
-- ================================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS dental_images
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE dental_images;
|
||||
|
||||
-- ================================================================
|
||||
-- CRIAR TABELA USERS
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ================================================================
|
||||
-- CRIAR TABELA IMAGES (COMPLETA)
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
image_guid VARCHAR(255) NOT NULL,
|
||||
patient_name TEXT,
|
||||
original_filename TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
tooth_number VARCHAR(10),
|
||||
tooth_side VARCHAR(10),
|
||||
patient_name_resumed VARCHAR(50),
|
||||
original_image_id INT,
|
||||
rotation INT DEFAULT 0,
|
||||
flip_horizontal INT DEFAULT 0,
|
||||
flip_vertical INT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_enabled (enabled),
|
||||
INDEX idx_original (original_image_id),
|
||||
INDEX idx_guid (image_guid),
|
||||
INDEX idx_created (created_at),
|
||||
INDEX idx_patient_name (patient_name(100))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ================================================================
|
||||
-- CONCLUÍDO
|
||||
-- ================================================================
|
||||
|
||||
SELECT 'Tables created successfully!' AS result;
|
||||
DESCRIBE images;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- ================================================================
|
||||
-- MIGRATION v2.0 - Sistema de Transformações e Gerenciamento
|
||||
-- Dental Image Server - PostgreSQL
|
||||
-- ================================================================
|
||||
|
||||
-- Execute este arquivo no banco PostgreSQL:
|
||||
-- psql -U postgres -d postgres -f migration-v2-postgres.sql
|
||||
-- (Este arquivo assume que você está criando a base primeiro, e em seguida rodando na base)
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 1: CRIAR BANCO
|
||||
-- ================================================================
|
||||
-- O Postgres não permite CREATE DATABASE dentro de um bloco de transação ou se já existir tão fácil,
|
||||
-- recomenda-se executar CREATE DATABASE dental_images fora do script ou se conectar diretamente nele após.
|
||||
-- CREATE DATABASE dental_images;
|
||||
|
||||
-- \c dental_images
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 2: CRIAR TABELA USERS
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 3: CRIAR TABELA IMAGES
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
image_guid VARCHAR(255) NOT NULL,
|
||||
patient_name TEXT,
|
||||
client_name VARCHAR(100),
|
||||
original_filename TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
enabled SMALLINT DEFAULT 1,
|
||||
tooth_number VARCHAR(10),
|
||||
tooth_side VARCHAR(10),
|
||||
patient_name_resumed VARCHAR(50),
|
||||
original_image_id INT,
|
||||
rotation INT DEFAULT 0,
|
||||
flip_horizontal SMALLINT DEFAULT 0,
|
||||
flip_vertical SMALLINT DEFAULT 0,
|
||||
doctor VARCHAR(255),
|
||||
remark TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (original_image_id) REFERENCES images(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 4: CRIAR ÍNDICES
|
||||
-- ================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_images_enabled ON images(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_original ON images(original_image_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_patient_name ON images(patient_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_guid ON images(image_guid);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_name ON images(client_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON images(created_at);
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 5: VALIDAÇÃO FINAL
|
||||
-- ================================================================
|
||||
|
||||
-- Migration completed successfully!
|
||||
@@ -0,0 +1,289 @@
|
||||
-- ================================================================
|
||||
-- MIGRATION v2.0 - Sistema de Transformações e Gerenciamento
|
||||
-- Dental Image Server - MySQL
|
||||
-- ================================================================
|
||||
|
||||
-- Execute este arquivo no banco MySQL:
|
||||
-- mysql -u root -p dental_images < migration-v2.sql
|
||||
-- OU via phpMyAdmin: Import > Selecionar arquivo
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 1: CRIAR BANCO (SE NÃO EXISTIR)
|
||||
-- ================================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS dental_images
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE dental_images;
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 2: CRIAR TABELA USERS (SE NÃO EXISTIR)
|
||||
-- ================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 3: CRIAR TABELA IMAGES
|
||||
-- ================================================================
|
||||
|
||||
-- Drop tabela antiga se existir (CUIDADO: remove todos os dados!)
|
||||
-- DROP TABLE IF EXISTS images;
|
||||
|
||||
-- Criar tabela images
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
image_guid VARCHAR(255) NOT NULL,
|
||||
patient_name TEXT,
|
||||
original_filename TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
tooth_number VARCHAR(10),
|
||||
tooth_side VARCHAR(10),
|
||||
patient_name_resumed VARCHAR(50),
|
||||
original_image_id INT,
|
||||
rotation INT DEFAULT 0,
|
||||
flip_horizontal INT DEFAULT 0,
|
||||
flip_vertical INT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_images_enabled (enabled),
|
||||
INDEX idx_images_original (original_image_id),
|
||||
INDEX idx_patient_name (patient_name(100)),
|
||||
INDEX idx_image_guid (image_guid),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (original_image_id) REFERENCES images(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 4: ADICIONAR COLUNAS NOVAS (SE TABELA JÁ EXISTIR)
|
||||
-- ================================================================
|
||||
|
||||
-- Verificar e adicionar colunas se não existirem
|
||||
SET @db_name = 'dental_images';
|
||||
SET @table_name = 'images';
|
||||
|
||||
-- Coluna enabled
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''enabled''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN enabled TINYINT(1) DEFAULT 1'',''SELECT \\''Column enabled already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Executar query se necessário
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Coluna tooth_number
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''tooth_number''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN tooth_number VARCHAR(10)'',''SELECT \\''Column tooth_number already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Coluna tooth_side
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''tooth_side''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN tooth_side VARCHAR(10)'',''SELECT \\''Column tooth_side already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Coluna patient_name_resumed
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''patient_name_resumed''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN patient_name_resumed VARCHAR(50)'',''SELECT \\''Column patient_name_resumed already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Coluna original_image_id
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''original_image_id''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN original_image_id INT'',''SELECT \\''Column original_image_id already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Coluna rotation
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''rotation''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN rotation INT DEFAULT 0'',''SELECT \\''Column rotation already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Coluna flip_horizontal
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''flip_horizontal''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN flip_horizontal INT DEFAULT 0'',''SELECT \\''Column flip_horizontal already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Coluna flip_vertical
|
||||
SET @sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @col_exists FROM information_schema.columns ',
|
||||
'WHERE table_schema = ? AND table_name = ? AND column_name = ''flip_vertical''; ',
|
||||
'SELECT IF(@col_exists = 0, ''ALTER TABLE images ADD COLUMN flip_vertical INT DEFAULT 0'',''SELECT \\''Column flip_vertical already exists\\'' AS message'') INTO @query; '
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt USING @db_name, @table_name;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 5: CRIAR ÍNDICES (SE NÃO EXISTIREM)
|
||||
-- ================================================================
|
||||
|
||||
-- MySQL suporta CREATE INDEX IF NOT EXISTS apenas em algumas versões
|
||||
-- Usando método alternativo:
|
||||
|
||||
-- Índice enabled
|
||||
SELECT COUNT(*) INTO @idx_exists
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @db_name
|
||||
AND table_name = @table_name
|
||||
AND index_name = 'idx_images_enabled';
|
||||
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'CREATE INDEX idx_images_enabled ON images(enabled)',
|
||||
'SELECT "Index idx_images_enabled already exists" AS message'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Índice original
|
||||
SELECT COUNT(*) INTO @idx_exists
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @db_name
|
||||
AND table_name = @table_name
|
||||
AND index_name = 'idx_images_original';
|
||||
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'CREATE INDEX idx_images_original ON images(original_image_id)',
|
||||
'SELECT "Index idx_images_original already exists" AS message'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Índice patient_name
|
||||
SELECT COUNT(*) INTO @idx_exists
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @db_name
|
||||
AND table_name = @table_name
|
||||
AND index_name = 'idx_patient_name';
|
||||
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'CREATE INDEX idx_patient_name ON images(patient_name(100))',
|
||||
'SELECT "Index idx_patient_name already exists" AS message'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Índice image_guid
|
||||
SELECT COUNT(*) INTO @idx_exists
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @db_name
|
||||
AND table_name = @table_name
|
||||
AND index_name = 'idx_image_guid';
|
||||
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'CREATE INDEX idx_image_guid ON images(image_guid)',
|
||||
'SELECT "Index idx_image_guid already exists" AS message'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Índice created_at
|
||||
SELECT COUNT(*) INTO @idx_exists
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @db_name
|
||||
AND table_name = @table_name
|
||||
AND index_name = 'idx_created_at';
|
||||
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'CREATE INDEX idx_created_at ON images(created_at)',
|
||||
'SELECT "Index idx_created_at already exists" AS message'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ================================================================
|
||||
-- PARTE 6: VALIDAÇÃO FINAL
|
||||
-- ================================================================
|
||||
|
||||
SELECT 'Migration completed successfully!' AS result;
|
||||
SELECT 'Table structure:' AS info;
|
||||
DESCRIBE images;
|
||||
|
||||
-- ================================================================
|
||||
-- FIM DA MIGRATION
|
||||
-- ================================================================
|
||||
@@ -0,0 +1,104 @@
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
-- MariaDB dump 10.19 Distrib 10.11.10-MariaDB, for Linux (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: dental_images
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 10.11.10-MariaDB-log
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||
|
||||
--
|
||||
-- Table structure for table `images`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `images`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `images` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`image_guid` varchar(255) NOT NULL,
|
||||
`patient_name` text DEFAULT NULL,
|
||||
`client_name` varchar(100) DEFAULT NULL,
|
||||
`original_filename` text DEFAULT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`enabled` tinyint(1) DEFAULT 1,
|
||||
`tooth_number` varchar(10) DEFAULT NULL,
|
||||
`tooth_side` varchar(10) DEFAULT NULL,
|
||||
`patient_name_resumed` varchar(50) DEFAULT NULL,
|
||||
`original_image_id` int(11) DEFAULT NULL,
|
||||
`rotation` int(11) DEFAULT 0,
|
||||
`flip_horizontal` int(11) DEFAULT 0,
|
||||
`flip_vertical` int(11) DEFAULT 0,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_images_enabled` (`enabled`),
|
||||
KEY `idx_images_original` (`original_image_id`),
|
||||
KEY `idx_patient_name` (`patient_name`(100)),
|
||||
KEY `idx_image_guid` (`image_guid`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_client_name` (`client_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping data for table `images`
|
||||
--
|
||||
|
||||
LOCK TABLES `images` WRITE;
|
||||
/*!40000 ALTER TABLE `images` DISABLE KEYS */;
|
||||
/*!40000 ALTER TABLE `images` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
--
|
||||
-- Table structure for table `users`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `users` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(50) NOT NULL,
|
||||
`email` varchar(100) NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
UNIQUE KEY `email` (`email`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping data for table `users`
|
||||
--
|
||||
|
||||
LOCK TABLES `users` WRITE;
|
||||
/*!40000 ALTER TABLE `users` DISABLE KEYS */;
|
||||
INSERT INTO `users` VALUES
|
||||
(1,'rcesar','rcesar@rcesar.com','$2b$10$i0zu9BGACsu6TDTsrU1EaOGZkKnvp8zwjOek1W/3m9mP4mzbN8HkC','2025-10-31 07:16:50');
|
||||
/*!40000 ALTER TABLE `users` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
--
|
||||
-- Dumping routines for database 'dental_images'
|
||||
--
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2025-10-31 21:24:39
|
||||
@@ -0,0 +1,84 @@
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
-- MariaDB dump 10.19 Distrib 10.11.10-MariaDB, for Linux (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: dental_images
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 10.11.10-MariaDB-log
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||
|
||||
--
|
||||
-- Table structure for table `images`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `images`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `images` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`image_guid` varchar(255) NOT NULL,
|
||||
`patient_name` text DEFAULT NULL,
|
||||
`client_name` varchar(100) DEFAULT NULL,
|
||||
`original_filename` text DEFAULT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`enabled` tinyint(1) DEFAULT 1,
|
||||
`tooth_number` varchar(10) DEFAULT NULL,
|
||||
`tooth_side` varchar(10) DEFAULT NULL,
|
||||
`patient_name_resumed` varchar(50) DEFAULT NULL,
|
||||
`original_image_id` int(11) DEFAULT NULL,
|
||||
`rotation` int(11) DEFAULT 0,
|
||||
`flip_horizontal` int(11) DEFAULT 0,
|
||||
`flip_vertical` int(11) DEFAULT 0,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_images_enabled` (`enabled`),
|
||||
KEY `idx_images_original` (`original_image_id`),
|
||||
KEY `idx_patient_name` (`patient_name`(100)),
|
||||
KEY `idx_image_guid` (`image_guid`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_client_name` (`client_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `users`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `users` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(50) NOT NULL,
|
||||
`email` varchar(100) NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
UNIQUE KEY `email` (`email`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping routines for database 'dental_images'
|
||||
--
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2025-10-31 21:24:36
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Gerenciador de Imagens Dentais</title>
|
||||
<meta name="description" content="Sistema de gerenciamento de imagens de raio-X dental">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>🦷 DentalSys</h2>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/" class="nav-item active">
|
||||
<span class="icon">🖼️</span> Pacientes
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openCreatePatientModal(); return false;">
|
||||
<span class="icon">➕</span> Novo Paciente
|
||||
</a>
|
||||
<a href="/clients" class="nav-item">
|
||||
<span class="icon">🖥️</span> Dispositivos
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openSettingsModal(); return false;">
|
||||
<span class="icon">⚙️</span> Configurações
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openPluginsModal(); return false;">
|
||||
<span class="icon">🔌</span> Plugins / Wasabi
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="openSyncModal(); return false;">
|
||||
<span class="icon">🔄</span> Sincronização
|
||||
</a>
|
||||
<a href="/reset" class="nav-item" style="color: #dc3545;">
|
||||
<span class="icon">⚠️</span> Zerar Sistema
|
||||
</a>
|
||||
<div class="nav-divider"></div>
|
||||
<button id="toggleDisabledBtn" class="btn btn-secondary" style="margin: 10px 20px; width: calc(100% - 40px);">
|
||||
<span id="toggleText">Ocultas</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<button id="backBtn" class="btn btn-back" style="display:none;">
|
||||
← Voltar
|
||||
</button>
|
||||
<h1 id="mainTitle">Galeria de Imagens</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<select id="clientFilter" class="client-filter">
|
||||
<option value="">📋 Todos os Clientes</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
id="searchInput"
|
||||
class="search-input"
|
||||
placeholder="🔍 Pesquisar paciente..."
|
||||
>
|
||||
<button class="btn btn-secondary" onclick="loadClientsList()">
|
||||
<span class="icon">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient sub-header (shown in patient-images view) -->
|
||||
<div id="patientHeader" class="patient-header" style="display:none;">
|
||||
<div class="patient-header-info">
|
||||
<div id="patientTitle" class="patient-header-name"></div>
|
||||
<div id="patientMeta" class="patient-header-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<div class="content-scroll">
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loadingState" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Carregando...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="empty-state" style="display: none;">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h2>Nenhum resultado encontrado</h2>
|
||||
<p>As imagens aparecerão aqui quando forem enviadas pelo cliente Windows</p>
|
||||
</div>
|
||||
|
||||
<!-- Images / Patients Grid -->
|
||||
<div id="imagesGrid" class="images-grid"> </div>
|
||||
|
||||
</div><!-- end .content-scroll -->
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Transform Modal -->
|
||||
<div id="transformModal" class="modal">
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header" style="display: flex; justify-content: space-between; width: 100%; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 15px;">
|
||||
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
|
||||
<h2 id="transformModalTitle" style="margin: 0; font-size: 1.4rem;"><!-- Dynamic Title (Patient + Date) --></h2>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="btnShowTransform" class="btn btn-primary" onclick="toggleModalView('transform')" style="width: 60px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 1.2rem;" title="Ajustar Imagem">🔄</button>
|
||||
<button id="btnShowGto" class="btn btn-secondary" onclick="toggleModalView('gto')" style="width: 60px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 1.2rem;" title="Gerenciar GTOs">📋</button>
|
||||
<button id="btnShowInfo" class="btn btn-secondary" onclick="toggleModalInfo()" style="width: 60px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 1.2rem;" title="Informações">ℹ️</button>
|
||||
<button id="btnResetImage" class="btn btn-danger" onclick="resetImageToOriginal()" style="width: 60px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; background-color: #ff6b6b; border-color: #ff6b6b; color: white;" title="Resetar para Original">↩️</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="modal-close" id="closeTransformModal">×</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="padding-top: 15px;">
|
||||
<!-- Bloco oculto de informações -->
|
||||
<div id="transformExtraInfo" style="display: none; font-size: 0.95rem; color: #555; background: #f8f9fa; padding: 12px 20px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 15px;">
|
||||
<!-- Preenchido via JS -->
|
||||
</div>
|
||||
|
||||
<!-- View 1: Transforms -->
|
||||
<div id="transformViewWrapper" style="display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%;">
|
||||
<div id="transformOptions" class="transform-grid" style="flex: 1; min-height: 0;"></div>
|
||||
<div id="transformActionArea" style="display: none; margin-top: 20px; background: #f8f9fa; padding: 15px; border-radius: 8px; flex-shrink: 0; border: 1px solid #e3e8ee;">
|
||||
<!-- Ajuste Fino de Rotação -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #e3e8ee; padding-bottom: 15px;">
|
||||
<span style="font-weight: 700; font-size: 0.95rem; margin-bottom: 10px; color: var(--dark-color); display: flex; align-items: center; gap: 6px;">
|
||||
📐 Ajuste Fino de Ângulo (Sensor Inclinado)
|
||||
</span>
|
||||
<div style="display: flex; align-items: center; gap: 8px; justify-content: center; flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(-5)" title="Rotacionar -5°">🔄 -5°</button>
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(-1)" title="Rotacionar -1°">🔄 -1°</button>
|
||||
|
||||
<div style="min-width: 100px; text-align: center; padding: 6px 12px; background: white; border: 1px solid #cbd5e0; border-radius: 6px; font-family: monospace; font-weight: 700; font-size: 1.1rem; color: var(--primary-color);" id="fineTuneValDisplay">
|
||||
0°
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(1)" title="Rotacionar +1°">🔄 +1°</button>
|
||||
<button type="button" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem; display: flex; align-items: center; gap: 3px;" onclick="adjustFineTune(5)" title="Rotacionar +5°">🔄 +5°</button>
|
||||
|
||||
<button type="button" class="btn btn-danger btn-small" style="padding: 6px 12px; font-size: 0.85rem; margin-left: 8px; display: none; background: #e53e3e; border-color: #e53e3e; color: white;" id="btnResetFineTune" onclick="resetFineTune()" title="Resetar ajuste fino">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style="font-weight: 600; display: block; margin-bottom: 8px;">Nova Observação / Detalhes (Opcional):</label>
|
||||
<textarea id="newImageRemark" class="search-input" rows="2" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 6px;" placeholder="Ex: Raio-X Dente 45..."></textarea>
|
||||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||
<button class="btn btn-secondary" style="flex: 1; padding: 12px; font-size: 1.1rem;" onclick="clearTransformSelection()">Cancelar</button>
|
||||
<button id="btnSaveTransform" class="btn btn-primary" style="flex: 2; padding: 12px; font-size: 1.1rem;" onclick="saveSelectedTransform()">Salvar Nova Imagem</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View 2: GTO List & Link -->
|
||||
<div id="gtoView" style="display: none;">
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<h3 style="margin-top: 0; font-size: 1.1rem; margin-bottom: 15px;">Cadastrar Nova GTO</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="inlineGtoNumber" class="search-input" placeholder="Número/ID da GTO" style="flex: 1; padding: 10px;">
|
||||
<input type="text" id="inlineGtoDesc" class="search-input" placeholder="Descrição (Opcional)" style="flex: 2; padding: 10px;">
|
||||
<button class="btn btn-primary" onclick="inlineCreateGto()">Cadastrar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0; font-size: 1.1rem;">Vincular esta imagem a uma GTO:</h3>
|
||||
<button class="btn btn-success" onclick="linkImageToGto()" id="btnLinkGto">✅ Vincular Selecionada</button>
|
||||
</div>
|
||||
|
||||
<div id="gtoListContainer" style="border: 1px solid #ddd; border-radius: 8px; overflow: hidden;">
|
||||
<!-- GTO list goes here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Novo Paciente -->
|
||||
<div class="modal" id="create-patient-modal">
|
||||
<div class="modal-content" style="max-width: 500px; height: auto; border-radius: var(--radius);">
|
||||
<div class="modal-header">
|
||||
<h2>Novo Paciente</h2>
|
||||
<div class="modal-close" onclick="closeCreatePatientModal()">×</div>
|
||||
</div>
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<form id="create-patient-form" onsubmit="submitCreatePatient(event)">
|
||||
<div class="form-group">
|
||||
<label>Nome *</label>
|
||||
<input type="text" id="patient-first-name" required placeholder="Ex: João">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sobrenome *</label>
|
||||
<input type="text" id="patient-last-name" required placeholder="Ex: Silva">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Dentista</label>
|
||||
<input type="text" id="patient-doctor" placeholder="Nome do dentista responsável">
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label>Data de Nascimento</label>
|
||||
<input type="date" id="patient-birthday">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label>Gênero</label>
|
||||
<select id="patient-gender">
|
||||
<option value="0">Masculino</option>
|
||||
<option value="1">Feminino</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Telefone</label>
|
||||
<input type="text" id="patient-phone" placeholder="(11) 99999-9999">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Observações</label>
|
||||
<textarea id="patient-remark" rows="3" placeholder="Informações adicionais..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 10px;">
|
||||
Salvar Paciente
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nova GTO -->
|
||||
<div class="modal" id="create-gto-modal">
|
||||
<div class="modal-content" style="max-width: 400px; height: auto; border-radius: var(--radius);">
|
||||
<div class="modal-header">
|
||||
<h2>Nova GTO</h2>
|
||||
<div class="modal-close" onclick="closeCreateGtoModal()">×</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="create-gto-form" onsubmit="submitCreateGto(event)">
|
||||
<div class="form-group">
|
||||
<label>Número/ID da GTO *</label>
|
||||
<input type="text" id="gto-number" required placeholder="Ex: GTO-2023-001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Descrição (opcional)</label>
|
||||
<textarea id="gto-description" rows="3" placeholder="Informações da guia..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 10px;">
|
||||
Criar GTO
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast de Notificação -->
|
||||
<div class="toast" id="toast">Operação realizada com sucesso!</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settingsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>⚙️ Configurações da Conta</h2>
|
||||
<span class="close-modal" onclick="closeSettingsModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="settingsForm" onsubmit="updateCredentials(event)">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">Senha Atual (Obrigatório)*</label>
|
||||
<input type="password" id="currentPassword" class="form-control" placeholder="Digite sua senha atual para autorizar" required>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="margin-bottom: 15px; color: #666; font-size: 0.9rem;">Preencha apenas os campos que deseja alterar:</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newUsername">Novo Nome de Usuário</label>
|
||||
<input type="text" id="newUsername" class="form-control" placeholder="Deixe em branco para não alterar">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">Nova Senha</label>
|
||||
<input type="password" id="newPassword" class="form-control" placeholder="Deixe em branco para não alterar">
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSettingsModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSaveSettings">Salvar Alterações</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugins / Wasabi Modal -->
|
||||
<div id="pluginsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>🔌 Configuração do Wasabi Storage</h2>
|
||||
<span class="modal-close" onclick="closePluginsModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="pluginsForm" onsubmit="updatePluginsConfig(event)">
|
||||
<div style="background: #f8f9fa; padding: 12px 18px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555;">
|
||||
Configure as credenciais e bucket do **Wasabi S3** para habilitar o armazenamento na nuvem. Se desativado, o sistema usará o armazenamento local do servidor de forma automática.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wasabiAccessKey">Access Key *</label>
|
||||
<input type="text" id="wasabiAccessKey" placeholder="Ex: AIPL5M4G..." required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wasabiSecretKey">Secret Key *</label>
|
||||
<input type="password" id="wasabiSecretKey" placeholder="Sua Secret Key" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wasabiBucket">Bucket Ativo *</label>
|
||||
<input type="text" id="wasabiBucket" placeholder="Ex: meu-bucket-rx" required>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="wasabiRegion">Região</label>
|
||||
<input type="text" id="wasabiRegion" placeholder="Ex: us-east-1 (padrão)">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="wasabiEndpoint">Endpoint</label>
|
||||
<input type="text" id="wasabiEndpoint" placeholder="Ex: s3.wasabisys.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pluginsStatusAlert" style="display: none; padding: 10px; border-radius: 6px; font-size: 0.9rem; margin-top: 15px;"></div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #eee; padding-top: 15px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePluginsModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSavePlugins">Salvar Configurações</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync / Upload Modal -->
|
||||
<div id="syncModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 650px; height: auto; border-radius: var(--radius);">
|
||||
<div class="modal-header">
|
||||
<h2>🔄 Área de Sincronização</h2>
|
||||
<span class="modal-close" onclick="closeSyncModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sync-tabs">
|
||||
<button type="button" id="tabBtnDevices" class="sync-tab-btn active" onclick="switchSyncTab('devices')">💻 Dispositivos Conectados</button>
|
||||
<button type="button" id="tabBtnUpload" class="sync-tab-btn" onclick="switchSyncTab('upload')">📤 Envio Manual</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Dispositivos -->
|
||||
<div id="tab-devices" class="sync-tab-content active">
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555; line-height: 1.5;">
|
||||
Os computadores clientes com Windows instalados e ativos enviam imagens em tempo real. Você pode solicitar que eles façam uma sincronização manual forçada com o servidor enviando um sinal.
|
||||
</div>
|
||||
|
||||
<div id="syncDevicesLoading" class="loading" style="padding: 20px 0;">
|
||||
<div class="spinner" style="width: 30px; height: 30px; margin-bottom: 10px;"></div>
|
||||
<p>Buscando dispositivos online...</p>
|
||||
</div>
|
||||
|
||||
<div id="syncDevicesEmpty" style="display: none; text-align: center; padding: 30px 20px; color: #666;">
|
||||
<span style="font-size: 2.5rem; display: block; margin-bottom: 10px;">📭</span>
|
||||
<p>Nenhum dispositivo Windows conectado no momento.</p>
|
||||
</div>
|
||||
|
||||
<div id="syncDeviceList" class="sync-device-list" style="display: none;">
|
||||
<!-- Preenchido via Javascript -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Fechar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="triggerSyncFromModal()" id="btnTriggerSyncModal">
|
||||
<span class="icon">🔄</span> Enviar Sinal de Sincronização
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Envio Manual -->
|
||||
<div id="tab-upload" class="sync-tab-content">
|
||||
<form id="manualUploadForm" onsubmit="handleManualUploadSubmit(event)">
|
||||
<div class="form-group">
|
||||
<label for="uploadPatientSelect">Paciente *</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<select id="uploadPatientSelect" onchange="toggleNewPatientFields()" required style="flex: 1;">
|
||||
<option value="">-- Selecione o Paciente --</option>
|
||||
<option value="NEW_PATIENT">➕ Novo Paciente...</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" onclick="refreshUploadPatients()" title="Atualizar Lista" style="width: 44px; padding: 0; display: flex; align-items: center; justify-content: center;">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campos para Novo Paciente (ocultos por padrão) -->
|
||||
<div id="newPatientFields" style="display: none; background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 18px; animation: slideDown 0.2s ease;">
|
||||
<h4 style="margin: 0 0 12px 0; color: var(--dark-color); font-size: 0.95rem;">Dados do Novo Paciente</h4>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Nome *</label>
|
||||
<input type="text" id="uploadNewPatientFirstName" placeholder="Ex: Maria" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Sobrenome *</label>
|
||||
<input type="text" id="uploadNewPatientLastName" placeholder="Ex: Oliveira" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Dentista Responsável</label>
|
||||
<input type="text" id="uploadNewPatientDoctor" placeholder="Nome do dentista (opcional)" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="uploadRemark">Observações / Detalhes (Opcional)</label>
|
||||
<textarea id="uploadRemark" placeholder="Ex: Raio-X Panorâmico" rows="2" style="width: 100%; padding: 10px; border: 2px solid var(--border-color); border-radius: var(--radius-sm); font-family: inherit; font-size: 1rem; resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Zona de Drag and Drop -->
|
||||
<div class="drag-drop-zone" id="uploadDragDropZone">
|
||||
<div class="drag-drop-icon">📸</div>
|
||||
<div class="drag-drop-text">Arraste as imagens aqui ou clique para selecionar</div>
|
||||
<div class="drag-drop-subtext">Suporta arquivos JPG, JPEG e PNG (máx. 10MB por imagem)</div>
|
||||
<input type="file" id="uploadFileInput" multiple accept="image/png, image/jpeg, image/jpg" style="display: none;">
|
||||
</div>
|
||||
|
||||
<!-- Previews das Imagens Selecionadas -->
|
||||
<div class="upload-previews" id="uploadPreviewsContainer"></div>
|
||||
|
||||
<!-- Progresso de Envio -->
|
||||
<div class="upload-progress-container" id="uploadProgressContainer" style="display: none;">
|
||||
<div class="progress-bar-label">
|
||||
<span id="uploadProgressText">Enviando imagens...</span>
|
||||
<span id="uploadProgressPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" id="uploadProgressBarFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSubmitManualUpload" disabled>Enviar Imagens</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Script -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ImageOptimizer = void 0;
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
class ImageOptimizer {
|
||||
/**
|
||||
* Optimizes an image: converts to WebP, strips metadata,
|
||||
* and applies a 1:1 Smart Crop if needed.
|
||||
*/
|
||||
static async optimize(buffer, category = 'media', options = {}) {
|
||||
try {
|
||||
let pipeline = (0, sharp_1.default)(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
if (!metadata.format)
|
||||
return { buffer, mimetype: 'application/octet-stream' };
|
||||
// 1. Identify Target Size
|
||||
// Default sizes based on category
|
||||
let targetSize = options.size || 1024;
|
||||
if (category === 'partners' || category === 'users')
|
||||
targetSize = 400;
|
||||
if (category === 'benefits')
|
||||
targetSize = 800;
|
||||
// 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category
|
||||
const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category);
|
||||
if (shouldCrop) {
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
fit: sharp_1.default.fit.cover,
|
||||
position: sharp_1.default.strategy.entropy // Smart focal point detection
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Resize without cropping if it's too large
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
withoutEnlargement: true,
|
||||
fit: sharp_1.default.fit.inside
|
||||
});
|
||||
}
|
||||
// 3. Convert to WebP & Strip Metadata
|
||||
const optimizedBuffer = await pipeline
|
||||
.webp({ quality: 85, effort: 4 })
|
||||
.toBuffer();
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimetype: 'image/webp'
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[SmartVision] Optimization failed:', err);
|
||||
return { buffer, mimetype: 'image/jpeg' }; // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ImageOptimizer = ImageOptimizer;
|
||||
//# sourceMappingURL=Optimizer.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"Optimizer.js","sourceRoot":"","sources":["Optimizer.ts"],"names":[],"mappings":";;;;;;AAAA,kDAA0B;AAO1B,MAAa,cAAc;IACvB;;;OAGG;IACH,MAAM,CAAC,KAAK,CAAC,QAAQ,CACjB,MAAc,EACd,WAAmB,OAAO,EAC1B,UAA8C,EAAE;QAEhD,IAAI,CAAC;YACD,IAAI,QAAQ,GAAG,IAAA,eAAK,EAAC,MAAM,CAAC,CAAC;YAC7B,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,CAAC;YAE3C,IAAI,CAAC,QAAQ,CAAC,MAAM;gBAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,0BAA0B,EAAE,CAAC;YAE9E,0BAA0B;YAC1B,kCAAkC;YAClC,IAAI,UAAU,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC;YACtC,IAAI,QAAQ,KAAK,UAAU,IAAI,QAAQ,KAAK,OAAO;gBAAE,UAAU,GAAG,GAAG,CAAC;YACtE,IAAI,QAAQ,KAAK,UAAU;gBAAE,UAAU,GAAG,GAAG,CAAC;YAE9C,sEAAsE;YACtE,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YAExF,IAAI,UAAU,EAAE,CAAC;gBACb,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,MAAM,EAAE,UAAU;oBAClB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,KAAK;oBACpB,QAAQ,EAAE,eAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,8BAA8B;iBAClE,CAAC,CAAC;YACP,CAAC;iBAAM,CAAC;gBACJ,4CAA4C;gBAC5C,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,kBAAkB,EAAE,IAAI;oBACxB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,MAAM;iBACxB,CAAC,CAAC;YACP,CAAC;YAED,sCAAsC;YACtC,MAAM,eAAe,GAAG,MAAM,QAAQ;iBACjC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;iBAChC,QAAQ,EAAE,CAAC;YAEhB,OAAO;gBACH,MAAM,EAAE,eAAe;gBACvB,QAAQ,EAAE,YAAY;aACzB,CAAC;QACN,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,OAAO,CAAC,KAAK,CAAC,oCAAoC,EAAE,GAAG,CAAC,CAAC;YACzD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,CAAC,CAAC,WAAW;QAC1D,CAAC;IACL,CAAC;CACJ;AAvDD,wCAuDC"}
|
||||
@@ -0,0 +1,63 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export interface OptimizationResult {
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export class ImageOptimizer {
|
||||
/**
|
||||
* Optimizes an image: converts to WebP, strips metadata,
|
||||
* and applies a 1:1 Smart Crop if needed.
|
||||
*/
|
||||
static async optimize(
|
||||
buffer: Buffer,
|
||||
category: string = 'media',
|
||||
options: { aspect?: string; size?: number } = {}
|
||||
): Promise<OptimizationResult> {
|
||||
try {
|
||||
let pipeline = sharp(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
|
||||
if (!metadata.format) return { buffer, mimetype: 'application/octet-stream' };
|
||||
|
||||
// 1. Identify Target Size
|
||||
// Default sizes based on category
|
||||
let targetSize = options.size || 1024;
|
||||
if (category === 'partners' || category === 'users') targetSize = 400;
|
||||
if (category === 'benefits') targetSize = 800;
|
||||
|
||||
// 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category
|
||||
const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category);
|
||||
|
||||
if (shouldCrop) {
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
fit: sharp.fit.cover,
|
||||
position: sharp.strategy.entropy // Smart focal point detection
|
||||
});
|
||||
} else {
|
||||
// Resize without cropping if it's too large
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
withoutEnlargement: true,
|
||||
fit: sharp.fit.inside
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Convert to WebP & Strip Metadata
|
||||
const optimizedBuffer = await pipeline
|
||||
.webp({ quality: 85, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimetype: 'image/webp'
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[SmartVision] Optimization failed:', err);
|
||||
return { buffer, mimetype: 'image/jpeg' }; // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const Optimizer_1 = require("./backend/Optimizer");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
// Register for the upload:transform hook
|
||||
ctx.hooks.register('upload:transform', async (payload) => {
|
||||
const { buffer, mimetype, category } = payload;
|
||||
// Only process common image formats
|
||||
const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
if (!imageTypes.includes(mimetype)) {
|
||||
return null; // Skip non-images
|
||||
}
|
||||
ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`);
|
||||
const result = await Optimizer_1.ImageOptimizer.optimize(buffer, category);
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
mimetype: result.mimetype
|
||||
};
|
||||
});
|
||||
ctx.logger.info('SmartVision Optimizer activated');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('SmartVision Optimizer deactivated');
|
||||
}
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,mDAAqD;AACrD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,yCAAyC;QACzC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,kBAAkB,EAAE,KAAK,EAAE,OAAY,EAAE,EAAE;YAC1D,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;YAE/C,oCAAoC;YACpC,MAAM,UAAU,GAAG,CAAC,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,CAAC,CAAC;YAC3E,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACjC,OAAO,IAAI,CAAC,CAAC,kBAAkB;YACnC,CAAC;YAED,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gDAAgD,QAAQ,EAAE,CAAC,CAAC;YAE5E,MAAM,MAAM,GAAG,MAAM,0BAAc,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAE/D,OAAO;gBACH,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;aAC5B,CAAC;QACN,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,iCAAiC,CAAC,CAAC;IACvD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAC;IACzD,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { ImageOptimizer } from './backend/Optimizer';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
// Register for the upload:transform hook
|
||||
ctx.hooks.register('upload:transform', async (payload: any) => {
|
||||
const { buffer, mimetype, category } = payload;
|
||||
|
||||
// Only process common image formats
|
||||
const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
if (!imageTypes.includes(mimetype)) {
|
||||
return null; // Skip non-images
|
||||
}
|
||||
|
||||
ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`);
|
||||
|
||||
const result = await ImageOptimizer.optimize(buffer, category);
|
||||
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
mimetype: result.mimetype
|
||||
};
|
||||
});
|
||||
|
||||
ctx.logger.info('SmartVision Optimizer activated');
|
||||
},
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('SmartVision Optimizer deactivated');
|
||||
}
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "SmartVision",
|
||||
"displayName": "SmartVision Optimizer",
|
||||
"version": "1.0.0",
|
||||
"description": "Otimização inteligente de imagens com WebP e Smart Crop 1:1.",
|
||||
"author": "Clube67 Team",
|
||||
"category": "integration",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [
|
||||
"uploads"
|
||||
],
|
||||
"hooks": {
|
||||
"subscribes": [
|
||||
"upload:transform"
|
||||
],
|
||||
"emits": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const client_s3_1 = require("@aws-sdk/client-s3");
|
||||
const lib_storage_1 = require("@aws-sdk/lib-storage");
|
||||
let storageProvider;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
}
|
||||
else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
||||
// Mapa de extensão → mimetype para o proxy
|
||||
const MIME_MAP = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg', doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
class UnifiedStorageProviderPlugin {
|
||||
constructor() {
|
||||
this.s3 = null;
|
||||
this.bucket = '';
|
||||
this.region = 'us-east-1';
|
||||
this.localFallbackDir = path_1.default.resolve(process.cwd(), 'storage', 'local_fallback');
|
||||
this.routesRegistered = false;
|
||||
}
|
||||
async activate(ctx) {
|
||||
this.ctx = ctx;
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
if (!fs_1.default.existsSync(this.localFallbackDir)) {
|
||||
fs_1.default.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
}
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1');
|
||||
this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '');
|
||||
const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com');
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug');
|
||||
if (!accessKey || !secretKey || !this.bucket) {
|
||||
ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin');
|
||||
}
|
||||
else {
|
||||
this.s3 = new client_s3_1.S3Client({
|
||||
region: this.region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
storageProvider.register(this);
|
||||
ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`);
|
||||
this.startSyncWorker();
|
||||
}
|
||||
if (!this.routesRegistered) {
|
||||
// ── Proxy: serve arquivos do Wasabi (ou fallback local) ────────────────
|
||||
ctx.app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath)
|
||||
return res.status(400).send('Path missing');
|
||||
try {
|
||||
// 1. Fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, filePath);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'local_fallback');
|
||||
return res.send(fs_1.default.readFileSync(localPath));
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return res.status(503).send('Storage não inicializado');
|
||||
}
|
||||
const result = await this.s3.send(new client_s3_1.GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: filePath,
|
||||
}));
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'wasabi');
|
||||
if (result.Body) {
|
||||
const bytes = await result.Body.transformToByteArray();
|
||||
return res.send(Buffer.from(bytes));
|
||||
}
|
||||
return res.status(404).send('Body vazio');
|
||||
}
|
||||
catch {
|
||||
return res.status(404).send('Arquivo não encontrado');
|
||||
}
|
||||
});
|
||||
// ── POST /api/storage/wasabi/list-buckets ──────────────────────────────
|
||||
// Lista buckets usando credenciais do body (sem exigir S3 pré-configurado)
|
||||
ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint } = req.body;
|
||||
if (!accessKey || !secretKey) {
|
||||
return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' });
|
||||
}
|
||||
try {
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
const s3t = new client_s3_1.S3Client({
|
||||
region: region || 'us-east-1',
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const data = await s3t.send(new client_s3_1.ListBucketsCommand({}));
|
||||
const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate }));
|
||||
return res.json({ buckets });
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── POST /api/storage/wasabi/create-bucket ─────────────────────────────
|
||||
ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint, name } = req.body;
|
||||
if (!accessKey || !secretKey)
|
||||
return res.status(400).json({ error: 'Credenciais obrigatórias' });
|
||||
if (!name)
|
||||
return res.status(400).json({ error: 'Campo "name" obrigatório' });
|
||||
const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const effectiveRegion = region || 'us-east-1';
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
try {
|
||||
const s3t = new client_s3_1.S3Client({
|
||||
region: effectiveRegion,
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const params = { Bucket: safeName };
|
||||
if (effectiveRegion !== 'us-east-1') {
|
||||
params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion };
|
||||
}
|
||||
await s3t.send(new client_s3_1.CreateBucketCommand(params));
|
||||
ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`);
|
||||
return res.status(201).json({ ok: true, name: safeName });
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
async deactivate(ctx) {
|
||||
this.s3 = null;
|
||||
ctx.logger.info('UnifiedStorageProvider desativado');
|
||||
}
|
||||
async checkHealth() {
|
||||
return (await this.checkDetailedHealth()).healthy;
|
||||
}
|
||||
async checkDetailedHealth() {
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.s3.send(new client_s3_1.HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` };
|
||||
}
|
||||
catch (err) {
|
||||
const code = err?.Code || err?.code || err?.name || '';
|
||||
const httpStatus = err?.$metadata?.httpStatusCode ?? 0;
|
||||
if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'auth_error',
|
||||
message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403 || code === 'AccessDenied') {
|
||||
// 403 pode ser pagamento atrasado ou permissão negada
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'billing_error',
|
||||
message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 404 || code === 'NoSuchBucket') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'bucket_not_found',
|
||||
message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`,
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'network_error',
|
||||
message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'unknown',
|
||||
message: `Erro ao verificar Wasabi: ${err?.message ?? code}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
async deletePrefix(prefix) {
|
||||
let deletedCount = 0;
|
||||
// 1. Limpa do Wasabi
|
||||
if (this.s3 && this.bucket) {
|
||||
try {
|
||||
let isTruncated = true;
|
||||
let continuationToken;
|
||||
while (isTruncated) {
|
||||
const listParams = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
};
|
||||
if (continuationToken) {
|
||||
listParams.ContinuationToken = continuationToken;
|
||||
}
|
||||
const listResult = await this.s3.send(new client_s3_1.ListObjectsV2Command(listParams));
|
||||
const objects = listResult.Contents ?? [];
|
||||
if (objects.length > 0) {
|
||||
const deleteParams = {
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key })),
|
||||
Quiet: true,
|
||||
},
|
||||
};
|
||||
await this.s3.send(new client_s3_1.DeleteObjectsCommand(deleteParams));
|
||||
deletedCount += objects.length;
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`);
|
||||
}
|
||||
isTruncated = listResult.IsTruncated ?? false;
|
||||
continuationToken = listResult.NextContinuationToken;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// 2. Limpa do fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, prefix);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
try {
|
||||
const stat = fs_1.default.statSync(localPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs_1.default.rmSync(localPath, { recursive: true, force: true });
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`);
|
||||
}
|
||||
else {
|
||||
fs_1.default.unlinkSync(localPath);
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return { deleted: deletedCount };
|
||||
}
|
||||
async upload(payload) {
|
||||
const { category, file } = payload;
|
||||
if (!VALID_CATEGORIES.includes(category))
|
||||
throw new Error(`Categoria inválida: ${category}`);
|
||||
let buffer = file.buffer;
|
||||
let originalName = file.originalname;
|
||||
let mimetype = file.mimetype;
|
||||
// Otimiza imagens grandes (>150 KB) para WebP
|
||||
if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) {
|
||||
try {
|
||||
buffer = await (0, sharp_1.default)(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer();
|
||||
mimetype = 'image/webp';
|
||||
originalName = originalName.replace(/\.[^.]+$/, '.webp');
|
||||
this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`);
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileName = `${Date.now()}-${safeBase}`;
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||
: `${category}/${fileName}`);
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
try {
|
||||
await new lib_storage_1.Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: buffer,
|
||||
ContentType: mimetype,
|
||||
},
|
||||
}).done();
|
||||
this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`);
|
||||
return { provider: 'wasabi', path: relativePath };
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`);
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
async getBuffer(relativePath) {
|
||||
// 1. Fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, relativePath);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
return fs_1.default.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket)
|
||||
return null;
|
||||
try {
|
||||
const result = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body)
|
||||
return null;
|
||||
const bytes = await result.Body.transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||
saveToLocalFallback(relativePath, buffer, originalName, _category) {
|
||||
const dest = path_1.default.join(this.localFallbackDir, relativePath);
|
||||
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
||||
fs_1.default.writeFileSync(dest, buffer);
|
||||
this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`);
|
||||
return { provider: 'local_fallback', path: relativePath };
|
||||
}
|
||||
/** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */
|
||||
startSyncWorker() {
|
||||
setInterval(async () => {
|
||||
if (!this.s3 || !this.bucket)
|
||||
return;
|
||||
await this.syncDir(this.localFallbackDir);
|
||||
}, 120000);
|
||||
}
|
||||
async syncDir(dir) {
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
return;
|
||||
for (const entry of fs_1.default.readdirSync(dir)) {
|
||||
const fullPath = path_1.default.join(dir, entry);
|
||||
const stat = fs_1.default.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
await this.syncDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relativePath = path_1.default.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/');
|
||||
try {
|
||||
await new lib_storage_1.Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: fs_1.default.readFileSync(fullPath),
|
||||
},
|
||||
}).done();
|
||||
fs_1.default.unlinkSync(fullPath);
|
||||
this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`);
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const instance = new UnifiedStorageProviderPlugin();
|
||||
exports.default = instance;
|
||||
//# sourceMappingURL=index.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
ListBucketsCommand,
|
||||
CreateBucketCommand,
|
||||
DeleteObjectsCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import type { StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider';
|
||||
|
||||
let storageProvider: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
} else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
||||
|
||||
// Mapa de extensão → mimetype para o proxy
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg', doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInterface {
|
||||
manifest: any;
|
||||
private s3: S3Client | null = null;
|
||||
private bucket: string = '';
|
||||
private region: string = 'us-east-1';
|
||||
private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback');
|
||||
private ctx!: PluginContext;
|
||||
private routesRegistered = false;
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
|
||||
if (!fs.existsSync(this.localFallbackDir)) {
|
||||
fs.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
}
|
||||
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1') as string;
|
||||
this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '') as string;
|
||||
|
||||
const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com') as string;
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
|
||||
ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug');
|
||||
|
||||
if (!accessKey || !secretKey || !this.bucket) {
|
||||
ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin');
|
||||
} else {
|
||||
this.s3 = new S3Client({
|
||||
region: this.region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey as string, secretAccessKey: secretKey as string },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
storageProvider.register(this);
|
||||
ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`);
|
||||
|
||||
this.startSyncWorker();
|
||||
}
|
||||
|
||||
if (!this.routesRegistered) {
|
||||
// ── Proxy: serve arquivos do Wasabi (ou fallback local) ────────────────
|
||||
ctx.app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath) return res.status(400).send('Path missing');
|
||||
|
||||
try {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, filePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'local_fallback');
|
||||
return res.send(fs.readFileSync(localPath));
|
||||
}
|
||||
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return res.status(503).send('Storage não inicializado');
|
||||
}
|
||||
|
||||
const result = await this.s3.send(new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: filePath,
|
||||
}));
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'wasabi');
|
||||
|
||||
if (result.Body) {
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return res.send(Buffer.from(bytes));
|
||||
}
|
||||
return res.status(404).send('Body vazio');
|
||||
} catch {
|
||||
return res.status(404).send('Arquivo não encontrado');
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/storage/wasabi/list-buckets ──────────────────────────────
|
||||
// Lista buckets usando credenciais do body (sem exigir S3 pré-configurado)
|
||||
ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint } = req.body as Record<string, string>;
|
||||
if (!accessKey || !secretKey) {
|
||||
return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' });
|
||||
}
|
||||
try {
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
const s3t = new S3Client({
|
||||
region: region || 'us-east-1',
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const data = await s3t.send(new ListBucketsCommand({}));
|
||||
const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate }));
|
||||
return res.json({ buckets });
|
||||
} catch (err: any) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/storage/wasabi/create-bucket ─────────────────────────────
|
||||
ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint, name } = req.body as Record<string, string>;
|
||||
if (!accessKey || !secretKey) return res.status(400).json({ error: 'Credenciais obrigatórias' });
|
||||
if (!name) return res.status(400).json({ error: 'Campo "name" obrigatório' });
|
||||
|
||||
const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const effectiveRegion = region || 'us-east-1';
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
|
||||
try {
|
||||
const s3t = new S3Client({
|
||||
region: effectiveRegion,
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const params: any = { Bucket: safeName };
|
||||
if (effectiveRegion !== 'us-east-1') {
|
||||
params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion };
|
||||
}
|
||||
await s3t.send(new CreateBucketCommand(params));
|
||||
ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`);
|
||||
return res.status(201).json({ ok: true, name: safeName });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
this.s3 = null;
|
||||
ctx.logger.info('UnifiedStorageProvider desativado');
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
return (await this.checkDetailedHealth()).healthy;
|
||||
}
|
||||
|
||||
async checkDetailedHealth(): Promise<import('../../backend/src/core/StorageProvider').StorageHealthStatus> {
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` };
|
||||
} catch (err: any) {
|
||||
const code: string = err?.Code || err?.code || err?.name || '';
|
||||
const httpStatus: number = err?.$metadata?.httpStatusCode ?? 0;
|
||||
|
||||
if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'auth_error',
|
||||
message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403 || code === 'AccessDenied') {
|
||||
// 403 pode ser pagamento atrasado ou permissão negada
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'billing_error',
|
||||
message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 404 || code === 'NoSuchBucket') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'bucket_not_found',
|
||||
message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`,
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'network_error',
|
||||
message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'unknown',
|
||||
message: `Erro ao verificar Wasabi: ${err?.message ?? code}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
||||
let deletedCount = 0;
|
||||
|
||||
// 1. Limpa do Wasabi
|
||||
if (this.s3 && this.bucket) {
|
||||
try {
|
||||
let isTruncated = true;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
while (isTruncated) {
|
||||
const listParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
};
|
||||
if (continuationToken) {
|
||||
listParams.ContinuationToken = continuationToken;
|
||||
}
|
||||
|
||||
const listResult = await this.s3.send(new ListObjectsV2Command(listParams));
|
||||
const objects = listResult.Contents ?? [];
|
||||
|
||||
if (objects.length > 0) {
|
||||
const deleteParams = {
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key! })),
|
||||
Quiet: true,
|
||||
},
|
||||
};
|
||||
await this.s3.send(new DeleteObjectsCommand(deleteParams));
|
||||
deletedCount += objects.length;
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`);
|
||||
}
|
||||
|
||||
isTruncated = listResult.IsTruncated ?? false;
|
||||
continuationToken = listResult.NextContinuationToken;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Limpa do fallback local
|
||||
const localPath = path.join(this.localFallbackDir, prefix);
|
||||
if (fs.existsSync(localPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(localPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`);
|
||||
} else {
|
||||
fs.unlinkSync(localPath);
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted: deletedCount };
|
||||
}
|
||||
|
||||
async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
|
||||
const { category, file } = payload;
|
||||
if (!VALID_CATEGORIES.includes(category)) throw new Error(`Categoria inválida: ${category}`);
|
||||
|
||||
let buffer = file.buffer;
|
||||
let originalName = file.originalname;
|
||||
let mimetype = file.mimetype;
|
||||
|
||||
// Otimiza imagens grandes (>150 KB) para WebP
|
||||
if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) {
|
||||
try {
|
||||
buffer = await sharp(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer();
|
||||
mimetype = 'image/webp';
|
||||
originalName = originalName.replace(/\.[^.]+$/, '.webp');
|
||||
this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`);
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileName = `${Date.now()}-${safeBase}`;
|
||||
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||
: `${category}/${fileName}`);
|
||||
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: buffer,
|
||||
ContentType: mimetype,
|
||||
},
|
||||
}).done();
|
||||
|
||||
this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`);
|
||||
return { provider: 'wasabi', path: relativePath };
|
||||
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`);
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(relativePath: string): Promise<Buffer | null> {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, relativePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
return fs.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) return null;
|
||||
try {
|
||||
const result = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body) return null;
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
|
||||
const dest = path.join(this.localFallbackDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buffer);
|
||||
this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`);
|
||||
return { provider: 'local_fallback', path: relativePath };
|
||||
}
|
||||
|
||||
/** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */
|
||||
private startSyncWorker(): void {
|
||||
setInterval(async () => {
|
||||
if (!this.s3 || !this.bucket) return;
|
||||
await this.syncDir(this.localFallbackDir);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
private async syncDir(dir: string): Promise<void> {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await this.syncDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.s3!,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: fs.readFileSync(fullPath),
|
||||
},
|
||||
}).done();
|
||||
|
||||
fs.unlinkSync(fullPath);
|
||||
this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`);
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new UnifiedStorageProviderPlugin();
|
||||
export default instance;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "UnifiedStorageProvider",
|
||||
"displayName": "Wasabi Storage",
|
||||
"version": "1.1.0",
|
||||
"description": "Armazenamento de mídias do WhatsApp via Wasabi (S3 compatível)",
|
||||
"author": "NewWhats",
|
||||
"category": "core",
|
||||
"enabled": true,
|
||||
"canDisable": false,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/storage",
|
||||
"hasMigrations": false
|
||||
},
|
||||
"configSchema": [
|
||||
{ "key": "wasabiAccessKey", "label": "Access Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave de acesso gerada em Account → Access Keys no painel Wasabi." },
|
||||
{ "key": "wasabiSecretKey", "label": "Secret Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave secreta correspondente à Access Key — exibida só uma vez no momento da criação." },
|
||||
{ "key": "wasabiBucket", "label": "Bucket Ativo", "type": "text", "required": true, "group": "Bucket", "tooltip": "Nome do bucket onde as mídias do WhatsApp serão armazenadas." },
|
||||
{ "key": "wasabiRegion", "label": "Region", "type": "text", "required": false, "group": "Avançado", "tooltip": "Região do bucket. Padrão: us-east-1. Exemplos: us-east-2, eu-central-1." },
|
||||
{ "key": "wasabiEndpoint", "label": "Endpoint", "type": "text", "required": false, "group": "Avançado", "tooltip": "Endpoint S3. Padrão: s3.wasabisys.com. Só altere se usar região diferente." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
const db = require('./database');
|
||||
const storage = require('./storage');
|
||||
const sharp = require('sharp');
|
||||
|
||||
// Limit of concurrency
|
||||
const CONCURRENCY = 15;
|
||||
|
||||
async function runBackfill() {
|
||||
console.log('🚀 Iniciando backfill de thumbnails concorrido...');
|
||||
|
||||
// 1. Inicializar banco de dados
|
||||
await db.initDatabase();
|
||||
console.log('✅ Banco de dados inicializado.');
|
||||
|
||||
// 2. Carregar configurações do Wasabi a partir do banco
|
||||
await storage.loadConfigFromDb();
|
||||
console.log('✅ Configuração do Wasabi carregada.');
|
||||
|
||||
// 3. Buscar todas as imagens que não possuem thumb_filename
|
||||
const query = "SELECT id, filename, client_name, patient_name FROM images WHERE (thumb_filename IS NULL OR thumb_filename = '') AND enabled = 1";
|
||||
const images = await db.all(query);
|
||||
console.log(`🔎 Encontradas ${images.length} imagens pendentes de thumbnail.`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// Worker function to process a single image
|
||||
async function processImage(img) {
|
||||
try {
|
||||
// a. Baixar imagem original do Wasabi
|
||||
const buffer = await storage.getImageBuffer(img.filename, img.client_name, img.patient_name, false);
|
||||
if (!buffer) {
|
||||
throw new Error(`Não foi possível obter o buffer para ${img.filename}`);
|
||||
}
|
||||
|
||||
// b. Gerar thumbnail WebP
|
||||
const thumbBuffer = await sharp(buffer)
|
||||
.resize({ width: 400, height: 400, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
// c. Salvar thumbnail no Wasabi
|
||||
const thumbFilename = `thumb_${Date.now()}_${img.id}.webp`;
|
||||
await storage.saveImage(thumbFilename, thumbBuffer, img.client_name, img.patient_name, false);
|
||||
|
||||
// d. Atualizar no banco de dados
|
||||
await db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, img.id]);
|
||||
|
||||
console.log(`✅ [ID ${img.id}] Thumbnail salvo com sucesso: ${thumbFilename} (Paciente: ${img.patient_name})`);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(`❌ [ID ${img.id}] Erro no processamento da imagem:`, err.message);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrency queue
|
||||
const queue = [...images];
|
||||
const workers = [];
|
||||
|
||||
async function worker() {
|
||||
while (queue.length > 0) {
|
||||
const img = queue.shift();
|
||||
if (!img) break;
|
||||
await processImage(img);
|
||||
}
|
||||
}
|
||||
|
||||
// Start workers
|
||||
for (let i = 0; i < Math.min(CONCURRENCY, images.length); i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
await Promise.all(workers);
|
||||
|
||||
console.log('\n=========================================');
|
||||
console.log('📊 Resumo do Backfill Concorrido:');
|
||||
console.log(`- Sucesso: ${successCount}`);
|
||||
console.log(`- Falhas : ${failCount}`);
|
||||
console.log('=========================================');
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBackfill().catch(err => {
|
||||
console.error('❌ Erro fatal no script de backfill:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { Upload } = require('@aws-sdk/lib-storage');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Configurações das pastas (resolvendo caminhos corretos)
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads');
|
||||
const PROCESSED_DIR = process.env.PROCESSED_DIR || path.join(__dirname, 'processed');
|
||||
const CONFIG_FILE = path.join(UPLOAD_DIR, 'wasabi_config.json');
|
||||
|
||||
let s3 = null;
|
||||
let currentBucket = null;
|
||||
let currentConfig = {};
|
||||
|
||||
// Função para inicializar/reconfigurar o S3 Client
|
||||
function initS3(cfg = {}) {
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
const region = cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1';
|
||||
const bucket = cfg.wasabiBucket || process.env.WASABI_BUCKET;
|
||||
const rawEndpoint = cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com';
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
|
||||
currentConfig = {
|
||||
wasabiAccessKey: accessKey || '',
|
||||
wasabiSecretKey: secretKey || '',
|
||||
wasabiBucket: bucket || '',
|
||||
wasabiRegion: region || 'us-east-1',
|
||||
wasabiEndpoint: rawEndpoint || 's3.wasabisys.com'
|
||||
};
|
||||
|
||||
if (accessKey && secretKey && bucket) {
|
||||
s3 = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
currentBucket = bucket;
|
||||
console.log(`✅ [Wasabi] Inicializado com sucesso — Bucket: ${bucket}, Endpoint: ${endpoint}`);
|
||||
} else {
|
||||
s3 = null;
|
||||
currentBucket = null;
|
||||
console.log('⚠️ [Wasabi] Chaves WASABI_ACCESS_KEY, WASABI_SECRET_KEY ou WASABI_BUCKET não definidas no JSON/Banco/Ambiente. Wasabi desativado.');
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializa a partir do arquivo JSON (se houver) ou das variáveis de ambiente (como fallback inicial síncrono)
|
||||
function loadConfigSync() {
|
||||
try {
|
||||
if (fsSync.existsSync(CONFIG_FILE)) {
|
||||
const data = fsSync.readFileSync(CONFIG_FILE, 'utf8');
|
||||
const cfg = JSON.parse(data);
|
||||
console.log('📖 [Wasabi] Configuração dinâmica lida de wasabi_config.json');
|
||||
initS3(cfg);
|
||||
} else {
|
||||
initS3({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [Wasabi] Erro ao carregar arquivo de config:', error.message);
|
||||
initS3({});
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializa a partir do Banco de Dados (Chamado de forma assíncrona após db.initDatabase)
|
||||
async function loadConfigFromDb() {
|
||||
try {
|
||||
const db = require('./database');
|
||||
const keys = [
|
||||
'wasabi_access_key',
|
||||
'wasabi_secret_key',
|
||||
'wasabi_bucket',
|
||||
'wasabi_region',
|
||||
'wasabi_endpoint'
|
||||
];
|
||||
|
||||
const cfg = {};
|
||||
let hasSettings = false;
|
||||
|
||||
for (const k of keys) {
|
||||
const row = await db.get('SELECT value FROM settings WHERE key = $1', [k]);
|
||||
if (row) {
|
||||
// Mapear de snake_case do banco para camelCase do config
|
||||
const camelKey = k.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||
cfg[camelKey] = row.value;
|
||||
hasSettings = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSettings) {
|
||||
console.log('📖 [Wasabi] Configuração carregada com sucesso do Banco de Dados.');
|
||||
initS3(cfg);
|
||||
} else {
|
||||
// Se não há settings no banco, tenta carregar do fallback JSON
|
||||
loadConfigSync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [Wasabi] Erro ao carregar do banco, usando fallback:', error.message);
|
||||
loadConfigSync();
|
||||
}
|
||||
}
|
||||
|
||||
// Inicialização imediata de fallback (síncrona)
|
||||
loadConfigSync();
|
||||
|
||||
// Retorna a configuração atual de forma segura (mascarando a senha/secret key)
|
||||
function getSafeConfig() {
|
||||
return {
|
||||
wasabiAccessKey: currentConfig.wasabiAccessKey,
|
||||
wasabiSecretKey: currentConfig.wasabiSecretKey ? '********' : '',
|
||||
wasabiBucket: currentConfig.wasabiBucket,
|
||||
wasabiRegion: currentConfig.wasabiRegion,
|
||||
wasabiEndpoint: currentConfig.wasabiEndpoint,
|
||||
enabled: !!(s3 && currentBucket)
|
||||
};
|
||||
}
|
||||
|
||||
// Salva e atualiza a configuração (no banco de dados e no arquivo de redundância local)
|
||||
async function updateConfig(newConfig) {
|
||||
try {
|
||||
const db = require('./database');
|
||||
|
||||
// Se a secretKey vier como asteriscos, mantém a antiga
|
||||
const finalSecretKey = newConfig.wasabiSecretKey === '********'
|
||||
? currentConfig.wasabiSecretKey
|
||||
: newConfig.wasabiSecretKey;
|
||||
|
||||
const configToSave = {
|
||||
wasabiAccessKey: newConfig.wasabiAccessKey || '',
|
||||
wasabiSecretKey: finalSecretKey || '',
|
||||
wasabiBucket: newConfig.wasabiBucket || '',
|
||||
wasabiRegion: newConfig.wasabiRegion || 'us-east-1',
|
||||
wasabiEndpoint: newConfig.wasabiEndpoint || 's3.wasabisys.com'
|
||||
};
|
||||
|
||||
// 1. Salva no banco de dados (com suporte a ON CONFLICT UPSERT do PostgreSQL e SQLite)
|
||||
const settingsMap = {
|
||||
'wasabi_access_key': configToSave.wasabiAccessKey,
|
||||
'wasabi_secret_key': configToSave.wasabiSecretKey,
|
||||
'wasabi_bucket': configToSave.wasabiBucket,
|
||||
'wasabi_region': configToSave.wasabiRegion,
|
||||
'wasabi_endpoint': configToSave.wasabiEndpoint
|
||||
};
|
||||
|
||||
for (const [key, val] of Object.entries(settingsMap)) {
|
||||
await db.run(
|
||||
`INSERT INTO settings (key, value)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value = EXCLUDED.value`,
|
||||
[key, val]
|
||||
);
|
||||
}
|
||||
console.log('💾 [Wasabi] Configurações persistidas no Banco de Dados.');
|
||||
|
||||
// 2. Salva localmente no arquivo de redundância JSON
|
||||
try {
|
||||
await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(configToSave, null, 2), 'utf8');
|
||||
console.log('💾 [Wasabi] Cópia de segurança salva em wasabi_config.json');
|
||||
} catch (fsErr) {
|
||||
console.warn('⚠️ [Wasabi] Falha ao gravar cópia em arquivo de config:', fsErr.message);
|
||||
}
|
||||
|
||||
initS3(configToSave);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('❌ [Wasabi] Erro ao salvar configurações:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Higieniza nomes para caminhos do S3
|
||||
function sanitizePathSegment(segment) {
|
||||
if (!segment) return 'desconhecido';
|
||||
return segment.trim().replace(/[\/\\?#%*:"<>|]/g, '_');
|
||||
}
|
||||
|
||||
// Salva uma imagem (original ou processada) no disco local e envia para o Wasabi
|
||||
async function saveImage(filename, buffer, clientName, patientName, isProcessed = false) {
|
||||
const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR;
|
||||
const destPath = path.join(targetDir, filename);
|
||||
|
||||
// 1. Salva localmente de forma temporária
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.writeFile(destPath, buffer);
|
||||
console.log(`💾 [Storage] Imagem salva localmente de forma temporária: ${destPath}`);
|
||||
|
||||
// 2. Envia para o Wasabi se ativo
|
||||
if (s3 && currentBucket) {
|
||||
try {
|
||||
const cleanClient = sanitizePathSegment(clientName);
|
||||
const cleanPatient = sanitizePathSegment(patientName);
|
||||
const key = `${cleanClient}/${cleanPatient}/${filename}`;
|
||||
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
let contentType = 'image/png';
|
||||
if (ext === '.jpg' || ext === '.jpeg') {
|
||||
contentType = 'image/jpeg';
|
||||
} else if (ext === '.webp') {
|
||||
contentType = 'image/webp';
|
||||
} else if (ext === '.svg') {
|
||||
contentType = 'image/svg+xml';
|
||||
}
|
||||
|
||||
const upload = new Upload({
|
||||
client: s3,
|
||||
params: {
|
||||
Bucket: currentBucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
console.log(`☁️ [Wasabi] Upload concluído na pasta do cliente/paciente: ${key}`);
|
||||
|
||||
// Se o upload foi bem-sucedido e o Wasabi está ativo, removemos o arquivo local imediatamente
|
||||
try {
|
||||
await fs.unlink(destPath);
|
||||
console.log(`🗑️ [Storage] Arquivo local temporário removido após upload no Wasabi: ${destPath}`);
|
||||
} catch (unlinkErr) {
|
||||
console.warn(`⚠️ [Storage] Falha ao remover arquivo local temporário: ${unlinkErr.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [Wasabi] Falha no upload de ${filename}:`, error.message);
|
||||
// Mantemos o arquivo local se o Wasabi falhar como fallback resiliente
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Obtém o buffer da imagem (lê localmente, e se não existir, tenta baixar do Wasabi)
|
||||
async function getImageBuffer(filename, clientName = null, patientName = null, isProcessed = false) {
|
||||
const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR;
|
||||
const localPath = path.join(targetDir, filename);
|
||||
|
||||
// 1. Tenta ler local primeiro (para compatibilidade caso o Wasabi não estivesse ativo no salvamento inicial)
|
||||
if (fsSync.existsSync(localPath)) {
|
||||
return await fs.readFile(localPath);
|
||||
}
|
||||
|
||||
// 2. Se não existir localmente, tenta obter do Wasabi
|
||||
if (s3 && currentBucket) {
|
||||
try {
|
||||
let cName = clientName;
|
||||
let pName = patientName;
|
||||
|
||||
// Se não foram passados, busca metadados no banco
|
||||
if (!cName || !pName) {
|
||||
try {
|
||||
const db = require('./database');
|
||||
const row = await db.get('SELECT client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', [filename, filename]);
|
||||
if (row) {
|
||||
cName = row.client_name;
|
||||
pName = row.patient_name;
|
||||
}
|
||||
} catch (dbErr) {
|
||||
console.error('⚠️ [Storage] Erro ao buscar metadados do banco:', dbErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanClient = sanitizePathSegment(cName);
|
||||
const cleanPatient = sanitizePathSegment(pName);
|
||||
const key = `${cleanClient}/${cleanPatient}/${filename}`;
|
||||
|
||||
console.log(`☁️ [Wasabi] Baixando do S3: ${key}`);
|
||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const response = await s3.send(new GetObjectCommand({
|
||||
Bucket: currentBucket,
|
||||
Key: key
|
||||
}));
|
||||
|
||||
if (response.Body) {
|
||||
const bytes = await response.Body.transformToByteArray();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// TODO(security): Não salvamos no disco rígido local da VPS em cache para preservar privacidade e espaço.
|
||||
// Apenas retornamos o buffer lido na memória diretamente para o servidor.
|
||||
console.log(`☁️ [Wasabi] Servindo imagem diretamente do S3 sem gravar em cache local`);
|
||||
return buffer;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [Wasabi] Falha ao recuperar do S3 para ${filename}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exclui uma imagem (do disco local e do Wasabi S3)
|
||||
async function deleteImage(filename, clientName, patientName) {
|
||||
// 1. Excluir localmente da pasta de uploads (imagens originais) e processed (imagens rotacionadas)
|
||||
const localPaths = [
|
||||
path.join(UPLOAD_DIR, filename),
|
||||
path.join(PROCESSED_DIR, filename)
|
||||
];
|
||||
|
||||
for (const localPath of localPaths) {
|
||||
try {
|
||||
// Usando fsSync.existsSync para checagem síncrona segura
|
||||
if (fsSync.existsSync(localPath)) {
|
||||
await fs.unlink(localPath);
|
||||
console.log(`🗑️ [Storage] Arquivo local excluído: ${localPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ [Storage] Falha ao excluir arquivo local ${localPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Excluir do Wasabi S3 se ativo
|
||||
if (s3 && currentBucket) {
|
||||
try {
|
||||
const cleanClient = sanitizePathSegment(clientName);
|
||||
const cleanPatient = sanitizePathSegment(patientName);
|
||||
const key = `${cleanClient}/${cleanPatient}/${filename}`;
|
||||
|
||||
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
await s3.send(new DeleteObjectCommand({
|
||||
Bucket: currentBucket,
|
||||
Key: key
|
||||
}));
|
||||
console.log(`🗑️ [Wasabi] Objeto excluído do S3: ${key}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ [Wasabi] Falha ao excluir objeto ${filename} do S3:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveImage,
|
||||
getImageBuffer,
|
||||
deleteImage,
|
||||
getSafeConfig,
|
||||
updateConfig,
|
||||
loadConfigFromDb,
|
||||
isWasabiEnabled: () => !!(s3 && currentBucket)
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
const db = require('./database');
|
||||
db.initDatabase().then(async () => {
|
||||
const row = await db.get('SELECT id, filename, thumb_filename, client_name, patient_name FROM images WHERE id = 533');
|
||||
console.log('Result:', row);
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user