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:
1 parent
ea6b3b915d
commit
119e6cefe9
10 files changed
+1605
-4
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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, ¬e)
|
||||
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()
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in new issue
Block a user