// 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 secure bool // força Secure no cookie mesmo atrás de proxy sem X-Forwarded-Proto webFS fs.FS md goldmark.Markdown hub *hub pres *presenceReg sess *sessionStore login *loginLimiter } // Options parametriza o servidor além do token. type Options struct { // SecureCookie força o atributo Secure no cookie de sessão. Use quando // o acesso é sempre por TLS atrás de proxy reverso — não se confia em // X-Forwarded-Proto do cliente para decidir isso. SecureCookie bool } // 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, opts ...Options) *Server { s := &Server{ st: st, token: token, webFS: webFS, md: goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(htmlr.WithHardWraps()), ), hub: newHub(), pres: newPresenceReg(), sess: newSessionStore(cookieMaxAge), // 5 tentativas erradas por IP em 15 minutos; acerto reseta login: newLoginLimiter(5, 15*time.Minute), } if len(opts) > 0 { s.secure = opts[0].SecureCookie } return s } // 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 /api/events", s.auth(s.handleEvents)) mux.HandleFunc("POST /api/presence", s.auth(s.handlePresence)) // gerenciador de arquivos / anexos mux.HandleFunc("POST /api/files/upload", s.auth(s.handleFileUpload)) mux.HandleFunc("GET /api/files/tree", s.auth(s.handleFileTree)) mux.HandleFunc("GET /api/files/static/{id}", s.auth(s.handleFileStatic)) mux.HandleFunc("DELETE /api/files/{id}", s.auth(s.handleFileDelete)) mux.HandleFunc("PUT /api/files/{id}/move", s.auth(s.handleFileMove)) mux.HandleFunc("POST /api/folders", s.auth(s.handleFolderCreate)) mux.HandleFunc("PUT /api/folders/{id}", s.auth(s.handleFolderUpdate)) mux.HandleFunc("DELETE /api/folders/{id}", s.auth(s.handleFolderDelete)) mux.HandleFunc("GET /{$}", s.serveIndex) mux.Handle("GET /", s.staticHandler()) return s.wrap(mux) } // staticHandler serve o FS embutido com os cabeçalhos que PWA exige: // sw.js nunca pode ser cacheado pelo browser (a detecção de nova versão é por // bytes) e o manifest ganha o MIME application/manifest+json. func (s *Server) staticHandler() http.Handler { files := http.FileServerFS(s.webFS) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/sw.js": w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Content-Type", "text/javascript; charset=utf-8") files.ServeHTTP(w, r) return case "/manifest.webmanifest": w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8") } files.ServeHTTP(w, r) }) } // 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 // --------------------------------------------------------------------------- // credOK valida o credencial da requisição por canal separado: // - "Authorization: Bearer " → token mestre (scripts/CLI), constant-time; // - cookie notas_token → id de sessão opaco revogável; o token mestre NUNCA // vale como cookie (o cookie pode vazar por XSS de subdomínio/logs). func (s *Server) credOK(r *http.Request) bool { if s.token == "" { return false } if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(v)), []byte(s.token)) == 1 } if c, err := r.Cookie(CookieName); err == nil { return s.sess.valid(c.Value) } return false } // auth valida o credencial por canal (Bearer=token mestre, cookie=sessão). func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.credOK(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) { ip := remoteIP(r) if !s.login.allow(ip) { writeError(w, http.StatusTooManyRequests, "muitas tentativas; tente novamente mais tarde") return } var req loginRequest if err := decodeJSON(w, r, &req, 1<<16); err != nil { return } if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(req.Token)), []byte(s.token)) != 1 { s.login.fail(ip) slog.Warn("login falhou", "ip", ip) writeError(w, http.StatusUnauthorized, "token inválido") return } s.login.success(ip) sid, exp := s.sess.new() secure := s.secure || r.TLS != nil http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: sid, Path: "/", MaxAge: int(cookieMaxAge.Seconds()), HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]any{"authed": true, "expires_at": exp.UTC().Format(time.RFC3339)}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(CookieName); err == nil { s.sess.revoke(c.Value) } 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.credOK(r) { writeError(w, http.StatusUnauthorized, "não autenticado") return } writeJSON(w, http.StatusOK, map[string]any{"authed": true}) } // --------------------------------------------------------------------------- // JSON helpers // --------------------------------------------------------------------------- // decodeJSON lê e valida o corpo JSON com limite. Corpos acima do limite viram // 413 (http.MaxBytesError); JSON inválido vira 400. Em erro a resposta já foi // escrita — o chamador deve apenas retornar. func decodeJSON(w http.ResponseWriter, r *http.Request, v any, maxBytes int64) error { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBytes)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { writeError(w, http.StatusRequestEntityTooLarge, "corpo da requisição excede o limite") return err } writeError(w, http.StatusBadRequest, "corpo JSON inválido") 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. resource identifica // o alvo ("nota", "revisão", "arquivo", "pasta", "categoria") para mensagens // 404 precisas em vez do genérico "nota não encontrada". func mapStoreError(w http.ResponseWriter, err error) { mapStoreErrorFor(w, err, "nota") } func mapStoreErrorFor(w http.ResponseWriter, err error, resource string) { notFound := map[string]string{ "nota": "nota não encontrada", "arquivo": "arquivo não encontrado", "pasta": "pasta não encontrada", "categoria": "categoria não encontrada", }[resource] if notFound == "" { notFound = "recurso não encontrado" } switch { case errors.Is(err, store.ErrNotFound): writeError(w, http.StatusNotFound, notFound) 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): if resource == "arquivo" { writeError(w, http.StatusRequestEntityTooLarge, "arquivo excede 64 MiB ou a quota total de anexos excede 2 GiB") } else { 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") } }