package api import ( "errors" "io/fs" "net/http" "github.com/lgcosta/notas-online/internal/store" ) // serveIndex entrega o shell da aplicação. func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) { data, err := fs.ReadFile(s.webFS, "index.html") if err != nil { http.Error(w, "frontend ausente no binário", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(data) } // noteInput é o corpo aceito por POST/PUT de notas. type noteInput struct { Title string `json:"title"` Format string `json:"format"` Categories []string `json:"categories"` // IDs da árvore de categorias Content string `json:"content"` Author string `json:"author"` Message string `json:"message"` BaseHash string `json:"base_hash"` // hash em que a edição se baseia (opcional; evita sobrescrever mudanças remotas) Session string `json:"session"` // id da sessão do cliente (presença/eventos; opcional) } func (in noteInput) fields() (string, string, []string, string, string, string, string) { f := in.Format if f == "" { f = store.FormatTxt } return in.Title, f, in.Categories, in.Content, in.Author, in.Message, in.BaseHash } // noteView é a representação completa (meta + conteúdo) de uma nota. type noteView struct { *store.Note Content string `json:"content"` } // normalizeNote garante que slices nil viram [] no JSON (null quebra // clientes com tipos não-nuláveis, ex. kotlinx.serialization). func normalizeNote(n *store.Note) { if n.Categories == nil { n.Categories = []string{} } if n.Tags == nil { n.Tags = []string{} } } func newNoteView(n *store.Note, content string) noteView { normalizeNote(n) return noteView{n, content} } func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() recursive := q.Get("recursive") != "0" notes, err := s.st.List(store.Filter{ Category: q.Get("category"), Recursive: recursive, Tag: q.Get("tag"), Query: q.Get("q"), }) if err != nil { mapStoreError(w, err) return } if notes == nil { notes = []*store.Note{} } for _, n := range notes { normalizeNote(n) } writeJSON(w, http.StatusOK, notes) } func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) { var in noteInput if err := decodeJSON(w, r, &in, maxBody); err != nil { return } title, format, categories, content, author, message, _ := in.fields() n, err := s.st.Create(title, format, categories, content, author, message) if err != nil { mapStoreError(w, err) return } s.publishSaved(n, author, in.Session, false) writeJSON(w, http.StatusCreated, newNoteView(n, content)) } func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) { content, n, err := s.st.Content(r.PathValue("id")) if err != nil { mapStoreError(w, err) return } writeJSON(w, http.StatusOK, newNoteView(n, content)) } func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) { var in noteInput if err := decodeJSON(w, r, &in, maxBody); err != nil { return } title, format, categories, content, author, message, baseHash := in.fields() n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message, baseHash) if errors.Is(err, store.ErrConflict) { // 409 com o estado atual: o cliente decide entre recarregar ou forçar. writeJSON(w, http.StatusConflict, map[string]any{ "error": "a nota foi alterada em outro dispositivo", "server_hash": n.Hash, "updated_at": n.UpdatedAt, }) return } if err != nil { mapStoreError(w, err) return } s.publishSaved(n, author, in.Session, false) writeJSON(w, http.StatusOK, newNoteView(n, content)) } func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") n, err := s.st.Get(id) if err != nil { mapStoreError(w, err) return } if err := s.st.Delete(id); err != nil { mapStoreError(w, err) return } s.publishSaved(n, "", "", true) w.WriteHeader(http.StatusNoContent) } // handleRaw baixa a nota como arquivo (Content-Disposition attachment). func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) { content, n, err := s.st.Content(r.PathValue("id")) if err != nil { mapStoreError(w, err) return } ctype := "text/plain; charset=utf-8" switch n.Format { case store.FormatMD: ctype = "text/markdown; charset=utf-8" case store.FormatHTML: ctype = "text/html; charset=utf-8" } w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Disposition", `attachment; filename="`+sanitizeFilename(n.Filename())+`"`) w.Header().Set("Cache-Control", "no-store") _, _ = w.Write([]byte(content)) } // sanitizeFilename elimina caracteres que quebrariam o header. // Delega à helper da store (mesma regra de sanitizeHeaderName) para não // duplicar a política de sanitização entre pacotes. func sanitizeFilename(name string) string { return store.SanitizeHeaderName(name) } func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) { tags, err := s.st.Tags() if err != nil { mapStoreError(w, err) return } if tags == nil { tags = []store.TagCount{} } writeJSON(w, http.StatusOK, tags) } func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) { cats, err := s.st.Categories() if err != nil { mapStoreError(w, err) return } if cats == nil { cats = []store.CategoryView{} } writeJSON(w, http.StatusOK, cats) }