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

+149 -41
View File
@@ -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()