refactor: integra reconcile no boot, dedup categoria/pasta, CI, Go 1.24
ci / check (push) Successful in 4m12s

- Boot chama ReconcileBlobs com log de órfãos (era código morto)
- treeNode/validTreeName/siblingDup/moveCreatesCycle compartilhados
  entre Create/Update Category/Folder (mesma regra, um só lugar)
- mapStoreErrorFor com mensagens 404 por recurso (nota/arquivo/pasta/categoria)
- revisionHash helper + teste GET /revisions/{hash} (200 + 404)
- ReconcileBlobs com lock curto (coleta sob RLock, I/O fora do lock)
- AddFile com LimitReader no stream (cap antes de gravar, não depois)
- DeleteFile/DeleteFolder transacionais com rollback em memória
- attachmentKind único no frontend (syntax/html/preview)
- CI: gofmt, vet, test -race, node --check; go.mod 1.24 = Dockerfile
- README: notas operacionais (memória, proxy, boot, SSE, 413/400)
This commit is contained in:
lgcosta committed 2026-11-05 19:57:38 -03:00
1 parent 0e59ef23f4
commit e0bef77e31
16 files changed
+518 -126

No files matched your search

+20 -1
View File
@@ -210,7 +210,26 @@ func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
t.Fatalf("stats do diff: +%d -%d", dv.Added, dv.Removed)
}
// Restaurar a primeira revisão.
// GET de revisão específica (conteúdo + metadados).
resp = call("GET", "/api/notes/"+id+"/revisions/"+created.Hash, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("get revision: %s", resp.Status)
}
var rc struct {
Hash string `json:"hash"`
Content string `json:"content"`
}
decode(t, resp, &rc)
if rc.Hash != created.Hash || !strings.Contains(rc.Content, "#systemd") {
t.Fatalf("revisão: %+v", rc)
}
// Hash malformado → 404.
resp = call("GET", "/api/notes/"+id+"/revisions/xyz", "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("hash inválido: esperava 404, obtive %s", resp.Status)
}
resp.Body.Close()
resp = call("POST", "/api/notes/"+id+"/revisions/"+created.Hash+"/restore", `{"author":"lg"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("restore: %s", resp.Status)
+3 -5
View File
@@ -12,7 +12,6 @@ type categoryInput struct {
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
var in categoryInput
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
parent := ""
@@ -21,7 +20,7 @@ func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
}
c, err := s.st.CreateCategory(in.Name, parent)
if err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "categoria")
return
}
writeJSON(w, http.StatusCreated, c)
@@ -30,12 +29,11 @@ func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
var in categoryInput
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
c, err := s.st.UpdateCategory(r.PathValue("id"), in.Name, in.Parent)
if err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "categoria")
return
}
writeJSON(w, http.StatusOK, c)
@@ -43,7 +41,7 @@ func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request) {
if err := s.st.DeleteCategory(r.PathValue("id")); err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "categoria")
return
}
w.WriteHeader(http.StatusNoContent)
+7 -10
View File
@@ -31,7 +31,7 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
defer f.Close()
meta, err := s.st.AddFile(hdr.Filename, r.FormValue("folder_id"), r.FormValue("note_id"), f)
if err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "arquivo")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
@@ -51,13 +51,13 @@ func (s *Server) handleFileTree(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleFileStatic(w http.ResponseWriter, r *http.Request) {
if err := s.st.ServeFile(w, r, r.PathValue("id")); err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "arquivo")
}
}
func (s *Server) handleFileDelete(w http.ResponseWriter, r *http.Request) {
if err := s.st.DeleteFile(r.PathValue("id")); err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "arquivo")
return
}
w.WriteHeader(http.StatusNoContent)
@@ -73,13 +73,12 @@ func (s *Server) handleFileMove(w http.ResponseWriter, r *http.Request) {
var in fileMoveInput
if r.ContentLength > 0 {
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
}
m, err := s.st.MoveFile(r.PathValue("id"), in.Name, in.Folder, in.Note)
if err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "arquivo")
return
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -97,7 +96,6 @@ type folderInput struct {
func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
var in folderInput
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
parent := ""
@@ -106,7 +104,7 @@ func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
}
f, err := s.st.CreateFolder(in.Name, parent)
if err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "pasta")
return
}
writeJSON(w, http.StatusCreated, f)
@@ -115,12 +113,11 @@ func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
var in folderInput
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
f, err := s.st.UpdateFolder(r.PathValue("id"), in.Name, in.Parent)
if err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "pasta")
return
}
writeJSON(w, http.StatusOK, f)
@@ -128,7 +125,7 @@ func (s *Server) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleFolderDelete(w http.ResponseWriter, r *http.Request) {
if err := s.st.DeleteFolder(r.PathValue("id")); err != nil {
mapStoreError(w, err)
mapStoreErrorFor(w, err, "pasta")
return
}
w.WriteHeader(http.StatusNoContent)
+3 -10
View File
@@ -4,7 +4,6 @@ import (
"errors"
"io/fs"
"net/http"
"strings"
"github.com/lgcosta/notas-online/internal/store"
)
@@ -88,7 +87,6 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
var in noteInput
if err := decodeJSON(w, r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message, _ := in.fields()
@@ -113,7 +111,6 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var in noteInput
if err := decodeJSON(w, r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message, baseHash := in.fields()
@@ -172,14 +169,10 @@ func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) {
}
// 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 strings.Map(func(r rune) rune {
switch r {
case '"', '\\', '\n', '\r', '/':
return '_'
}
return r
}, name)
return store.SanitizeHeaderName(name)
}
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
+5 -1
View File
@@ -167,6 +167,10 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "streaming não suportado")
return
}
// SSE é um stream longo; o WriteTimeout global fecharia a conexão. Renovamos
// o deadline a cada keepalive via ResponseController (Go 1.20+).
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{})
session := truncate(r.URL.Query().Get("session"), 64)
ch := s.hub.register()
@@ -194,6 +198,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
case <-keepalive.C:
_ = rc.SetWriteDeadline(time.Time{})
_, _ = io.WriteString(w, ": ping\n\n")
flusher.Flush()
case <-r.Context().Done():
@@ -211,7 +216,6 @@ type presenceInput struct {
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
var in presenceInput
if err := decodeJSON(w, r, &in, 1<<16); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
in.Session = truncate(in.Session, 64)
-1
View File
@@ -22,7 +22,6 @@ type renderInput struct {
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
var in renderInput
if err := decodeJSON(w, r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
if len(in.Content) > maxRenderInput {
+17 -10
View File
@@ -11,6 +11,17 @@ import (
var hashRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
// revisionHash valida o hash da rota; em hash inválido responde 404 e retorna
// ("", false) — o chamador deve apenas retornar.
func revisionHash(w http.ResponseWriter, r *http.Request) (string, bool) {
hash := r.PathValue("hash")
if !hashRe.MatchString(hash) {
writeError(w, http.StatusNotFound, "revisão não encontrada")
return "", false
}
return hash, true
}
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
revs, err := s.st.History(r.PathValue("id"))
if err != nil {
@@ -26,9 +37,8 @@ func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if !hashRe.MatchString(hash) {
writeError(w, http.StatusNotFound, "revisão não encontrada")
hash, ok := revisionHash(w, r)
if !ok {
return
}
rc, err := s.st.RevisionWithContent(r.PathValue("id"), hash)
@@ -51,9 +61,8 @@ type diffView struct {
}
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if !hashRe.MatchString(hash) {
writeError(w, http.StatusNotFound, "revisão não encontrada")
hash, ok := revisionHash(w, r)
if !ok {
return
}
before, after, rev, err := s.st.DiffBetween(r.PathValue("id"), hash)
@@ -80,15 +89,13 @@ type restoreInput struct {
}
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if !hashRe.MatchString(hash) {
writeError(w, http.StatusNotFound, "revisão não encontrada")
hash, ok := revisionHash(w, r)
if !ok {
return
}
var in restoreInput
if r.ContentLength > 0 {
if err := decodeJSON(w, r, &in, 1<<16); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
}
+26 -4
View File
@@ -194,7 +194,6 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
var req loginRequest
if err := decodeJSON(w, r, &req, 1<<16); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(req.Token)), []byte(s.token)) != 1 {
@@ -239,11 +238,19 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
// 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 {
defer r.Body.Close()
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
@@ -260,11 +267,26 @@ 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.
// 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, "nota não encontrada")
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):