aboutsummaryrefslogtreecommitdiff
path: root/backend/internal/api
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
commit6cc0bfd1d79074df790272b8091b1f0226d14283 (patch)
treecab18b0f708cf3b0eaeeeb98e2cf81459365b33e /backend/internal/api
downloadyaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz
yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'backend/internal/api')
-rw-r--r--backend/internal/api/auth.go144
-rw-r--r--backend/internal/api/handler.go243
-rw-r--r--backend/internal/api/router.go71
-rw-r--r--backend/internal/api/sse.go57
4 files changed, 515 insertions, 0 deletions
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(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
+ <rect width="%d" height="20" rx="3" fill="#333"/>
+ <rect x="%d" width="%d" height="20" rx="3" fill="%s"/>
+ <text x="%d" y="14" font-family="monospace, sans-serif" font-size="11" font-weight="bold" fill="#fff" text-anchor="middle">%s</text>
+</svg>`,
+ 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
+ }
+ }
+}