refactor: integra reconcile no boot, dedup categoria/pasta, CI, Go 1.24
ci / check (push) Successful in 4m12s
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:
1 parent
0e59ef23f4
commit
e0bef77e31
16 files changed
+518
-126
No files matched your search
@@ -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
|
||||
@@ -381,6 +381,14 @@ curl -sI -H "Authorization: Bearer $T" $B/api/files/static/<id> | 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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/lgcosta/notas-online
|
||||
|
||||
go 1.23
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/hexops/gotextdiff v1.0.3
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
+149
-41
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
-17
@@ -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
|
||||
}
|
||||
|
||||
+20
-25
@@ -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 `<img src="${f.url}" alt="${esc(f.name)}">`;
|
||||
if (isVid) return `<video controls src="${f.url}"></video>`;
|
||||
if (isAud) return `<audio controls src="${f.url}"></audio>`;
|
||||
return `<a href="${f.url}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
|
||||
}
|
||||
if (isImg) return ``;
|
||||
return `[${(isVid ? '▶ ' : isAud ? ' ' : '') + f.name}](${f.url})`;
|
||||
const kind = attachmentKind(f);
|
||||
if (fmt === 'html') return attachmentHtml(f);
|
||||
if (kind === 'img') return ``;
|
||||
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 `<img src="${f.url}" alt="${esc(f.name)}">`;
|
||||
if (isVid) return `<video controls src="${f.url}"></video>`;
|
||||
if (isAud) return `<audio controls src="${f.url}"></audio>`;
|
||||
const kind = attachmentKind(f);
|
||||
if (kind === 'img') return `<img src="${f.url}" alt="${esc(f.name)}">`;
|
||||
if (kind === 'vid') return `<video controls src="${f.url}"></video>`;
|
||||
if (kind === 'aud') return `<audio controls src="${f.url}"></audio>`;
|
||||
return `<a href="${f.url}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user