From e0bef77e310996e4b5be2ad7136e6d1862cd4e95 Mon Sep 17 00:00:00 2001 From: lgcosta Date: Thu, 5 Nov 2026 19:57:38 -0300 Subject: [PATCH] refactor: integra reconcile no boot, dedup categoria/pasta, CI, Go 1.24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/workflows/ci.yml | 27 ++++ README.md | 8 ++ cmd/notas/main.go | 10 ++ go.mod | 2 +- internal/api/api_test.go | 21 +++- internal/api/categories.go | 8 +- internal/api/files.go | 17 ++- internal/api/notes.go | 13 +- internal/api/presence.go | 6 +- internal/api/render.go | 1 - internal/api/revisions.go | 27 ++-- internal/api/server.go | 30 ++++- internal/store/files.go | 190 ++++++++++++++++++++++------- internal/store/files_extra_test.go | 166 +++++++++++++++++++++++++ internal/store/store.go | 73 ++++++++--- web/js/app.js | 45 +++---- 16 files changed, 518 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 internal/store/files_extra_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b55857a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: gofmt + run: test -z "$(gofmt -l internal cmd)" + - name: go vet + run: go vet ./... + - name: go test -race + run: go test -race ./... + - name: frontend syntax + run: node --check web/js/app.js diff --git a/README.md b/README.md index d272403..93f4f11 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,14 @@ curl -sI -H "Authorization: Bearer $T" $B/api/files/static/ | grep -i dispos - Rode atrás de TLS (reverse proxy) em produção. O modelo é **token único compartilhado** (sem usuários múltiplos); a imagem final é `scratch` non-root (uid 65534) — sem shell para `exec` de depuração, por design. +- **Notas operacionais.** Rate-limit e sessões vivem **em memória**: reiniciar o processo + limpa bloqueios de login e revoga todas as sessões (relogin). Atrás de proxy reverso, + todos os clientes chegam com o IP do proxy — o bloqueio passa a ser compartilhado; + só confie em cabeçalhos `X-Forwarded-*` com lista explícita de proxies confiáveis + (hoje o servidor usa `RemoteAddr` puro). O boot roda reconciliação de anexos + (remove blobs órfãos, loga metadados sem blob). SSE é stream longo sem deadline + global de escrita (renovado por keepalive). Erros de corpo acima do limite + retornam **413**; JSON malformado retorna **400**. - Preferências do editor e abas abertas ficam em `localStorage` (dados não sensíveis, por dispositivo/navegador); o token nunca vai para lá. - Presença/SSE: o stream exige a mesma autenticação das rotas `/api/*` (cookie HttpOnly no diff --git a/cmd/notas/main.go b/cmd/notas/main.go index 25a8222..ac76f1c 100644 --- a/cmd/notas/main.go +++ b/cmd/notas/main.go @@ -48,6 +48,16 @@ func run() error { if err != nil { return err } + if removed, missing, rerr := st.ReconcileBlobs(); rerr != nil { + slog.Warn("reconciliação de anexos falhou", "err", rerr) + } else { + if removed > 0 || len(missing) > 0 { + slog.Warn("anexos órfãos no boot", "blobsRemovidos", removed, "metadadosSemBlob", len(missing)) + } + for _, id := range missing { + slog.Warn("anexo sem blob", "id", id) + } + } handler := api.New(st, token, web.FS, api.Options{SecureCookie: secureCookie}).Handler() srv := &http.Server{ diff --git a/go.mod b/go.mod index dd5401c..1ecf6c6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/lgcosta/notas-online -go 1.23 +go 1.24 require ( github.com/hexops/gotextdiff v1.0.3 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 36f00f6..58a3f7d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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) diff --git a/internal/api/categories.go b/internal/api/categories.go index d2b93da..9da5090 100644 --- a/internal/api/categories.go +++ b/internal/api/categories.go @@ -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) diff --git a/internal/api/files.go b/internal/api/files.go index f41e9de..a7f62f3 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -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) diff --git a/internal/api/notes.go b/internal/api/notes.go index 858eefc..d68a390 100644 --- a/internal/api/notes.go +++ b/internal/api/notes.go @@ -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) { diff --git a/internal/api/presence.go b/internal/api/presence.go index e52e6c3..1f69532 100644 --- a/internal/api/presence.go +++ b/internal/api/presence.go @@ -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) diff --git a/internal/api/render.go b/internal/api/render.go index 1269890..a3720f3 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -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 { diff --git a/internal/api/revisions.go b/internal/api/revisions.go index 97900de..198f333 100644 --- a/internal/api/revisions.go +++ b/internal/api/revisions.go @@ -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 } } diff --git a/internal/api/server.go b/internal/api/server.go index 8927201..c275759 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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): diff --git a/internal/store/files.go b/internal/store/files.go index e662b39..4e8d7d8 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -27,6 +27,9 @@ const MaxFileBytes = 64 << 20 // uploads concorrentes (1000 × 64 MiB) esgotam o disco do host. const MaxFilesTotalBytes = 2048 << 20 +// maxFolderLen limita o nome de pastas (mesmo teto das categorias). +const maxFolderLen = 100 + // Folder é um nó da árvore de pastas (parent "" = raiz). type Folder struct { ID string `json:"id"` @@ -216,8 +219,8 @@ func (s *Store) fileBlobPath(id, ext string) string { // CreateFolder cria uma pasta na árvore de arquivos. func (s *Store) CreateFolder(name, parent string) (*Folder, error) { - name = strings.TrimSpace(name) - if name == "" || len([]rune(name)) > 100 || !fileNameRe.MatchString(name) { + name, ok := validTreeName(name, maxFolderLen) + if !ok || !fileNameRe.MatchString(name) { return nil, fmt.Errorf("%w: nome de pasta inválido", ErrInvalid) } s.mu.Lock() @@ -225,11 +228,8 @@ func (s *Store) CreateFolder(name, parent string) (*Folder, error) { if parent != "" && s.folderByID(parent) == nil { return nil, fmt.Errorf("%w: pasta pai inexistente", ErrInvalid) } - lower := strings.ToLower(name) - for _, f := range s.folders { - if f.Parent == parent && strings.ToLower(f.Name) == lower { - return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, name) - } + if siblingDup(folderNodes(s.folders), "", parent, name) { + return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, name) } f := Folder{ID: newID(), Name: name, Parent: parent, CreatedAt: time.Now().UTC()} s.folders = append(s.folders, f) @@ -239,6 +239,15 @@ func (s *Store) CreateFolder(name, parent string) (*Folder, error) { return &f, nil } +// folderNodes projeta pastas para validação genérica de árvore. +func folderNodes(folders []Folder) []treeNode { + out := make([]treeNode, len(folders)) + for i, f := range folders { + out[i] = treeNode{ID: f.ID, Name: f.Name, Parent: f.Parent} + } + return out +} + // UpdateFolder renomeia/move pasta com checagem de ciclo. func (s *Store) UpdateFolder(id, name string, parent *string) (*Folder, error) { s.mu.Lock() @@ -251,22 +260,17 @@ func (s *Store) UpdateFolder(id, name string, parent *string) (*Folder, error) { if *parent != "" && s.folderByID(*parent) == nil { return nil, fmt.Errorf("%w: pasta pai inexistente", ErrInvalid) } - for _, d := range s.folderSubtree(id) { - if d == *parent { - return nil, fmt.Errorf("%w: não é possível mover uma pasta para dentro dela mesma", ErrInvalid) - } + if moveCreatesCycle(s.folderSubtree(id), *parent) { + return nil, fmt.Errorf("%w: não é possível mover uma pasta para dentro dela mesma", ErrInvalid) } f.Parent = *parent } if n := strings.TrimSpace(name); n != "" && n != f.Name { - if len([]rune(n)) > 100 || !fileNameRe.MatchString(n) { + if _, ok := validTreeName(n, maxFolderLen); !ok || !fileNameRe.MatchString(n) { return nil, fmt.Errorf("%w: nome de pasta inválido", ErrInvalid) } - lower := strings.ToLower(n) - for _, other := range s.folders { - if other.ID != id && other.Parent == f.Parent && strings.ToLower(other.Name) == lower { - return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, n) - } + if siblingDup(folderNodes(s.folders), id, f.Parent, n) { + return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, n) } f.Name = n } @@ -277,7 +281,9 @@ func (s *Store) UpdateFolder(id, name string, parent *string) (*Folder, error) { return &cp, nil } -// DeleteFolder remove a pasta e todo o subtree (incluindo arquivos). +// DeleteFolder remove a pasta e todo o subtree (incluindo arquivos). Ordem +// transacional: persiste metadados (pastas + arquivos) antes de remover blobs; +// em falha de gravação o estado em memória é restaurado (sem perda silenciosa). func (s *Store) DeleteFolder(id string) error { s.mu.Lock() defer s.mu.Unlock() @@ -288,6 +294,8 @@ func (s *Store) DeleteFolder(id string) error { for _, d := range s.folderSubtree(id) { sub[d] = true } + prevFolders := append([]Folder(nil), s.folders...) + prevFiles := append([]FileMeta(nil), s.fileMetas...) keptFolders := s.folders[:0] for _, f := range s.folders { if !sub[f.ID] { @@ -296,19 +304,30 @@ func (s *Store) DeleteFolder(id string) error { } s.folders = keptFolders + removed := make([]FileMeta, 0) keptFiles := s.fileMetas[:0] for _, m := range s.fileMetas { if sub[m.Folder] { - _ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))) + removed = append(removed, m) continue } keptFiles = append(keptFiles, m) } s.fileMetas = keptFiles if err := s.saveFolders(); err != nil { + s.folders = prevFolders + s.fileMetas = prevFiles return err } - return s.saveFileMetas() + if err := s.saveFileMetas(); err != nil { + s.folders = prevFolders + s.fileMetas = prevFiles + return err + } + for _, m := range removed { + _ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))) + } + return nil } // --------------------------------------------------------------------------- @@ -317,19 +336,26 @@ func (s *Store) DeleteFolder(id string) error { // AddFile persiste um upload: valida nome/pasta/nota, calcula SHA-256 e MIME // reais (content sniffing) e grava o blob atomicamente. Limite: MaxFileBytes. +// A cópia para disco ocorre fora do lock global para não bloquear a store +// durante uploads lentos; o commit (revalidação + quota + rename + +// persistência) é feito sob lock, com rollback em falha. func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, error) { clean, err := cleanFileName(name) if err != nil { return nil, err } - s.mu.Lock() - defer s.mu.Unlock() - if folder != "" && s.folderByID(folder) == nil { - return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid) - } - if note != "" { - if _, err := s.get(note); err != nil { - return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid) + // Pré-validação barata; revalidada no commit sob lock. + if folder != "" || note != "" { + s.mu.RLock() + fok := folder == "" || s.folderByID(folder) != nil + s.mu.RUnlock() + if !fok { + return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid) + } + if note != "" { + if _, err := s.get(note); err != nil { + return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid) + } } } @@ -340,8 +366,8 @@ func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, erro return nil, err } fail := func(err error) (*FileMeta, error) { - f.Close() - os.Remove(tmp) + _ = f.Close() + _ = os.Remove(tmp) return nil, err } @@ -358,7 +384,9 @@ func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, erro if _, err := h.Write(head); err != nil { return fail(err) } - rest, err := io.Copy(io.MultiWriter(f, h), r) + // Limita durante o stream (não só depois): evita gravar >MaxFileBytes+1 + // no disco quando o corpo é aberto ou o Content-Length mente. + rest, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(r, MaxFileBytes-int64(n)+1)) if err != nil { return fail(err) } @@ -366,18 +394,35 @@ func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, erro if size > MaxFileBytes { return fail(fmt.Errorf("%w: arquivo excede %d MiB", ErrTooLarge, MaxFileBytes>>20)) } - if total := s.totalFileBytesLocked(); size+total > MaxFilesTotalBytes { - return fail(fmt.Errorf("%w: total de anexos excede %d GiB", ErrTooLarge, MaxFilesTotalBytes>>30)) - } if err := f.Close(); err != nil { - os.Remove(tmp) + _ = os.Remove(tmp) return nil, err } mime := extByMime(clean, http.DetectContentType(head)) ext := safeExt(clean, mime) - if err := os.Rename(tmp, s.fileBlobPath(id, ext)); err != nil { - os.Remove(tmp) + + // Commit sob lock: revalida referências e quota agregada (corrige corrida + // entre uploads concorrentes); em falha remove o temporário ou reverte o blob. + s.mu.Lock() + defer s.mu.Unlock() + if folder != "" && s.folderByID(folder) == nil { + _ = os.Remove(tmp) + return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid) + } + if note != "" { + if _, err := s.get(note); err != nil { + _ = os.Remove(tmp) + return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid) + } + } + if total := s.totalFileBytesLocked(); size+total > MaxFilesTotalBytes { + _ = os.Remove(tmp) + return nil, fmt.Errorf("%w: total de anexos excede %d GiB", ErrTooLarge, MaxFilesTotalBytes>>30) + } + blob := s.fileBlobPath(id, ext) + if err := os.Rename(tmp, blob); err != nil { + _ = os.Remove(tmp) return nil, err } @@ -387,6 +432,8 @@ func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, erro } s.fileMetas = append(s.fileMetas, meta) if err := s.saveFileMetas(); err != nil { + s.fileMetas = s.fileMetas[:len(s.fileMetas)-1] + _ = os.Remove(blob) return nil, err } return &meta, nil @@ -401,7 +448,8 @@ func (s *Store) totalFileBytesLocked() int64 { return total } -// Files devolve todos os metadados (ordenados por data desc). +// Files devolve todos os metadados (ordenados por data desc). Exportado por +// necessidade (o campo interno tem o mesmo nome); a API usa FileTree/RootFiles. func (s *Store) Files() []FileMeta { s.mu.RLock() defer s.mu.RUnlock() @@ -459,7 +507,9 @@ func (s *Store) MoveFile(id, name string, folder, note *string) (*FileMeta, erro return &cp, nil } -// DeleteFile remove metadado + blob. +// DeleteFile remove metadado + blob. Persiste os metadados antes de remover o +// blob: em falha de gravação o arquivo continua referenciado (sem perda +// silenciosa); em falha só no unlink, a reconciliação de órfãos cobre. func (s *Store) DeleteFile(id string) error { s.mu.Lock() defer s.mu.Unlock() @@ -474,9 +524,14 @@ func (s *Store) DeleteFile(id string) error { return ErrNotFound } m := s.fileMetas[idx] - _ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))) + prev := append([]FileMeta(nil), s.fileMetas...) s.fileMetas = append(s.fileMetas[:idx], s.fileMetas[idx+1:]...) - return s.saveFileMetas() + if err := s.saveFileMetas(); err != nil { + s.fileMetas = prev + return err + } + _ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))) + return nil } // FileTree monta a hierarquia de pastas com arquivos. @@ -574,6 +629,11 @@ func (s *Store) ServeFile(w http.ResponseWriter, r *http.Request, id string) err } func sanitizeHeaderName(name string) string { + return SanitizeHeaderName(name) +} + +// SanitizeHeaderName elimina caracteres que quebrariam Content-Disposition. +func SanitizeHeaderName(name string) string { return strings.Map(func(r rune) rune { switch r { case '"', '\\', '\n', '\r', '/': @@ -584,6 +644,54 @@ func sanitizeHeaderName(name string) string { } // Folders devolve todas as pastas (lista plana; a UI monta a árvore). + +// ReconcileBlobs remove blobs sem metadado (órfãos de falhas de commit) e +// reporta metadados sem blob (órfãos de falhas de unlink). Operação de +// manutenção (idealmente só no boot): coleta candidatos sob lock curto e +// remove fora do lock para não prender a store durante I/O de disco. +// Retorna (blobsRemovidos, metadadosSemBlob, erro). +func (s *Store) ReconcileBlobs() (removed int, missing []string, rerr error) { + s.mu.RLock() + want := map[string]bool{} + metas := make([]FileMeta, len(s.fileMetas)) + copy(metas, s.fileMetas) + for _, m := range metas { + want[s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))] = true + } + dir := s.filesRoot() + s.mu.RUnlock() + entries, rerr := os.ReadDir(dir) + if rerr != nil { + return 0, nil, rerr + } + var candidates []string + for _, e := range entries { + if e.IsDir() { + continue + } + p := dir + string(os.PathSeparator) + e.Name() + if strings.HasSuffix(e.Name(), ".tmp") || !want[p] { + candidates = append(candidates, p) + } + } + var statTargets []FileMeta + for _, m := range metas { + statTargets = append(statTargets, m) + } + for _, p := range candidates { + _ = os.Remove(p) + removed++ + } + for _, m := range statTargets { + if _, serr := os.Stat(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))); serr != nil { + missing = append(missing, m.ID) + } + } + return removed, missing, nil +} + +// Folders devolve todas as pastas (lista plana; a árvore de API sai de +// FileTree). Exportado por necessidade: o campo interno tem o mesmo nome. func (s *Store) Folders() []Folder { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/store/files_extra_test.go b/internal/store/files_extra_test.go new file mode 100644 index 0000000..b1bc0c0 --- /dev/null +++ b/internal/store/files_extra_test.go @@ -0,0 +1,166 @@ +package store + +import ( + "errors" + "os" + "strings" + "testing" +) + +func mustFolder(t *testing.T, s *Store, name, parent string) *Folder { + t.Helper() + f, err := s.CreateFolder(name, parent) + if err != nil { + t.Fatalf("CreateFolder(%q): %v", name, err) + } + return f +} + +func strPtr(s string) *string { return &s } + +// writeBlobForTest grava um arquivo bruto no dir de blobs (simula órfão). +func writeBlobForTest(s *Store, path string) error { + return os.WriteFile(path, []byte("orfao"), 0o640) +} + +func TestAddFileValidatesAndServesBlob(t *testing.T) { + s := newTestStore(t) + folder := mustFolder(t, s, "docs", "") + m, err := s.AddFile("rel.pdf", folder.ID, "", strings.NewReader("%PDF-1.4 fake")) + if err != nil { + t.Fatal(err) + } + if m.Size == 0 || m.MIME == "" || m.SHA256 == "" { + t.Fatalf("metadado incompleto: %+v", m) + } + if got := s.RootFiles(); len(got) != 0 { + t.Fatalf("arquivo com pasta não deveria estar na raiz: %d", len(got)) + } + if _, err := s.AddFile("x.txt", "pasta-inexistente", "", strings.NewReader("x")); !errors.Is(err, ErrInvalid) { + t.Fatalf("pasta inexistente deveria dar ErrInvalid, deu %v", err) + } + if _, err := s.AddFile("y.txt", "", "nota-inexistente", strings.NewReader("y")); !errors.Is(err, ErrInvalid) { + t.Fatalf("nota inexistente deveria dar ErrInvalid, deu %v", err) + } +} + +func TestAddFileRejectsPerFileCap(t *testing.T) { + s := newTestStore(t) + if _, err := s.AddFile("big.bin", "", "", infiniteReader{}); !errors.Is(err, ErrTooLarge) { + t.Fatalf("esperava ErrTooLarge, deu %v", err) + } +} + +// infiniteReader gera bytes infinitos (para estourar o cap sem alocar). +type infiniteReader struct{} + +func (infiniteReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 'A' + } + return len(p), nil +} + +func TestAddFileConcurrencyHonorsQuota(t *testing.T) { + s := newTestStore(t) + payload := strings.Repeat("A", 1<<20) // 1 MiB + const n = 8 + errs := make(chan error, n) + for range n { + go func() { + _, err := s.AddFile("f.bin", "", "", strings.NewReader(payload)) + errs <- err + }() + } + for range n { + if err := <-errs; err != nil { + // Quota é 2 GiB: nenhum upload de 1 MiB deveria falhar aqui. + t.Fatalf("erro inesperado: %v", err) + } + } + removed, missing, err := s.ReconcileBlobs() + if err != nil { + t.Fatal(err) + } + if removed != 0 || len(missing) != 0 { + t.Fatalf("reconciliação após uploads ok: removed=%d missing=%v", removed, missing) + } +} + +func TestDeleteFilePersistsBeforeUnlink(t *testing.T) { + s := newTestStore(t) + m, err := s.AddFile("a.txt", "", "", strings.NewReader("oi")) + if err != nil { + t.Fatal(err) + } + if err := s.DeleteFile(m.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetFile(m.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("esperava ErrNotFound após delete, deu %v", err) + } + if err := s.DeleteFile(m.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("delete duplo deveria dar ErrNotFound, deu %v", err) + } +} + +func TestDeleteFolderPersistsBeforeUnlink(t *testing.T) { + s := newTestStore(t) + f := mustFolder(t, s, "p", "") + if _, err := s.AddFile("a.txt", f.ID, "", strings.NewReader("conteudo")); err != nil { + t.Fatal(err) + } + if err := s.DeleteFolder(f.ID); err != nil { + t.Fatal(err) + } + if len(s.fileMetas) != 0 { + t.Fatalf("arquivos deveriam ter sumido: %+v", s.fileMetas) + } + removed, missing, err := s.ReconcileBlobs() + if err != nil { + t.Fatal(err) + } + if removed != 0 || len(missing) != 0 { + t.Fatalf("órfãos após delete limpo: removed=%d missing=%v", removed, missing) + } +} + +func TestReconcileBlobsRemovesOrphans(t *testing.T) { + s := newTestStore(t) + if _, err := s.AddFile("a.txt", "", "", strings.NewReader("conteudo")); err != nil { + t.Fatal(err) + } + // Simula blob órfão de falha de commit: arquivo no disco sem metadado. + if err := writeBlobForTest(s, s.fileBlobPath("sem-metadado", ".bin")); err != nil { + t.Fatal(err) + } + removed, missing, err := s.ReconcileBlobs() + if err != nil { + t.Fatal(err) + } + if removed != 1 { + t.Fatalf("esperava 1 órfão removido, removeu %d", removed) + } + if len(missing) != 0 { + t.Fatalf("nenhum metadado deveria estar sem blob: %v", missing) + } +} + +func TestMoveFileValidation(t *testing.T) { + s := newTestStore(t) + m, err := s.AddFile("a.txt", "", "", strings.NewReader("x")) + if err != nil { + t.Fatal(err) + } + if _, err := s.MoveFile(m.ID, "", strPtr("pasta-inexistente"), nil); !errors.Is(err, ErrInvalid) { + t.Fatalf("move para pasta inexistente deveria dar ErrInvalid, deu %v", err) + } + f := mustFolder(t, s, "ok", "") + got, err := s.MoveFile(m.ID, "", &f.ID, nil) + if err != nil { + t.Fatal(err) + } + if got.Folder != f.ID { + t.Fatalf("esperava pasta %s, veio %s", f.ID, got.Folder) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 15874b1..b9cc3fc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -164,6 +164,44 @@ func New(dir string) (*Store, error) { // Árvore de categorias (categories.json) // --------------------------------------------------------------------------- +// treeNode é a interface mínima para validar árvores categoria/pasta com o +// mesmo algoritmo (nomes únicos entre irmãos, sem ciclos). +type treeNode struct { + ID string + Name string + Parent string +} + +// validTreeName normaliza e valida o comprimento do nome. +func validTreeName(name string, maxLen int) (string, bool) { + n := strings.TrimSpace(name) + if n == "" || len([]rune(n)) > maxLen { + return "", false + } + return n, true +} + +// siblingDup diz se já existe irmão com o mesmo nome (case-insensitive). +func siblingDup(nodes []treeNode, id, parent, name string) bool { + lower := strings.ToLower(name) + for _, o := range nodes { + if o.ID != id && o.Parent == parent && strings.ToLower(o.Name) == lower { + return true + } + } + return false +} + +// moveCreatesCycle diz se mover id para newParent criaria ciclo. +func moveCreatesCycle(subtree []string, newParent string) bool { + for _, d := range subtree { + if d == newParent { + return true + } + } + return false +} + func (s *Store) catsPath() string { return filepath.Join(s.dir, "categories.json") } func (s *Store) loadCats() error { @@ -211,8 +249,8 @@ func (s *Store) catName(id string) string { // CreateCategory cria uma pasta; nomes únicos entre irmãos (case-insensitive). func (s *Store) CreateCategory(name, parent string) (*Category, error) { - name = strings.TrimSpace(name) - if r := []rune(name); name == "" || len(r) > maxCategoryLen { + name, ok := validTreeName(name, maxCategoryLen) + if !ok { return nil, fmt.Errorf("%w: nome de categoria inválido", ErrInvalid) } s.mu.Lock() @@ -220,11 +258,8 @@ func (s *Store) CreateCategory(name, parent string) (*Category, error) { if parent != "" && s.catByID(parent) == nil { return nil, fmt.Errorf("%w: categoria pai inexistente", ErrInvalid) } - lower := strings.ToLower(name) - for _, c := range s.cats { - if c.Parent == parent && strings.ToLower(c.Name) == lower { - return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, name) - } + if siblingDup(catNodes(s.cats), "", parent, name) { + return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, name) } cat := Category{ID: newID(), Name: name, Parent: parent, CreatedAt: time.Now().UTC()} s.cats = append(s.cats, cat) @@ -234,6 +269,15 @@ func (s *Store) CreateCategory(name, parent string) (*Category, error) { return &cat, nil } +// catNodes projeta categorias para validação genérica de árvore. +func catNodes(cats []Category) []treeNode { + out := make([]treeNode, len(cats)) + for i, c := range cats { + out[i] = treeNode{ID: c.ID, Name: c.Name, Parent: c.Parent} + } + return out +} + // UpdateCategory renomeia (name == "" mantém) e/ou move (parent != nil) a categoria. func (s *Store) UpdateCategory(id, name string, parent *string) (*Category, error) { s.mu.Lock() @@ -246,22 +290,17 @@ func (s *Store) UpdateCategory(id, name string, parent *string) (*Category, erro if *parent != "" && s.catByID(*parent) == nil { return nil, fmt.Errorf("%w: categoria pai inexistente", ErrInvalid) } - for _, d := range s.catSubtree(id) { // sem ciclos: pai não pode ser descendente - if d == *parent { - return nil, fmt.Errorf("%w: não é possível mover uma categoria para dentro dela mesma", ErrInvalid) - } + if moveCreatesCycle(s.catSubtree(id), *parent) { + return nil, fmt.Errorf("%w: não é possível mover uma categoria para dentro dela mesma", ErrInvalid) } c.Parent = *parent } if n := strings.TrimSpace(name); n != "" && n != c.Name { - if len([]rune(n)) > maxCategoryLen { + if _, ok := validTreeName(n, maxCategoryLen); !ok { return nil, fmt.Errorf("%w: nome de categoria inválido", ErrInvalid) } - lower := strings.ToLower(n) - for _, other := range s.cats { - if other.ID != id && other.Parent == c.Parent && strings.ToLower(other.Name) == lower { - return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, n) - } + if siblingDup(catNodes(s.cats), id, c.Parent, n) { + return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, n) } c.Name = n } diff --git a/web/js/app.js b/web/js/app.js index 1d4f625..7145c37 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -2371,27 +2371,24 @@ ? 'Anexo(s) inserido(s) no editor' : `Inseridos ${done.length}/${files.length}`); } + function attachmentKind(f) { + const mime = f.mime || ''; + if (mime.startsWith('image/') && mime !== 'image/svg+xml') return 'img'; + if (mime.startsWith('video/')) return 'vid'; + if (mime.startsWith('audio/')) return 'aud'; + return 'doc'; + } function attachmentSyntax(f, fmt) { - const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml'; - const isVid = f.mime.startsWith('video/'); - const isAud = f.mime.startsWith('audio/'); - if (fmt === 'html') { - if (isImg) return `${esc(f.name)}`; - if (isVid) return ``; - if (isAud) return ``; - return `${esc(f.name)}`; - } - if (isImg) return `![${f.name}](${f.url})`; - return `[${(isVid ? '▶ ' : isAud ? ' ' : '') + f.name}](${f.url})`; + const kind = attachmentKind(f); + if (fmt === 'html') return attachmentHtml(f); + if (kind === 'img') return `![${f.name}](${f.url})`; + return `[${(kind === 'vid' ? '▶ ' : kind === 'aud' ? ' ' : '') + f.name}](${f.url})`; } - function attachmentHtml(f) { - const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml'; - const isVid = f.mime.startsWith('video/'); - const isAud = f.mime.startsWith('audio/'); - if (isImg) return `${esc(f.name)}`; - if (isVid) return ``; - if (isAud) return ``; + const kind = attachmentKind(f); + if (kind === 'img') return `${esc(f.name)}`; + if (kind === 'vid') return ``; + if (kind === 'aud') return ``; return `${esc(f.name)}`; } @@ -2436,14 +2433,12 @@ F.lbDl.href = f.url; F.lbDl.setAttribute('download', f.name); F.lbBody.textContent = ''; - const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml'; - const isVid = f.mime.startsWith('video/'); - const isAud = f.mime.startsWith('audio/'); - if (isImg || isVid || isAud || f.mime.includes('pdf')) { + const kind = attachmentKind(f); + if (kind !== 'doc' || (f.mime || '').includes('pdf')) { let el; - if (isImg) { el = document.createElement('img'); el.src = f.url; el.alt = f.name; } - else if (isVid) { el = document.createElement('video'); el.src = f.url; el.controls = true; } - else if (isAud) { el = document.createElement('audio'); el.src = f.url; el.controls = true; } + if (kind === 'img') { el = document.createElement('img'); el.src = f.url; el.alt = f.name; } + else if (kind === 'vid') { el = document.createElement('video'); el.src = f.url; el.controls = true; } + else if (kind === 'aud') { el = document.createElement('audio'); el.src = f.url; el.controls = true; } else { el = document.createElement('iframe'); el.src = f.url; el.title = f.name; } F.lbBody.appendChild(el); } else {