// Package api expõe a camada HTTP da aplicação de notas. package api import ( "crypto/subtle" "encoding/json" "errors" "io/fs" "log/slog" "net/http" "strings" "time" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" htmlr "github.com/yuin/goldmark/renderer/html" "github.com/lgcosta/notas-online/internal/store" ) // CookieName é o cookie HttpOnly onde o token de sessão é guardado. const CookieName = "notas_token" const cookieMaxAge = 30 * 24 * time.Hour // Server agrupa dependências e rotas. type Server struct { st *store.Store token string webFS fs.FS md goldmark.Markdown } // New cria o servidor da API. webFS é o filesystem embutido do frontend // (raiz contém index.html, css/, js/). func New(st *store.Store, token string, webFS fs.FS) *Server { return &Server{ st: st, token: token, webFS: webFS, md: goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(htmlr.WithHardWraps()), ), } } // Handler monta o mux completo (rotas públicas, autenticadas e estáticas). func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /api/login", s.handleLogin) mux.HandleFunc("POST /api/logout", s.handleLogout) mux.HandleFunc("GET /api/me", s.handleMe) mux.HandleFunc("GET /api/notes", s.auth(s.handleListNotes)) mux.HandleFunc("POST /api/notes", s.auth(s.handleCreateNote)) mux.HandleFunc("GET /api/notes/{id}", s.auth(s.handleGetNote)) mux.HandleFunc("PUT /api/notes/{id}", s.auth(s.handleUpdateNote)) mux.HandleFunc("DELETE /api/notes/{id}", s.auth(s.handleDeleteNote)) mux.HandleFunc("GET /api/notes/{id}/raw", s.auth(s.handleRaw)) mux.HandleFunc("GET /api/notes/{id}/revisions", s.auth(s.handleListRevisions)) mux.HandleFunc("GET /api/notes/{id}/revisions/{hash}", s.auth(s.handleGetRevision)) mux.HandleFunc("GET /api/notes/{id}/revisions/{hash}/diff", s.auth(s.handleRevisionDiff)) mux.HandleFunc("POST /api/notes/{id}/revisions/{hash}/restore", s.auth(s.handleRestore)) mux.HandleFunc("GET /api/tags", s.auth(s.handleTags)) mux.HandleFunc("GET /api/categories", s.auth(s.handleCategories)) mux.HandleFunc("POST /api/categories", s.auth(s.handleCreateCategory)) mux.HandleFunc("PUT /api/categories/{id}", s.auth(s.handleUpdateCategory)) mux.HandleFunc("DELETE /api/categories/{id}", s.auth(s.handleDeleteCategory)) mux.HandleFunc("POST /api/render", s.auth(s.handleRender)) mux.HandleFunc("GET /{$}", s.serveIndex) mux.Handle("GET /", http.FileServerFS(s.webFS)) return s.wrap(mux) } // wrap aplica middlewares globais: recuperação, headers de segurança e log. func (s *Server) wrap(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { slog.Error("panic atendendo requisição", "path", r.URL.Path, "panic", rec) http.Error(w, `{"error":"erro interno"}`, http.StatusInternalServerError) } }() h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; "+ "script-src 'self'; frame-src 'self'; connect-src 'self'") next.ServeHTTP(w, r) }) } // --------------------------------------------------------------------------- // Autenticação // --------------------------------------------------------------------------- func (s *Server) presentedToken(r *http.Request) string { if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { return strings.TrimSpace(v) } if c, err := r.Cookie(CookieName); err == nil { return c.Value } return "" } func (s *Server) validToken(candidate string) bool { if s.token == "" || candidate == "" { return false } return subtle.ConstantTimeCompare([]byte(candidate), []byte(s.token)) == 1 } func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.validToken(s.presentedToken(r)) { writeError(w, http.StatusUnauthorized, "token inválido ou ausente") return } h(w, r) } } type loginRequest struct { Token string `json:"token"` } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := decodeJSON(r, &req, 1<<16); err != nil { writeError(w, http.StatusBadRequest, "corpo JSON inválido") return } if !s.validToken(strings.TrimSpace(req.Token)) { writeError(w, http.StatusUnauthorized, "token inválido") return } secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: strings.TrimSpace(req.Token), Path: "/", MaxAge: int(cookieMaxAge.Seconds()), HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]any{"authed": true}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) writeJSON(w, http.StatusOK, map[string]any{"authed": false}) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { if !s.validToken(s.presentedToken(r)) { writeError(w, http.StatusUnauthorized, "não autenticado") return } writeJSON(w, http.StatusOK, map[string]any{"authed": true}) } // --------------------------------------------------------------------------- // JSON helpers // --------------------------------------------------------------------------- func decodeJSON(r *http.Request, v any, maxBytes int64) error { defer r.Body.Close() dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxBytes)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { return err } return nil } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) } // mapStoreError converte erros da store para códigos HTTP. func mapStoreError(w http.ResponseWriter, err error) { switch { case errors.Is(err, store.ErrNotFound): writeError(w, http.StatusNotFound, "nota não encontrada") case errors.Is(err, store.ErrRevision): writeError(w, http.StatusNotFound, "revisão não encontrada") case errors.Is(err, store.ErrInvalid): writeError(w, http.StatusBadRequest, err.Error()) case errors.Is(err, store.ErrTooLarge): writeError(w, http.StatusRequestEntityTooLarge, "conteúdo excede 2 MiB") case errors.Is(err, store.ErrUnchanged): writeError(w, http.StatusConflict, "o conteúdo dessa revisão já está ativo") default: slog.Error("erro interno da store", "err", err) writeError(w, http.StatusInternalServerError, "erro interno") } }