From 6cc0bfd1d79074df790272b8091b1f0226d14283 Mon Sep 17 00:00:00 2001 From: zwlucas Date: Fri, 29 May 2026 15:57:37 -0300 Subject: feat: upload project Signed-off-by: zwlucas --- backend/internal/api/auth.go | 144 ++++++++++++++++++++++++ backend/internal/api/handler.go | 243 ++++++++++++++++++++++++++++++++++++++++ backend/internal/api/router.go | 71 ++++++++++++ backend/internal/api/sse.go | 57 ++++++++++ 4 files changed, 515 insertions(+) create mode 100644 backend/internal/api/auth.go create mode 100644 backend/internal/api/handler.go create mode 100644 backend/internal/api/router.go create mode 100644 backend/internal/api/sse.go (limited to 'backend/internal/api') diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..5095194 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,144 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "log" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type AuthHandler struct { + username string + passwordHash string + jwtSecret []byte +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +func NewAuthHandler(username, passwordHash, jwtSecret string) *AuthHandler { + if username == "" { + username = "admin" + } + secret := []byte(jwtSecret) + if len(secret) == 0 { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + secret = []byte("dev-secret-do-not-use-in-production") + } else { + secret = []byte(base64.RawURLEncoding.EncodeToString(key)) + } + log.Println("[auth] jwt_secret nao configurado — usando chave gerada aleatoriamente") + } + if passwordHash == "" { + log.Fatal("[auth] ADMIN_PASSWORD_HASH ou ADMIN_PASSWORD deve ser definido") + } + return &AuthHandler{ + username: username, + passwordHash: passwordHash, + jwtSecret: secret, + } +} + +func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido"}) + return + } + if req.Username != a.username { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "credenciais invalidas"}) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(a.passwordHash), []byte(req.Password)); err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "credenciais invalidas"}) + return + } + + now := time.Now() + claims := Claims{ + Username: req.Username, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), + Subject: req.Username, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(a.jwtSecret) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "erro ao gerar token"}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"token": signed}) +} + +func (a *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) { + tokenStr := extractToken(r) + if tokenStr == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token ausente"}) + return + } + if !a.validateToken(tokenStr) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token invalido ou expirado"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (a *AuthHandler) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/auth/") { + next.ServeHTTP(w, r) + return + } + if r.Method == http.MethodGet || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + tokenStr := extractToken(r) + if tokenStr == "" || !a.validateToken(tokenStr) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "nao autorizado"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *AuthHandler) validateToken(tokenStr string) bool { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) { + return a.jwtSecret, nil + }) + if err != nil || !token.Valid { + return false + } + return claims.Username == a.username +} + +func extractToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + cookie, err := r.Cookie("auth_token") + if err == nil && cookie.Value != "" { + return cookie.Value + } + return "" +} diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go new file mode 100644 index 0000000..f304351 --- /dev/null +++ b/backend/internal/api/handler.go @@ -0,0 +1,243 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "yaum/internal/monitor" + "yaum/internal/model" + "yaum/internal/store" +) + +type ServiceResponse struct { + model.Service + LastHeartbeat *model.Heartbeat `json:"last_heartbeat,omitempty"` +} + +type Handler struct { + store store.Store + timeout int +} + +func NewHandler(s store.Store, timeoutSec int) *Handler { + return &Handler{store: s, timeout: timeoutSec} +} + +func (h *Handler) ListServices(w http.ResponseWriter, r *http.Request) { + services, err := h.store.ListServices() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + resp := make([]ServiceResponse, len(services)) + for i, svc := range services { + resp[i] = ServiceResponse{ + Service: svc.Service, + LastHeartbeat: svc.LastHeartbeat, + } + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) { + var svc model.Service + if err := json.NewDecoder(r.Body).Decode(&svc); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()}) + return + } + if svc.Name == "" || svc.URL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name e url sao obrigatorios"}) + return + } + created, err := h.store.AddService(svc) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (h *Handler) ToggleService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + svc, err := h.store.ToggleService(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, svc) +} + +func (h *Handler) TestService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + + svc, err := h.store.GetService(id) + if err != nil || svc == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "servico nao encontrado"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(h.timeout)*time.Second) + defer cancel() + + hb := monitor.CheckHTTP(ctx, svc.URL, svc.KeywordToFind) + hb.ServiceID = svc.ID + + if saverr := h.store.AddHeartbeat(hb); saverr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": saverr.Error()}) + return + } + + writeJSON(w, http.StatusOK, hb) +} + +func (h *Handler) UpdateService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + + var svc model.Service + if err := json.NewDecoder(r.Body).Decode(&svc); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()}) + return + } + + updated, err := h.store.UpdateService(id, svc) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (h *Handler) DeleteService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + if err := h.store.DeleteService(id); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) GetHistory(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + history, err := h.store.GetHistory(id, 50) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, history) +} + +func (h *Handler) GetActiveMaintenance(w http.ResponseWriter, r *http.Request) { + m, err := h.store.GetActiveMaintenance(time.Now()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if m == nil { + writeJSON(w, http.StatusOK, map[string]any{"maintenance": nil}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"maintenance": m}) +} + +func (h *Handler) BadgeSVG(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + w.Header().Set("Content-Type", "image/svg+xml") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(badgeSVG("ERRO", "#888"))) + return + } + + svc, err := h.store.GetService(id) + if err != nil || svc == nil { + w.Header().Set("Content-Type", "image/svg+xml") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(badgeSVG("N/A", "#888"))) + return + } + + stats, _ := h.store.GetServiceStats(id) + lastHb, _ := h.store.GetLastHeartbeat(id) + + var label, color string + if lastHb == nil { + label = "AGUARDANDO" + color = "#888" + } else if lastHb.IsUp { + if stats != nil && stats.Uptime30d > 0 { + label = "UP " + fmt.Sprintf("%.1f", stats.Uptime30d) + "%" + } else { + label = "UP" + } + color = "#22f06a" + } else { + label = "DOWN" + color = "#ff4060" + } + + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(badgeSVG(label, color))) +} + +func badgeSVG(label string, color string) string { + textWidth := len(label) * 8 + if textWidth < 40 { + textWidth = 40 + } + totalWidth := 20 + textWidth + 8 + + return fmt.Sprintf( + ` + + + %s +`, + totalWidth, totalWidth, totalWidth-textWidth-8, textWidth+8, color, + 10+(textWidth/2), label, + ) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + stats, err := h.store.GetServiceStats(id) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, stats) +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go new file mode 100644 index 0000000..9a18cc5 --- /dev/null +++ b/backend/internal/api/router.go @@ -0,0 +1,71 @@ +package api + +import ( + "net/http" + "strings" +) + +func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("POST /api/auth/login", auth.Login) + mux.HandleFunc("GET /api/auth/verify", auth.Verify) + + mux.HandleFunc("GET /api/services", h.ListServices) + mux.HandleFunc("POST /api/services", h.CreateService) + mux.HandleFunc("PUT /api/services/{id}", h.UpdateService) + mux.HandleFunc("PATCH /api/services/{id}/toggle", h.ToggleService) + mux.HandleFunc("POST /api/services/{id}/test", h.TestService) + mux.HandleFunc("DELETE /api/services/{id}", h.DeleteService) + mux.HandleFunc("GET /api/services/{id}/history", h.GetHistory) + mux.HandleFunc("GET /api/services/{id}/stats", h.GetStats) + mux.HandleFunc("GET /api/services/{id}/badge.svg", h.BadgeSVG) + + mux.HandleFunc("GET /api/active-maintenance", h.GetActiveMaintenance) + + mux.Handle("GET /api/stream", stream) + + return corsMiddleware(auth.Middleware(mux)) +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin != "" && isAllowedOrigin(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +func isAllowedOrigin(origin string) bool { + allowed := []string{ + "http://localhost:5173", + "http://localhost:4173", + "http://127.0.0.1:5173", + "http://127.0.0.1:4173", + } + for _, a := range allowed { + if strings.EqualFold(origin, a) { + return true + } + } + if strings.HasPrefix(origin, "http://localhost:") { + return true + } + return false +} diff --git a/backend/internal/api/sse.go b/backend/internal/api/sse.go new file mode 100644 index 0000000..7c2332c --- /dev/null +++ b/backend/internal/api/sse.go @@ -0,0 +1,57 @@ +package api + +import ( + "fmt" + "log" + "net/http" + + "yaum/internal/sse" +) + +type StreamHandler struct { + broadcaster *sse.Broadcaster +} + +func NewStreamHandler(b *sse.Broadcaster) *StreamHandler { + return &StreamHandler{broadcaster: b} +} + +func (sh *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ch := sh.broadcaster.Subscribe() + defer sh.broadcaster.Unsubscribe(ch) + + _, err := fmt.Fprintf(w, "event: connected\ndata: {}\n\n") + if err != nil { + return + } + flusher.Flush() + + ctx := r.Context() + for { + select { + case data, ok := <-ch: + if !ok { + return + } + _, err := fmt.Fprintf(w, "event: heartbeat\ndata: %s\n\n", data) + if err != nil { + log.Printf("[sse] cliente desconectado: %v", err) + return + } + flusher.Flush() + case <-ctx.Done(): + return + } + } +} -- cgit v1.2.3