feat(files): gerenciador de arquivos/anexos com árvore de pastas, upload, preview e picker

- store/files.go: pastas (parent, anti-ciclo), FileMeta (nota opcional = avulso),
  blobs com extensão neutralizada p/ tipos perigosos, sanitização de nome
- api/files.go: upload multipart (64MiB), tree, static com ETag/Range + CSP
  sandbox + attachment p/ html/svg/js (stored-XSS mitigado), move/rename/delete,
  CRUD de pastas
- web: overlay Ctrl+J / 📎 (modo seletor insere sintaxe md/html/txt e associa),
  filtros Todos/Anexos/Avulsos/Nota atual, busca, lightbox img/video/audio/pdf/texto,
  colar-e-arrastar no editor e no gerenciador, ações por card (preview/associar/
  mover/renomear/excluir), mobile-first (ações sempre visíveis)
- fix: api() já serializa o body — chamadas do gerenciador passavam JSON pré-string
  (dupla codificação); auth por cookie (sem Bearer manual no fetch)
- sw v10; testes store+api (traversal, ciclo, XSS headers, range); README
This commit is contained in:
lgcosta committed 2026-11-05 10:32:40 -03:00
1 parent ea6b3b915d
commit 119e6cefe9
10 files changed
+1605 -4

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
package api
import (
"net/http"
)
// ---------------------------------------------------------------------------
// Gerenciador de arquivos: upload, árvore, static seguro, mover/renomear/excluir
// ---------------------------------------------------------------------------
// handleFileUpload recebe multipart/form-data com campo "file" e parâmetros
// opcionais folder_id e note_id.
func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusBadRequest, "multipart inválido: "+err.Error())
return
}
defer r.MultipartForm.RemoveAll()
f, hdr, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, `campo "file" é obrigatório`)
return
}
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)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": meta.ID, "name": meta.Name, "folder": meta.Folder, "note": meta.Note,
"size": meta.Size, "mime": meta.MIME, "sha256": meta.SHA256,
"created_at": meta.CreatedAt, "url": "/api/files/static/" + meta.ID,
})
}
// handleFileTree devolve a hierarquia de pastas com arquivos + raiz solta.
func (s *Server) handleFileTree(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"tree": s.st.FileTree(),
"rootFiles": s.st.RootFiles(),
})
}
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)
}
}
func (s *Server) handleFileDelete(w http.ResponseWriter, r *http.Request) {
if err := s.st.DeleteFile(r.PathValue("id")); err != nil {
mapStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type fileMoveInput struct {
Folder *string `json:"folder"` // ausente = mantém; "" = raiz
Note *string `json:"note"` // ausente = mantém; "" = avulso
Name string `json:"name"` // opcional (renomear junto)
}
func (s *Server) handleFileMove(w http.ResponseWriter, r *http.Request) {
var in fileMoveInput
if r.ContentLength > 0 {
if err := decodeJSON(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)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": m.ID, "name": m.Name, "folder": m.Folder, "note": m.Note,
"size": m.Size, "mime": m.MIME, "sha256": m.SHA256,
"created_at": m.CreatedAt, "url": "/api/files/static/" + m.ID,
})
}
type folderInput struct {
Name string `json:"name"`
Parent *string `json:"parent"`
}
func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
var in folderInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
parent := ""
if in.Parent != nil {
parent = *in.Parent
}
f, err := s.st.CreateFolder(in.Name, parent)
if err != nil {
mapStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, f)
}
func (s *Server) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
var in folderInput
if err := decodeJSON(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)
return
}
writeJSON(w, http.StatusOK, f)
}
func (s *Server) handleFolderDelete(w http.ResponseWriter, r *http.Request) {
if err := s.st.DeleteFolder(r.PathValue("id")); err != nil {
mapStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+232
View File
@@ -0,0 +1,232 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// auth faz uma chamada autenticada via helper comum.
func auth(t *testing.T, srv *httptest.Server, method, path, body string) *http.Response {
return do(t, srv, method, path, body, testToken, "")
}
func uploadFile(t *testing.T, srv *httptest.Server, name string, content []byte, folderID, noteID string) map[string]any {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(content); err != nil {
t.Fatal(err)
}
if folderID != "" {
_ = mw.WriteField("folder_id", folderID)
}
if noteID != "" {
_ = mw.WriteField("note_id", noteID)
}
mw.Close()
req, _ := http.NewRequest("POST", srv.URL+"/api/files/upload", &buf)
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("upload %s: %s %s", name, resp.Status, body)
}
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return out
}
func TestFilesAPI(t *testing.T) {
srv := newTestServer(t)
// upload markdown avulso (raiz)
png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 600))
md := uploadFile(t, srv, "guia.md", []byte("# Guia\n\nconteudo"), "", "")
mdID := md["id"].(string)
if md["mime"] != "text/markdown; charset=utf-8" {
t.Errorf("mime md: %v", md["mime"])
}
if md["url"] != "/api/files/static/"+mdID {
t.Errorf("url: %v", md["url"])
}
// pasta + upload de imagem dentro dela
folder := auth(t, srv, "POST", "/api/folders", `{"name":"prints","parent":""}`)
var f map[string]any
decode(t, folder, &f)
fid := f["id"].(string)
img := uploadFile(t, srv, "foto.png", png, fid, "")
imgID := img["id"].(string)
if !strings.HasPrefix(img["mime"].(string), "image/") {
t.Errorf("mime img: %v", img["mime"])
}
if img["folder"] != fid {
t.Errorf("folder: %v", img["folder"])
}
// subpasta
sub := auth(t, srv, "POST", "/api/folders", `{"name":"2026","parent":"`+fid+`"}`)
var subm map[string]any
decode(t, sub, &subm)
subID := subm["id"].(string)
// ciclo: mover prints para dentro de 2026 → 400
cyc := auth(t, srv, "PUT", "/api/folders/"+fid, `{"name":"","parent":"`+subID+`"}`)
if cyc.StatusCode != http.StatusBadRequest {
t.Fatalf("ciclo deveria 400: %s", cyc.Status)
}
cyc.Body.Close()
// árvore hierárquica
treeResp := auth(t, srv, "GET", "/api/files/tree", "")
var tree struct {
Tree []struct {
ID string `json:"id"`
Name string `json:"name"`
Children []struct {
ID string `json:"id"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
} `json:"children"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
} `json:"tree"`
RootFiles []struct {
ID string `json:"id"`
} `json:"rootFiles"`
}
decode(t, treeResp, &tree)
if len(tree.Tree) != 1 || tree.Tree[0].Name != "prints" {
t.Fatalf("tree raiz: %+v", tree.Tree)
}
if len(tree.Tree[0].Files) != 1 || tree.Tree[0].Files[0].ID != imgID {
t.Errorf("files em prints: %+v", tree.Tree[0].Files)
}
if len(tree.Tree[0].Children) != 1 || tree.Tree[0].Children[0].ID != subID {
t.Errorf("children: %+v", tree.Tree[0].Children)
}
if len(tree.RootFiles) != 1 || tree.RootFiles[0].ID != mdID {
t.Errorf("rootFiles: %+v", tree.RootFiles)
}
// static: imagem inline com headers endurecidos
st := auth(t, srv, "GET", "/api/files/static/"+imgID, "")
if st.Header.Get("Content-Type") != "image/png" {
t.Errorf("content-type img: %q", st.Header.Get("Content-Type"))
}
if st.Header.Get("Content-Disposition") != "" {
t.Errorf("img não deveria ter Content-Disposition: %q", st.Header.Get("Content-Disposition"))
}
if st.Header.Get("Content-Security-Policy") != "sandbox" {
t.Error("CSP sandbox ausente")
}
body, _ := io.ReadAll(st.Body)
st.Body.Close()
if !bytes.Equal(body, png) {
t.Errorf("conteúdo png difere (%d bytes)", len(body))
}
// Range (preview parcial de vídeo)
rq, _ := http.NewRequest("GET", srv.URL+"/api/files/static/"+mdID, nil)
rq.Header.Set("Authorization", "Bearer "+testToken)
rq.Header.Set("Range", "bytes=0-4")
rr, err := srv.Client().Do(rq)
if err != nil {
t.Fatal(err)
}
if rr.StatusCode != http.StatusPartialContent {
t.Errorf("range: %s", rr.Status)
}
rbody, _ := io.ReadAll(rr.Body)
rr.Body.Close()
if string(rbody) != "# Gui" {
t.Errorf("range body: %q", rbody)
}
// XSS guard: .html anexado vira attachment (nunca roda same-origin)
h := uploadFile(t, srv, "malicioso.html", []byte("<script>alert(1)</script>"), "", "")
hs := auth(t, srv, "GET", "/api/files/static/"+h["id"].(string), "")
if !strings.Contains(hs.Header.Get("Content-Disposition"), "attachment") {
t.Errorf("html deveria ser attachment: %q", hs.Header.Get("Content-Disposition"))
}
hs.Body.Close()
if h["mime"] != "text/html; charset=utf-8" {
t.Errorf("mime html: %v", h["mime"])
}
// mover md para 2026 + associar a uma nota + renomear
noteResp := auth(t, srv, "POST", "/api/notes", `{"title":"Com anexo","format":"md","categories":[],"content":"oi","author":"","message":""}`)
var note map[string]any
decode(t, noteResp, &note)
mv := auth(t, srv, "PUT", "/api/files/"+mdID+"/move",
`{"folder":"`+subID+`","note":"`+note["id"].(string)+`","name":"guia-v2.md"}`)
if mv.StatusCode != http.StatusOK {
t.Fatalf("move: %s", mv.Status)
}
var mvd map[string]any
decode(t, mv, &mvd)
if mvd["folder"] != subID || mvd["note"] != note["id"].(string) || mvd["name"] != "guia-v2.md" {
t.Errorf("move result: %+v", mvd)
}
// nota fantasma na associação → 400
bad := auth(t, srv, "PUT", "/api/files/"+mdID+"/move", `{"note":"naoexiste1"}`)
if bad.StatusCode != http.StatusBadRequest {
t.Errorf("nota fantasma: %s", bad.Status)
}
bad.Body.Close()
// excluir pasta raiz remove subtree e arquivos (png + md movido)
del := auth(t, srv, "DELETE", "/api/folders/"+fid, "")
if del.StatusCode != http.StatusNoContent {
t.Fatalf("delete folder: %s", del.Status)
}
gone := auth(t, srv, "GET", "/api/files/static/"+imgID, "")
if gone.StatusCode != http.StatusNotFound {
t.Errorf("arquivo da pasta excluída deveria 404: %s", gone.Status)
}
gone.Body.Close()
// excluir arquivo avulso
del2 := auth(t, srv, "DELETE", "/api/files/"+h["id"].(string), "")
if del2.StatusCode != http.StatusNoContent {
t.Fatalf("delete file: %s", del2.Status)
}
finalTree := auth(t, srv, "GET", "/api/files/tree", "")
var ft map[string]any
decode(t, finalTree, &ft)
if len(ft["tree"].([]any)) != 0 || len(ft["rootFiles"].([]any)) != 0 {
t.Errorf("árvore final deveria estar vazia: %+v", ft)
}
// static inexistente → 404 (id nunca vira path; traversal impossível)
respBad := auth(t, srv, "GET", "/api/files/static/naoexiste1", "")
if respBad.StatusCode != http.StatusNotFound {
t.Errorf("static inexistente: %s", respBad.Status)
}
respBad.Body.Close()
// upload sem token → 401
noAuth := do(t, srv, "GET", "/api/files/tree", "", "", "")
if noAuth.StatusCode != http.StatusUnauthorized {
t.Errorf("tree sem auth: %s", noAuth.Status)
}
noAuth.Body.Close()
}
+10
View File
@@ -77,6 +77,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/events", s.auth(s.handleEvents))
mux.HandleFunc("POST /api/presence", s.auth(s.handlePresence))
// gerenciador de arquivos / anexos
mux.HandleFunc("POST /api/files/upload", s.auth(s.handleFileUpload))
mux.HandleFunc("GET /api/files/tree", s.auth(s.handleFileTree))
mux.HandleFunc("GET /api/files/static/{id}", s.auth(s.handleFileStatic))
mux.HandleFunc("DELETE /api/files/{id}", s.auth(s.handleFileDelete))
mux.HandleFunc("PUT /api/files/{id}/move", s.auth(s.handleFileMove))
mux.HandleFunc("POST /api/folders", s.auth(s.handleFolderCreate))
mux.HandleFunc("PUT /api/folders/{id}", s.auth(s.handleFolderUpdate))
mux.HandleFunc("DELETE /api/folders/{id}", s.auth(s.handleFolderDelete))
mux.HandleFunc("GET /{$}", s.serveIndex)
mux.Handle("GET /", s.staticHandler())
+577
View File
@@ -0,0 +1,577 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// Gerenciador de arquivos: árvore de pastas + arquivos com associação opcional
// a uma nota. Persistência: folders.json, files.json e blobs em files/<id>.<ext>.
// ---------------------------------------------------------------------------
// MaxFileBytes limita o tamanho de cada anexo (64 MiB).
const MaxFileBytes = 64 << 20
// Folder é um nó da árvore de pastas (parent "" = raiz).
type Folder struct {
ID string `json:"id"`
Name string `json:"name"`
Parent string `json:"parent"`
CreatedAt time.Time `json:"created_at"`
}
// FileMeta descreve um arquivo anexado ou avulso.
type FileMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Folder string `json:"folder"` // "" = raiz da árvore de arquivos
Note string `json:"note"` // "" = avulso (sem nota)
Size int64 `json:"size"`
MIME string `json:"mime"`
SHA256 string `json:"sha256"`
CreatedAt time.Time `json:"created_at"`
}
// FileNode é a visão de API (meta + URL para <img>/<video>/<a>).
type FileNode struct {
FileMeta
URL string `json:"url"`
}
// TreeNode é a hierarquia completa (pastas aninhadas + arquivos).
type TreeNode struct {
Folder
Children []*TreeNode `json:"children"`
Files []*FileNode `json:"files"`
}
var fileNameRe = regexp.MustCompile(`^[\p{L}\p{N} .,_\-()#]+$`)
func nodeFromFile(m FileMeta) *FileNode {
return &FileNode{FileMeta: m, URL: "/api/files/static/" + m.ID}
}
func (s *Store) foldersPath() string { return filepath.Join(s.dir, "folders.json") }
func (s *Store) filesJSONPath() string { return filepath.Join(s.dir, "files.json") }
func (s *Store) filesRoot() string { return filepath.Join(s.dir, "files") }
// loadFiles carrega os índices de pastas/arquivos (chamado por New).
func (s *Store) loadFiles() error {
s.folders = nil
s.fileMetas = nil
var f []Folder
if err := readJSON(s.foldersPath(), &f); err == nil {
s.folders = f
} else if !errors.Is(err, ErrNotFound) {
return err
}
var m []FileMeta
if err := readJSON(s.filesJSONPath(), &m); err == nil {
s.fileMetas = m
} else if !errors.Is(err, ErrNotFound) {
return err
}
return os.MkdirAll(s.filesRoot(), 0o750)
}
func (s *Store) saveFolders() error { return writeJSONAtomic(s.foldersPath(), s.folders) }
func (s *Store) saveFileMetas() error { return writeJSONAtomic(s.filesJSONPath(), s.fileMetas) }
func (s *Store) folderByID(id string) *Folder {
for i := range s.folders {
if s.folders[i].ID == id {
return &s.folders[i]
}
}
return nil
}
func (s *Store) fileByID(id string) *FileMeta {
for i := range s.fileMetas {
if s.fileMetas[i].ID == id {
return &s.fileMetas[i]
}
}
return nil
}
func (s *Store) folderSubtree(id string) []string {
out := []string{id}
for i := 0; i < len(out); i++ {
for _, f := range s.folders {
if f.Parent == out[i] {
out = append(out, f.ID)
}
}
}
return out
}
func cleanFileName(name string) (string, error) {
name = strings.TrimSpace(name)
name = filepath.Base(name) // nunca aceita path (anti-traversal no nome)
if name == "" || name == "." || name == ".." || len(name) > 200 || !fileNameRe.MatchString(name) {
return "", fmt.Errorf("%w: nome de arquivo inválido", ErrInvalid)
}
return name, nil
}
// extByMime normaliza o MIME detectado quando o sniffing é genérico.
func extByMime(name, sniffed string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".md", ".markdown":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/markdown; charset=utf-8"
}
case ".html", ".htm":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/html; charset=utf-8"
}
case ".css":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/css; charset=utf-8"
}
case ".js", ".mjs":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/javascript; charset=utf-8"
}
case ".json":
if strings.HasPrefix(sniffed, "text/plain") {
return "application/json"
}
case ".svg":
if strings.Contains(sniffed, "xml") || strings.HasPrefix(sniffed, "text/plain") {
return "image/svg+xml"
}
case ".webp":
if sniffed == "application/octet-stream" {
return "image/webp"
}
case ".mp3":
if sniffed == "application/octet-stream" {
return "audio/mpeg"
}
case ".mp4", ".m4v":
if sniffed == "application/octet-stream" {
return "video/mp4"
}
case ".flac":
if sniffed == "application/octet-stream" {
return "audio/flac"
}
case ".ogg":
if strings.HasPrefix(sniffed, "application/ogg") {
return "audio/ogg"
}
case ".txt", ".log":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/plain; charset=utf-8"
}
}
return sniffed
}
// safeExt decide a extensão física do blob. Extensões perigosas (html/svg/…)
// viram sufixo neutro: o MIME real só vive no metadado e é aplicado no serve.
func safeExt(name, mime string) string {
ext := strings.ToLower(filepath.Ext(name))
if len(ext) > 8 || !regexp.MustCompile(`^\.[a-z0-9]+$`).MatchString(ext) ||
ext == ".html" || ext == ".htm" || ext == ".xhtml" || ext == ".svg" || ext == ".js" || ext == ".mjs" {
switch {
case strings.HasPrefix(mime, "image/"):
return ".img"
case strings.HasPrefix(mime, "video/"):
return ".vid"
case strings.HasPrefix(mime, "audio/"):
return ".aud"
default:
return ".bin"
}
}
return ext
}
func (s *Store) fileBlobPath(id, ext string) string {
return filepath.Join(s.filesRoot(), id+ext)
}
// ---------------------------------------------------------------------------
// Pastas
// ---------------------------------------------------------------------------
// 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) {
return nil, fmt.Errorf("%w: nome de pasta inválido", ErrInvalid)
}
s.mu.Lock()
defer s.mu.Unlock()
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)
}
}
f := Folder{ID: newID(), Name: name, Parent: parent, CreatedAt: time.Now().UTC()}
s.folders = append(s.folders, f)
if err := s.saveFolders(); err != nil {
return nil, err
}
return &f, nil
}
// UpdateFolder renomeia/move pasta com checagem de ciclo.
func (s *Store) UpdateFolder(id, name string, parent *string) (*Folder, error) {
s.mu.Lock()
defer s.mu.Unlock()
f := s.folderByID(id)
if f == nil {
return nil, ErrNotFound
}
if parent != nil && *parent != f.Parent {
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)
}
}
f.Parent = *parent
}
if n := strings.TrimSpace(name); n != "" && n != f.Name {
if len([]rune(n)) > 100 || !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)
}
}
f.Name = n
}
if err := s.saveFolders(); err != nil {
return nil, err
}
cp := *f
return &cp, nil
}
// DeleteFolder remove a pasta e todo o subtree (incluindo arquivos).
func (s *Store) DeleteFolder(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.folderByID(id) == nil {
return ErrNotFound
}
sub := map[string]bool{}
for _, d := range s.folderSubtree(id) {
sub[d] = true
}
keptFolders := s.folders[:0]
for _, f := range s.folders {
if !sub[f.ID] {
keptFolders = append(keptFolders, f)
}
}
s.folders = keptFolders
keptFiles := s.fileMetas[:0]
for _, m := range s.fileMetas {
if sub[m.Folder] {
_ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME)))
continue
}
keptFiles = append(keptFiles, m)
}
s.fileMetas = keptFiles
if err := s.saveFolders(); err != nil {
return err
}
return s.saveFileMetas()
}
// ---------------------------------------------------------------------------
// Arquivos
// ---------------------------------------------------------------------------
// AddFile persiste um upload: valida nome/pasta/nota, calcula SHA-256 e MIME
// reais (content sniffing) e grava o blob atomicamente. Limite: MaxFileBytes.
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)
}
}
id := newID()
tmp := s.fileBlobPath(id, ".tmp")
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
fail := func(err error) (*FileMeta, error) {
f.Close()
os.Remove(tmp)
return nil, err
}
h := sha256.New()
head := make([]byte, 512)
n, err := io.ReadFull(r, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return fail(err)
}
head = head[:n]
if _, err := f.Write(head); err != nil {
return fail(err)
}
if _, err := h.Write(head); err != nil {
return fail(err)
}
rest, err := io.Copy(io.MultiWriter(f, h), r)
if err != nil {
return fail(err)
}
size := int64(n) + rest
if size > MaxFileBytes {
return fail(fmt.Errorf("%w: arquivo excede %d MiB", ErrTooLarge, MaxFileBytes>>20))
}
if err := f.Close(); err != nil {
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)
return nil, err
}
meta := FileMeta{
ID: id, Name: clean, Folder: folder, Note: note, Size: size,
MIME: mime, SHA256: hex.EncodeToString(h.Sum(nil)), CreatedAt: time.Now().UTC(),
}
s.fileMetas = append(s.fileMetas, meta)
if err := s.saveFileMetas(); err != nil {
return nil, err
}
return &meta, nil
}
// Files devolve todos os metadados (ordenados por data desc).
func (s *Store) Files() []FileMeta {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]FileMeta, len(s.fileMetas))
copy(out, s.fileMetas)
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// GetFile devolve o metadado.
func (s *Store) GetFile(id string) (*FileMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
m := s.fileByID(id)
if m == nil {
return nil, ErrNotFound
}
cp := *m
return &cp, nil
}
// MoveFile altera pasta/nome/associação de nota (nil = manter).
func (s *Store) MoveFile(id, name string, folder, note *string) (*FileMeta, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.fileByID(id)
if m == nil {
return nil, ErrNotFound
}
if folder != nil {
if *folder != "" && s.folderByID(*folder) == nil {
return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid)
}
m.Folder = *folder
}
if note != nil {
if *note != "" {
if _, err := s.get(*note); err != nil {
return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid)
}
}
m.Note = *note
}
if n := strings.TrimSpace(name); n != "" && n != m.Name {
clean, err := cleanFileName(n)
if err != nil {
return nil, err
}
m.Name = clean
}
if err := s.saveFileMetas(); err != nil {
return nil, err
}
cp := *m
return &cp, nil
}
// DeleteFile remove metadado + blob.
func (s *Store) DeleteFile(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.fileMetas {
if s.fileMetas[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return ErrNotFound
}
m := s.fileMetas[idx]
_ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME)))
s.fileMetas = append(s.fileMetas[:idx], s.fileMetas[idx+1:]...)
return s.saveFileMetas()
}
// FileTree monta a hierarquia de pastas com arquivos.
func (s *Store) FileTree() []*TreeNode {
s.mu.RLock()
defer s.mu.RUnlock()
nodes := map[string]*TreeNode{}
for _, f := range s.folders {
nodes[f.ID] = &TreeNode{Folder: f, Children: []*TreeNode{}, Files: []*FileNode{}}
}
var roots []*TreeNode
for _, f := range s.folders {
n := nodes[f.ID]
if p, ok := nodes[f.Parent]; ok && f.Parent != "" {
p.Children = append(p.Children, n)
} else {
roots = append(roots, n)
}
}
if roots == nil {
roots = []*TreeNode{}
}
rootFiles := []*FileNode{}
for _, m := range s.fileMetas {
n := nodeFromFile(m)
if t, ok := nodes[m.Folder]; ok && m.Folder != "" {
t.Files = append(t.Files, n)
} else {
rootFiles = append(rootFiles, n)
}
}
sort.Slice(roots, func(i, j int) bool { return roots[i].Name < roots[j].Name })
return roots
}
// RootFiles devolve os arquivos da raiz (folder "").
func (s *Store) RootFiles() []*FileNode {
s.mu.RLock()
defer s.mu.RUnlock()
out := []*FileNode{}
for _, m := range s.fileMetas {
if m.Folder == "" {
out = append(out, nodeFromFile(m))
}
}
return out
}
// inlineSafe diz se o tipo pode ser exibido inline com segurança. html/js/svg
// e afins viram download (attachment) — previne stored-XSS same-origin.
func inlineSafe(mime string) bool {
base := strings.ToLower(strings.TrimSpace(strings.Split(mime, ";")[0]))
switch {
case strings.HasPrefix(base, "image/"):
return base != "image/svg+xml"
case strings.HasPrefix(base, "video/"), strings.HasPrefix(base, "audio/"):
return true
case base == "text/plain", base == "text/markdown", base == "text/css",
base == "application/json", base == "application/pdf",
base == "text/csv":
return true
default:
return false
}
}
// ServeFile entrega o blob com headers endurecidos (MIME real, ETag, Range via
// ServeContent, CSP sandbox e attachment para tipos não-inline-seguros).
// A URL aceita apenas o id gerado — nada de path do disco.
func (s *Store) ServeFile(w http.ResponseWriter, r *http.Request, id string) error {
s.mu.RLock()
m := s.fileByID(id)
var p string
if m != nil {
p = s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))
}
cp := m
s.mu.RUnlock()
if cp == nil {
return ErrNotFound
}
f, err := os.Open(p)
if err != nil {
return ErrNotFound
}
defer f.Close()
w.Header().Set("Content-Security-Policy", "sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
if !inlineSafe(cp.MIME) {
w.Header().Set("Content-Disposition", `attachment; filename="`+sanitizeHeaderName(cp.Name)+`"`)
}
w.Header().Set("ETag", `"`+cp.SHA256+`"`)
http.ServeContent(w, r, cp.Name, cp.CreatedAt, f)
return nil
}
func sanitizeHeaderName(name string) string {
return strings.Map(func(r rune) rune {
switch r {
case '"', '\\', '\n', '\r', '/':
return '_'
}
return r
}, name)
}
// Folders devolve todas as pastas (lista plana; a UI monta a árvore).
func (s *Store) Folders() []Folder {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Folder, len(s.folders))
copy(out, s.folders)
return out
}
+8 -3
View File
@@ -132,9 +132,11 @@ type CategoryView struct {
// Store é seguro para concorrência.
type Store struct {
dir string
mu sync.RWMutex
cats []Category // cache da árvore; persistido em categories.json
dir string
mu sync.RWMutex
cats []Category // cache da árvore; persistido em categories.json
folders []Folder // árvore do gerenciador de arquivos (folders.json)
fileMetas []FileMeta // metadados de anexos (files.json)
}
// New abre (ou cria) o diretório raiz de dados e migra metadados legados
@@ -149,6 +151,9 @@ func New(dir string) (*Store, error) {
if err := s.loadCats(); err != nil {
return nil, err
}
if err := s.loadFiles(); err != nil {
return nil, err
}
if err := s.migrateLegacy(); err != nil {
return nil, err
}