Prepara versao dev 1.0

This commit is contained in:
Erik Silva
2025-12-08 21:47:38 -03:00
parent 512287698e
commit 190fde20c3
85 changed files with 7755 additions and 2317 deletions

View File

@@ -1,10 +0,0 @@
module server
go 1.23.12
require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.27.0
)

View File

@@ -1,8 +0,0 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=

View File

@@ -2,576 +2,127 @@ package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
"aggios-app/backend/internal/api/handlers"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
)
var db *sql.DB
// jwtSecret carrega o secret do ambiente ou usa fallback (NUNCA use fallback em produção)
var jwtSecret = []byte(getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"))
// Rate limiting simples (IP -> timestamp das últimas tentativas)
var loginAttempts = make(map[string][]time.Time)
var registerAttempts = make(map[string][]time.Time)
const maxAttemptsPerMinute = 5
// corsMiddleware adiciona headers CORS
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// CORS - apenas domínios permitidos
allowedOrigins := map[string]bool{
"http://localhost": true, // Dev local
"http://dash.localhost": true, // Dashboard dev
"http://aggios.local": true, // Institucional dev
"http://dash.aggios.local": true, // Dashboard dev alternativo
"https://aggios.app": true, // Institucional prod
"https://dash.aggios.app": true, // Dashboard prod
"https://www.aggios.app": true, // Institucional prod www
}
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
// Headers de segurança
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Handle preflight
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Log da requisição (sem dados sensíveis)
log.Printf("📥 %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
next(w, r)
}
}
// RegisterRequest representa os dados completos de registro
type RegisterRequest struct {
// Step 1 - Dados Pessoais
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
Newsletter bool `json:"newsletter"`
// Step 2 - Empresa
CompanyName string `json:"companyName"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
// Step 3 - Localização
CEP string `json:"cep"`
State string `json:"state"`
City string `json:"city"`
Neighborhood string `json:"neighborhood"`
Street string `json:"street"`
Number string `json:"number"`
Complement string `json:"complement"`
Contacts []struct {
ID int `json:"id"`
WhatsApp string `json:"whatsapp"`
} `json:"contacts"`
// Step 4 - Domínio
Subdomain string `json:"subdomain"`
// Step 5 - Personalização
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
}
// RegisterResponse representa a resposta do registro
type RegisterResponse struct {
Token string `json:"token"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
TenantID string `json:"tenantId"`
Company string `json:"company"`
Subdomain string `json:"subdomain"`
CreatedAt string `json:"created_at"`
}
// ErrorResponse representa uma resposta de erro
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// LoginRequest representa os dados de login
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse representa a resposta do login
type LoginResponse struct {
Token string `json:"token"`
User UserPayload `json:"user"`
}
// UserPayload representa os dados do usuário no token
type UserPayload struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
TenantID string `json:"tenantId"`
Company string `json:"company"`
Subdomain string `json:"subdomain"`
}
// Claims customizado para JWT
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
TenantID string `json:"tenantId"`
jwt.RegisteredClaims
}
// getEnvOrDefault retorna variável de ambiente ou valor padrão
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// checkRateLimit verifica se IP excedeu limite de tentativas
func checkRateLimit(ip string, attempts map[string][]time.Time) bool {
now := time.Now()
cutoff := now.Add(-1 * time.Minute)
// Limpar tentativas antigas
if timestamps, exists := attempts[ip]; exists {
var recent []time.Time
for _, t := range timestamps {
if t.After(cutoff) {
recent = append(recent, t)
}
}
attempts[ip] = recent
// Verificar se excedeu limite
if len(recent) >= maxAttemptsPerMinute {
return false
}
}
// Adicionar nova tentativa
attempts[ip] = append(attempts[ip], now)
return true
}
// validateEmail valida formato de email
func validateEmail(email string) bool {
if len(email) < 3 || len(email) > 254 {
return false
}
// Regex simples para validação
return strings.Contains(email, "@") && strings.Contains(email, ".")
}
func initDB() error {
func initDB(cfg *config.Config) (*sql.DB, error) {
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.User,
cfg.Database.Password,
cfg.Database.Name,
)
var err error
db, err = sql.Open("postgres", connStr)
db, err := sql.Open("postgres", connStr)
if err != nil {
return fmt.Errorf("erro ao abrir conexão: %v", err)
return nil, fmt.Errorf("erro ao abrir conexão: %v", err)
}
if err = db.Ping(); err != nil {
return fmt.Errorf("erro ao conectar ao banco: %v", err)
return nil, fmt.Errorf("erro ao conectar ao banco: %v", err)
}
log.Println("✅ Conectado ao PostgreSQL")
return nil
return db, nil
}
func main() {
// Inicializar banco de dados
if err := initDB(); err != nil {
// Load configuration
cfg := config.Load()
// Initialize database
db, err := initDB(cfg)
if err != nil {
log.Fatalf("❌ Erro ao inicializar banco: %v", err)
}
defer db.Close()
// Health check handlers
http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"healthy","version":"1.0.0","database":"pending","redis":"pending","minio":"pending"}`)
})
// Initialize repositories
userRepo := repository.NewUserRepository(db)
tenantRepo := repository.NewTenantRepository(db)
companyRepo := repository.NewCompanyRepository(db)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"ok"}`)
})
// Initialize services
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg)
tenantService := service.NewTenantService(tenantRepo)
companyService := service.NewCompanyService(companyRepo)
// Auth routes (com CORS)
http.HandleFunc("/api/auth/register", corsMiddleware(handleRegister))
http.HandleFunc("/api/auth/login", corsMiddleware(handleLogin))
http.HandleFunc("/api/me", corsMiddleware(authMiddleware(handleMe)))
// Initialize handlers
healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService)
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo)
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
tenantHandler := handlers.NewTenantHandler(tenantService)
companyHandler := handlers.NewCompanyHandler(companyService)
port := os.Getenv("SERVER_PORT")
if port == "" {
port = "8080"
}
// Create middleware chain
tenantDetector := middleware.TenantDetector(tenantRepo)
corsMiddleware := middleware.CORS(cfg)
securityMiddleware := middleware.SecurityHeaders
rateLimitMiddleware := middleware.RateLimit(cfg)
authMiddleware := middleware.Auth(cfg)
addr := fmt.Sprintf(":%s", port)
// Setup routes
mux := http.NewServeMux()
// Health check (no auth)
mux.HandleFunc("/health", healthHandler.Check)
mux.HandleFunc("/api/health", healthHandler.Check)
// Auth routes (public with rate limiting)
mux.HandleFunc("/api/auth/login", authHandler.Login)
// Protected auth routes
mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword)))
// Agency management (SUPERADMIN only)
mux.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency)
mux.HandleFunc("/api/admin/agencies", tenantHandler.ListAll)
mux.HandleFunc("/api/admin/agencies/", agencyHandler.HandleAgency)
// Client registration (ADMIN_AGENCIA only - requires auth)
mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient)))
// Agency profile routes (protected)
mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
agencyProfileHandler.GetProfile(w, r)
} else if r.Method == http.MethodPut || r.Method == http.MethodPatch {
agencyProfileHandler.UpdateProfile(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
// Protected routes (require authentication)
mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List)))
mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create)))
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux))))
// Start server
addr := fmt.Sprintf(":%s", cfg.Server.Port)
log.Printf("🚀 Server starting on %s", addr)
log.Printf("📍 Health check: http://localhost:%s/health", port)
log.Printf("🔗 API: http://localhost:%s/api/health", port)
log.Printf("👤 Register: http://localhost:%s/api/auth/register", port)
log.Printf("🔐 Login: http://localhost:%s/api/auth/login", port)
log.Printf("👤 Me: http://localhost:%s/api/me", port)
log.Printf("📍 Health check: http://localhost:%s/health", cfg.Server.Port)
log.Printf("🔗 API: http://localhost:%s/api/health", cfg.Server.Port)
log.Printf("🏢 Register Agency (SUPERADMIN): http://localhost:%s/api/admin/agencies/register", cfg.Server.Port)
log.Printf("🔐 Login: http://localhost:%s/api/auth/login", cfg.Server.Port)
if err := http.ListenAndServe(addr, nil); err != nil {
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("❌ Server error: %v", err)
}
}
// handleRegister handler para criar novo usuário
func handleRegister(w http.ResponseWriter, r *http.Request) {
// Apenas POST
if r.Method != http.MethodPost {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting
ip := strings.Split(r.RemoteAddr, ":")[0]
if !checkRateLimit(ip, registerAttempts) {
sendError(w, "Too many registration attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Parse JSON
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validações básicas
if !validateEmail(req.Email) {
sendError(w, "Invalid email format", http.StatusBadRequest)
return
}
if req.Password == "" {
sendError(w, "Password is required", http.StatusBadRequest)
return
}
if len(req.Password) < 8 {
sendError(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
if req.FullName == "" {
sendError(w, "Full name is required", http.StatusBadRequest)
return
}
if req.CompanyName == "" {
sendError(w, "Company name is required", http.StatusBadRequest)
return
}
if req.Subdomain == "" {
sendError(w, "Subdomain is required", http.StatusBadRequest)
return
}
// Verificar se email já existe
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", req.Email).Scan(&exists)
if err != nil {
sendError(w, "Database error", http.StatusInternalServerError)
log.Printf("Erro ao verificar email: %v", err)
return
}
if exists {
sendError(w, "Email already registered", http.StatusConflict)
return
}
// Hash da senha com bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
sendError(w, "Error processing password", http.StatusInternalServerError)
log.Printf("Erro ao hash senha: %v", err)
return
}
// Criar Tenant (empresa)
tenantID := uuid.New().String()
domain := fmt.Sprintf("%s.aggios.app", req.Subdomain)
createdAt := time.Now()
_, err = db.Exec(
"INSERT INTO tenants (id, name, domain, subdomain, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
tenantID, req.CompanyName, domain, req.Subdomain, true, createdAt, createdAt,
)
if err != nil {
sendError(w, "Error creating company", http.StatusInternalServerError)
log.Printf("Erro ao criar tenant: %v", err)
return
}
log.Printf("✅ Tenant criado: %s (%s)", req.CompanyName, tenantID)
// Criar Usuário (administrador do tenant)
userID := uuid.New().String()
firstName := req.FullName
lastName := ""
_, err = db.Exec(
"INSERT INTO users (id, tenant_id, email, password_hash, first_name, last_name, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
userID, tenantID, req.Email, string(hashedPassword), firstName, lastName, true, createdAt, createdAt,
)
if err != nil {
sendError(w, "Error creating user", http.StatusInternalServerError)
log.Printf("Erro ao inserir usuário: %v", err)
return
}
log.Printf("✅ Usuário criado: %s (%s)", req.Email, userID)
// Gerar token JWT para login automático
token, err := generateToken(userID, req.Email, tenantID)
if err != nil {
sendError(w, "Error generating token", http.StatusInternalServerError)
log.Printf("Erro ao gerar token: %v", err)
return
}
response := RegisterResponse{
Token: token,
ID: userID,
Email: req.Email,
Name: req.FullName,
TenantID: tenantID,
Company: req.CompanyName,
Subdomain: req.Subdomain,
CreatedAt: createdAt.Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// sendError envia uma resposta de erro padronizada
func sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{
Error: http.StatusText(statusCode),
Message: message,
})
}
// generateToken gera um JWT token para o usuário
func generateToken(userID, email, tenantID string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
TenantID: tenantID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "aggios-api",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// authMiddleware verifica o token JWT
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
sendError(w, "Authorization header required", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
sendError(w, "Invalid authorization format", http.StatusUnauthorized)
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
sendError(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Adicionar claims ao contexto (simplificado: usar headers)
r.Header.Set("X-User-ID", claims.UserID)
r.Header.Set("X-User-Email", claims.Email)
r.Header.Set("X-Tenant-ID", claims.TenantID)
next(w, r)
}
}
// handleLogin handler para fazer login
func handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting
ip := strings.Split(r.RemoteAddr, ":")[0]
if !checkRateLimit(ip, loginAttempts) {
sendError(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if !validateEmail(req.Email) || req.Password == "" {
sendError(w, "Invalid credentials", http.StatusBadRequest)
return
}
// Buscar usuário no banco
var userID, email, passwordHash, firstName, tenantID string
var tenantName, subdomain string
err := db.QueryRow(`
SELECT u.id, u.email, u.password_hash, u.first_name, u.tenant_id, t.name, t.subdomain
FROM users u
INNER JOIN tenants t ON u.tenant_id = t.id
WHERE u.email = $1 AND u.is_active = true
`, req.Email).Scan(&userID, &email, &passwordHash, &firstName, &tenantID, &tenantName, &subdomain)
if err == sql.ErrNoRows {
sendError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
if err != nil {
sendError(w, "Database error", http.StatusInternalServerError)
log.Printf("Erro ao buscar usuário: %v", err)
return
}
// Verificar senha
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
sendError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Gerar token JWT
token, err := generateToken(userID, email, tenantID)
if err != nil {
sendError(w, "Error generating token", http.StatusInternalServerError)
log.Printf("Erro ao gerar token: %v", err)
return
}
log.Printf("✅ Login bem-sucedido: %s", email)
response := LoginResponse{
Token: token,
User: UserPayload{
ID: userID,
Email: email,
Name: firstName,
TenantID: tenantID,
Company: tenantName,
Subdomain: subdomain,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleMe retorna dados do usuário autenticado
func handleMe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("X-User-ID")
tenantID := r.Header.Get("X-Tenant-ID")
var email, firstName, lastName string
var tenantName, subdomain string
err := db.QueryRow(`
SELECT u.email, u.first_name, u.last_name, t.name, t.subdomain
FROM users u
INNER JOIN tenants t ON u.tenant_id = t.id
WHERE u.id = $1 AND u.tenant_id = $2
`, userID, tenantID).Scan(&email, &firstName, &lastName, &tenantName, &subdomain)
if err != nil {
sendError(w, "User not found", http.StatusNotFound)
log.Printf("Erro ao buscar usuário: %v", err)
return
}
fullName := firstName
if lastName != "" {
fullName += " " + lastName
}
response := UserPayload{
ID: userID,
Email: email,
Name: fullName,
TenantID: tenantID,
Company: tenantName,
Subdomain: subdomain,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}