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
@@ -301,6 +301,55 @@ No actix-web, o `fs::Files` já define MIME por extensão, mas registre antes:
|
||||
"application/manifest+json")` e um `wrap_fn` que sobrepõe `Cache-Control` quando o caminho for
|
||||
`/sw.js`. (No `tower-http` `SetResponseHeaderLayer` com `Match` por path é o caminho limpo.)
|
||||
|
||||
## Gerenciador de arquivos (anexos)
|
||||
|
||||
Árvore de pastas estilo sistema de arquivos + arquivos com **associação opcional
|
||||
a uma nota** (sem nota = avulso). Abra com **Ctrl+J**, menu **Arquivo →
|
||||
Gerenciador de arquivos…** ou o botão **📎 Anexo** (modo seletor: clicar num
|
||||
arquivo insere no editor e vincula à nota).
|
||||
|
||||
- **Persistência (disc)**: `data/folders.json` (árvore `parent`), `data/files.json`
|
||||
(metadados: nome, pasta, nota, tamanho, MIME, SHA-256) e blobs em
|
||||
`data/files/<id>.<ext>` — a extensão física de tipos perigosos (html/js/svg) é
|
||||
neutralizada (`.bin`/`.img`); o MIME real só vive no metadado.
|
||||
- **API**:
|
||||
- `POST /api/files/upload` — multipart (`file`, opcionais `folder_id`, `note_id`) → 201 com `url`
|
||||
- `GET /api/files/tree` — hierarquia JSON (pastas aninhadas + arquivos + raiz)
|
||||
- `GET /api/files/static/{id}` — serve com MIME real, **ETag (SHA-256) e Range**
|
||||
(vídeos fazem seek), `CSP: sandbox` + `nosniff`; tipos não-inline-seguros
|
||||
(html/svg/js…) vão como **`Content-Disposition: attachment`** — anexar um
|
||||
`.html` malicioso **não executa same-origin** (stored-XSS mitigado)
|
||||
- `PUT /api/files/{id}/move` — `{folder, note, name}` (mover/associar/desvincular/renomear)
|
||||
- `DELETE /api/files/{id}` · `POST/PUT/DELETE /api/folders[/{id}]` (com anti-ciclo)
|
||||
- Limite: 64 MiB/arquivo; nomes sanitizados (`filepath.Base` + whitelist); ids
|
||||
gerados — path do disco nunca aparece na URL.
|
||||
- **Editor**: colar (Ctrl+V) imagem/arquivo do clipboard faz upload automático e
|
||||
insere `` / `![]()` / link conforme o formato; arrastar-e-soltar
|
||||
funciona no editor e no gerenciador; no WYSIWYG a inserção é visual (`<img>`/
|
||||
`<video>`) e volta a Markdown na serialização; preview no gerenciador abre
|
||||
lightbox com `<img>`/`<video>`/`<audio>`/PDF (iframe) ou texto puro.
|
||||
|
||||
### Instruções de teste
|
||||
|
||||
```bash
|
||||
# 1) subir o servidor e criar uma nota pela UI (ou curl)
|
||||
export T=seu-token; B=http://127.0.0.1:8080
|
||||
# 2) upload avulso e em pasta
|
||||
FID=$(curl -s -X POST $B/api/files/folders -H "Authorization: Bearer $T" \
|
||||
-H 'Content-Type: application/json' -d '{"name":"docs","parent":""}' | jq -r .id)
|
||||
curl -s -X POST $B/api/files/upload -H "Authorization: Bearer $T" \
|
||||
-F file=@foto.png -F folder_id=$FID | jq .
|
||||
# 3) árvore + estático com Range/headers
|
||||
curl -s $B/api/files/tree -H "Authorization: Bearer $T" | jq
|
||||
curl -sI -H "Authorization: Bearer $T" -H 'Range: bytes=0-9' $B/api/files/static/<id>
|
||||
# 4) XSS guard: anexar .html deve vir attachment
|
||||
printf '<script>alert(1)</script>' > x.html
|
||||
curl -s -X POST $B/api/files/upload -H "Authorization: Bearer $T" -F file=@x.html | jq -r .id
|
||||
curl -sI -H "Authorization: Bearer $T" $B/api/files/static/<id> | grep -i disposition
|
||||
# 5) UI: Ctrl+J abre o gerenciador; cole uma imagem (Ctrl+V) no editor e arraste
|
||||
# arquivos para dentro dele; use 📎 para inserir um arquivo existente.
|
||||
```
|
||||
|
||||
## Como funciona o versionamento
|
||||
|
||||
- O conteúdo é guardado em `data/blobs/<aa>/<hash-sha256>` (content-addressed, como objetos do
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+119
@@ -1800,3 +1800,122 @@ html[data-theme="carbon-paper"] #notes-list li.active,
|
||||
html[data-theme="carbon-paper"] #revisions-list li.active { background: #c2ccd5; }
|
||||
html[data-theme="carbon-paper"] #notes-list li:hover,
|
||||
html[data-theme="carbon-paper"] #revisions-list li:hover { background: #e6e5dd; }
|
||||
|
||||
/* ------------------------------------------------- gerenciador de arquivos */
|
||||
.files-overlay {
|
||||
position: fixed;
|
||||
inset: 4vh 3vw;
|
||||
z-index: 95;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, .35);
|
||||
overflow: hidden;
|
||||
}
|
||||
.files-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.files-title { font-weight: 700; font-size: 15px; margin-right: 6px; }
|
||||
.files-filters { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
#files-search {
|
||||
flex: 1 1 120px;
|
||||
max-width: 220px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
}
|
||||
.files-body { display: flex; flex: 1 1 auto; min-height: 0; }
|
||||
.files-tree {
|
||||
width: 264px;
|
||||
flex: none;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
padding: 8px 6px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.ftree-acts { display: none; gap: 2px; }
|
||||
.ftree-row:hover .ftree-acts, .ftree-row.active .ftree-acts { display: inline-flex; }
|
||||
.ftree-act {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ftree-act:hover { background: var(--accent); color: #fff; }
|
||||
.files-list {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.files-empty { padding: 24px 12px; text-align: center; }
|
||||
.fitem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
|
||||
cursor: pointer;
|
||||
transition: transform .1s ease, box-shadow .12s ease, border-color .12s ease;
|
||||
}
|
||||
.fitem:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: 0 4px 14px -4px rgba(0, 0, 0, .2); }
|
||||
.fitem:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.fitem-icon { font-size: 22px; flex: none; }
|
||||
.fitem-main { flex: 1 1 auto; min-width: 0; }
|
||||
.fitem-name { font-weight: 600; font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fitem-meta { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fitem-acts { display: flex; gap: 3px; flex: none; opacity: 0; transition: opacity .12s ease; }
|
||||
.fitem:hover .fitem-acts, .fitem:focus-within .fitem-acts { opacity: 1; }
|
||||
.dropzone.drop-hl, #editor-host.drop-hl {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -4px;
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
}
|
||||
#editor-host { position: relative; }
|
||||
|
||||
/* lightbox */
|
||||
.lightbox-box {
|
||||
width: min(940px, 94vw);
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
.lightbox-body { flex: 1 1 auto; min-height: 0; overflow: auto; display: flex; justify-content: center; }
|
||||
.lightbox-body img { max-width: 100%; max-height: 74vh; border-radius: 8px; }
|
||||
.lightbox-body video { width: 100%; max-height: 74vh; }
|
||||
.lightbox-body audio { width: 100%; }
|
||||
.lightbox-body iframe { width: 100%; height: 70vh; border: 1px solid var(--border-soft); border-radius: 8px; }
|
||||
.lightbox-text { width: 100%; max-height: 66vh; overflow: auto; background: var(--panel-2); border-radius: 8px; padding: 12px; font-size: 12.5px; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.files-overlay { inset: 0; border-radius: 0; }
|
||||
.files-tree { width: 172px; }
|
||||
.fitem-acts { opacity: 1; flex-wrap: wrap; justify-content: flex-end; max-width: 40%; }
|
||||
.ftree-acts { display: inline-flex; }
|
||||
}
|
||||
@@ -113,6 +113,36 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Gerenciador de arquivos / anexos -->
|
||||
<div id="files-overlay" class="files-overlay hidden">
|
||||
<div class="files-head">
|
||||
<span class="files-title">Arquivos</span>
|
||||
<div id="files-filters" class="files-filters"></div>
|
||||
<input id="files-search" type="search" placeholder="Buscar…" autocomplete="off">
|
||||
<button type="button" class="btn btn-primary btn-small" id="files-upload-btn">⬆ Enviar</button>
|
||||
<button type="button" class="btn btn-small" id="files-newfolder">+ Pasta</button>
|
||||
<button type="button" class="btn btn-icon btn-small" id="files-close" title="Fechar">✕</button>
|
||||
</div>
|
||||
<div class="files-body">
|
||||
<aside id="files-tree" class="files-tree"></aside>
|
||||
<section id="files-list" class="files-list dropzone">
|
||||
<div id="files-empty" class="files-empty muted">Solte arquivos aqui ou use “⬆ Enviar”.</div>
|
||||
</section>
|
||||
</div>
|
||||
<input type="file" id="files-input" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- Lightbox de preview -->
|
||||
<div id="lightbox" class="overlay hidden">
|
||||
<div class="lightbox-box">
|
||||
<div id="lightbox-body" class="lightbox-body"></div>
|
||||
<div class="dialog-actions">
|
||||
<a id="lightbox-dl" class="btn btn-primary" download>Baixar</a>
|
||||
<button type="button" id="lightbox-close" class="btn">Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal genérico (Sobre / confirmar) -->
|
||||
<div id="modal-overlay" class="overlay hidden">
|
||||
<div class="dialog">
|
||||
@@ -146,6 +176,7 @@
|
||||
<li data-action="new-note">Nova nota<span class="accel">Ctrl+N</span></li>
|
||||
<li data-action="save">Salvar<span class="accel">Ctrl+S</span></li>
|
||||
<li data-action="export">Baixar arquivo (.txt/.md/.html)…</li>
|
||||
<li data-action="files">Gerenciador de arquivos…<span class="accel">Ctrl+J</span></li>
|
||||
<li class="menu-sep"></li>
|
||||
<li data-action="close-tab">Fechar aba<span class="accel">Alt+W</span></li>
|
||||
<li data-action="delete-note">Excluir esta nota…</li>
|
||||
@@ -192,6 +223,7 @@
|
||||
<button type="button" class="btn btn-icon" id="btn-new" title="Nova nota (Ctrl+N)">✚<span class="tb-label">Nova</span></button>
|
||||
<button type="button" class="btn btn-icon" id="btn-save" title="Salvar (Ctrl+S)">💾<span class="tb-label">Salvar</span></button>
|
||||
<button type="button" class="btn btn-icon" id="btn-export" title="Baixar arquivo">⬇<span class="tb-label">Baixar</span></button>
|
||||
<button type="button" class="btn btn-icon" id="btn-attach" title="Inserir anexo (📎)">📎<span class="tb-label">Anexo</span></button>
|
||||
</div>
|
||||
<span class="tb-sep"></span>
|
||||
<div class="tb-group" id="fmt-tools">
|
||||
|
||||
+449
@@ -1439,6 +1439,8 @@
|
||||
persistTabs();
|
||||
});
|
||||
E.wysiwyg.addEventListener('paste', (e) => {
|
||||
const files = e.clipboardData && e.clipboardData.files;
|
||||
if (files && files.length) { e.preventDefault(); handleFiles(files, 'editor'); return; }
|
||||
e.preventDefault(); // sempre texto puro: nada de HTML grudento de fora
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
E.wysiwyg.focus();
|
||||
@@ -1785,6 +1787,7 @@
|
||||
'new-note': () => newDraftTab(),
|
||||
'save': () => save(),
|
||||
'export': exportNote,
|
||||
'files': () => openFiles(false),
|
||||
'close-tab': () => closeTab(state.activeKey),
|
||||
'delete-note': deleteNote,
|
||||
'undo': () => { E.editor.focus(); document.execCommand('undo'); const t = activeTab(); if (t) t.content = E.editor.value; markDirty(); scheduleRender(); updateStatus(); },
|
||||
@@ -1835,6 +1838,7 @@
|
||||
E.btnTabNew.addEventListener('click', () => newDraftTab());
|
||||
E.btnSave.addEventListener('click', () => save());
|
||||
E.btnExport.addEventListener('click', exportNote);
|
||||
$('#btn-attach').addEventListener('click', () => openFiles(true));
|
||||
E.btnVersions.addEventListener('click', () => toggleVersionsPanel());
|
||||
E.btnSidebar.addEventListener('click', toggleSidebar);
|
||||
|
||||
@@ -1858,6 +1862,7 @@
|
||||
case 'r': e.preventDefault(); setMode('preview'); break;
|
||||
case 'h': e.preventDefault(); toggleVersionsPanel(); break;
|
||||
case 'm': e.preventDefault(); cycleMode(); break;
|
||||
case 'j': e.preventDefault(); openFiles(false); break;
|
||||
case ',': e.preventDefault(); openPrefs(); break;
|
||||
case 'b': if (e.target === E.editor) { e.preventDefault(); applyFmt('bold'); } break;
|
||||
case 'i': if (e.target === E.editor) { e.preventDefault(); applyFmt('italic'); } break;
|
||||
@@ -1953,6 +1958,450 @@
|
||||
E.authorInput.addEventListener('change', () =>
|
||||
localStorage.setItem('notas_author', E.authorInput.value.trim()));
|
||||
|
||||
/* ------------------------------------------------ gerenciador de arquivos */
|
||||
|
||||
const F = {
|
||||
overlay: $('#files-overlay'), tree: $('#files-tree'), list: $('#files-list'),
|
||||
empty: $('#files-empty'), filters: $('#files-filters'), search: $('#files-search'),
|
||||
input: $('#files-input'), uploadBtn: $('#files-upload-btn'), newFolderBtn: $('#files-newfolder'),
|
||||
closeBtn: $('#files-close'), lightbox: $('#lightbox'), lbBody: $('#lightbox-body'),
|
||||
lbDl: $('#lightbox-dl'), lbClose: $('#lightbox-close'),
|
||||
};
|
||||
|
||||
const filesState = {
|
||||
tree: [], rootFiles: [], folders: new Map(), files: new Map(),
|
||||
folder: '', // '' = raiz | '__all__' = todos | id
|
||||
filter: 'all', // all | attach | loose | note
|
||||
search: '',
|
||||
picker: false,
|
||||
expanded: new Set(JSON.parse(localStorage.getItem('notas_files_expanded') || '[]')),
|
||||
};
|
||||
|
||||
const iconFor = (mime) => mime == null ? '📄'
|
||||
: mime.startsWith('image/') ? '🖼️'
|
||||
: mime.startsWith('video/') ? '🎬'
|
||||
: mime.startsWith('audio/') ? '🎵'
|
||||
: mime.includes('pdf') ? '📕'
|
||||
: mime.includes('zip') || mime.includes('compressed') ? '🗜️'
|
||||
: mime.startsWith('text/') ? '📝' : '📄';
|
||||
|
||||
const fmtBytes = (n) => n < 1024 ? n + ' B'
|
||||
: n < 1048576 ? (n / 1024).toFixed(1) + ' KiB' : (n / 1048576).toFixed(1) + ' MiB';
|
||||
|
||||
async function loadFiles() {
|
||||
const d = await api('GET', '/api/files/tree');
|
||||
filesState.tree = d.tree || [];
|
||||
filesState.rootFiles = d.rootFiles || [];
|
||||
filesState.folders = new Map();
|
||||
filesState.files = new Map();
|
||||
(function walk(nodes) {
|
||||
for (const n of nodes) {
|
||||
filesState.folders.set(n.id, n);
|
||||
(n.files || []).forEach((f) => filesState.files.set(f.id, f));
|
||||
walk(n.children || []);
|
||||
}
|
||||
})(filesState.tree);
|
||||
filesState.rootFiles.forEach((f) => filesState.files.set(f.id, f));
|
||||
renderFilesUI();
|
||||
}
|
||||
|
||||
function filesInScope() {
|
||||
let arr;
|
||||
if (filesState.folder === '__all__') arr = [...filesState.files.values()];
|
||||
else if (filesState.folder === '') arr = filesState.rootFiles.slice();
|
||||
else {
|
||||
const n = filesState.folders.get(filesState.folder);
|
||||
arr = n ? (n.files || []).slice() : [];
|
||||
}
|
||||
const t = activeTab();
|
||||
if (filesState.filter === 'attach') arr = arr.filter((f) => f.note);
|
||||
else if (filesState.filter === 'loose') arr = arr.filter((f) => !f.note);
|
||||
else if (filesState.filter === 'note') arr = [...filesState.files.values()].filter((f) => t && f.note === t.id);
|
||||
if (filesState.search) {
|
||||
const q = filesState.search.toLowerCase();
|
||||
arr = arr.filter((f) => f.name.toLowerCase().includes(q));
|
||||
}
|
||||
return arr.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
}
|
||||
|
||||
function renderFilesUI() {
|
||||
renderFilesFilters();
|
||||
renderFilesTree();
|
||||
renderFilesList();
|
||||
}
|
||||
|
||||
function renderFilesFilters() {
|
||||
F.filters.textContent = '';
|
||||
const mk = (value, label) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'tag-chip' + (filesState.filter === value ? ' active' : '');
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', () => { filesState.filter = value; renderFilesUI(); });
|
||||
F.filters.appendChild(b);
|
||||
};
|
||||
mk('all', 'Todos');
|
||||
mk('attach', 'Anexos');
|
||||
mk('loose', 'Avulsos');
|
||||
mk('note', '📌 Nota atual');
|
||||
}
|
||||
|
||||
function folderRow(node, depth) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tree-row ftree-row' + (filesState.folder === node.id ? ' active' : '');
|
||||
row.style.paddingLeft = (8 + depth * 14) + 'px';
|
||||
|
||||
const caret = document.createElement('button');
|
||||
caret.type = 'button';
|
||||
caret.className = 'tree-caret';
|
||||
const hasKids = (node.children || []).length > 0;
|
||||
caret.textContent = hasKids ? (filesState.expanded.has(node.id) ? '▾' : '▸') : '·';
|
||||
caret.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasKids) return;
|
||||
if (filesState.expanded.has(node.id)) filesState.expanded.delete(node.id);
|
||||
else filesState.expanded.add(node.id);
|
||||
localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded]));
|
||||
renderFilesTree();
|
||||
});
|
||||
row.appendChild(caret);
|
||||
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'tree-name';
|
||||
nm.textContent = '📁 ' + node.name;
|
||||
row.appendChild(nm);
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'tree-count';
|
||||
count.textContent = String((node.files || []).length);
|
||||
row.appendChild(count);
|
||||
|
||||
const acts = document.createElement('span');
|
||||
acts.className = 'ftree-acts';
|
||||
const mkAct = (label, title, fn) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'ftree-act';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
|
||||
acts.appendChild(b);
|
||||
};
|
||||
mkAct('+', 'Nova subpasta', () => createFolder(node.id));
|
||||
mkAct('✎', 'Renomear', async () => {
|
||||
const name = window.prompt('Novo nome da pasta:', node.name);
|
||||
if (!name || !name.trim() || name.trim() === node.name) return;
|
||||
try { await api('PUT', '/api/folders/' + node.id, { name: name.trim() }); await loadFiles(); }
|
||||
catch (err) { toast('Renomear pasta: ' + err.message); }
|
||||
});
|
||||
mkAct('✕', 'Excluir pasta', async () => {
|
||||
const ok = await confirmModal('Excluir pasta?',
|
||||
`<b>${esc(node.name)}</b>, suas subpastas e TODOS os arquivos dentro serão apagados (inclusive anexos usados em notas).`, 'Excluir');
|
||||
if (!ok) return;
|
||||
try { await api('DELETE', '/api/folders/' + node.id); await loadFiles(); toast('Pasta excluída.'); }
|
||||
catch (err) { toast('Excluir pasta: ' + err.message); }
|
||||
});
|
||||
row.appendChild(acts);
|
||||
|
||||
row.addEventListener('click', () => { filesState.folder = node.id; renderFilesUI(); });
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFilesTree() {
|
||||
F.tree.textContent = '';
|
||||
const special = (value, label) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tree-row' + (filesState.folder === value ? ' active' : '');
|
||||
row.style.paddingLeft = '8px';
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'tree-name';
|
||||
nm.textContent = label;
|
||||
row.appendChild(nm);
|
||||
row.addEventListener('click', () => { filesState.folder = value; renderFilesUI(); });
|
||||
F.tree.appendChild(row);
|
||||
};
|
||||
special('__all__', '🗂 Todos os arquivos');
|
||||
special('', '🏠 Raiz');
|
||||
(function walk(nodes, depth) {
|
||||
for (const n of nodes) {
|
||||
F.tree.appendChild(folderRow(n, depth));
|
||||
if (filesState.expanded.has(n.id)) walk(n.children || [], depth + 1);
|
||||
}
|
||||
})(filesState.tree, 0);
|
||||
}
|
||||
|
||||
function fileRow(f) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'fitem' + (filesState.picker ? ' pickable' : '');
|
||||
row.tabIndex = 0;
|
||||
|
||||
const ic = document.createElement('span');
|
||||
ic.className = 'fitem-icon';
|
||||
ic.textContent = iconFor(f.mime);
|
||||
row.appendChild(ic);
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'fitem-main';
|
||||
const nm = document.createElement('div');
|
||||
nm.className = 'fitem-name';
|
||||
nm.textContent = f.name;
|
||||
main.appendChild(nm);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'fitem-meta';
|
||||
const bits = [fmtBytes(f.size), f.mime, relTime(f.created_at)];
|
||||
if (f.note) {
|
||||
const tn = state.notes.find((n) => n.id === f.note);
|
||||
bits.push('📌 ' + (tn ? tn.title || 'Sem título' : 'nota?'));
|
||||
}
|
||||
meta.textContent = bits.join(' · ');
|
||||
main.appendChild(meta);
|
||||
row.appendChild(main);
|
||||
|
||||
const acts = document.createElement('div');
|
||||
acts.className = 'fitem-acts';
|
||||
const mkAct = (label, title, fn) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn btn-small';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
|
||||
acts.appendChild(b);
|
||||
};
|
||||
mkAct('👁', 'Visualizar', () => previewFile(f));
|
||||
if (!filesState.picker) {
|
||||
mkAct('📌', 'Associar à nota atual', async () => {
|
||||
const t = activeTab();
|
||||
if (!t || !t.id) { toast('Salve a nota primeiro para associar anexos.'); return; }
|
||||
try { await api('PUT', '/api/files/' + f.id + '/move', { note: t.id }); await loadFiles(); }
|
||||
catch (err) { toast('Associar: ' + err.message); }
|
||||
});
|
||||
if (f.note) {
|
||||
mkAct('📎-', 'Desvincular da nota', async () => {
|
||||
try { await api('PUT', '/api/files/' + f.id + '/move', { note: '' }); await loadFiles(); }
|
||||
catch (err) { toast('Desvincular: ' + err.message); }
|
||||
});
|
||||
}
|
||||
mkAct('📂', 'Mover de pasta', async () => {
|
||||
const opts = ['(nenhuma mudança)'];
|
||||
const lines = ['0: Raiz'];
|
||||
let i = 1;
|
||||
(function walk(nodes) {
|
||||
for (const n of nodes) { lines.push(i + ': ' + n.name); opts.push(n.id); i++; walk(n.children || []); }
|
||||
})(filesState.tree);
|
||||
const ans = window.prompt('Mover para:\n' + lines.join('\n'), '0');
|
||||
if (ans == null) return;
|
||||
const idx = parseInt(ans, 10);
|
||||
if (isNaN(idx) || idx < 0 || idx >= opts.length) return;
|
||||
try { await api('PUT', '/api/files/' + f.id + '/move', { folder: opts[idx] || '' }); await loadFiles(); }
|
||||
catch (err) { toast('Mover: ' + err.message); }
|
||||
});
|
||||
mkAct('✎', 'Renomear', async () => {
|
||||
const name = window.prompt('Novo nome:', f.name);
|
||||
if (!name || !name.trim() || name.trim() === f.name) return;
|
||||
try { await api('PUT', '/api/files/' + f.id + '/move', { name: name.trim() }); await loadFiles(); }
|
||||
catch (err) { toast('Renomear: ' + err.message); }
|
||||
});
|
||||
mkAct('✕', 'Excluir arquivo', async () => {
|
||||
const ok = await confirmModal('Excluir arquivo?', `<b>${esc(f.name)}</b> será apagado permanentemente.`, 'Excluir');
|
||||
if (!ok) return;
|
||||
try { await api('DELETE', '/api/files/' + f.id); await loadFiles(); toast('Arquivo excluído.'); }
|
||||
catch (err) { toast('Excluir: ' + err.message); }
|
||||
});
|
||||
}
|
||||
|
||||
row.appendChild(acts);
|
||||
row.addEventListener('click', () => (filesState.picker ? pickFile(f) : previewFile(f)));
|
||||
row.addEventListener('keydown', (e) => { if (e.key === 'Enter') filesState.picker ? pickFile(f) : previewFile(f); });
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderFilesList() {
|
||||
F.list.textContent = '';
|
||||
const arr = filesInScope();
|
||||
if (!arr.length) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'files-empty muted';
|
||||
d.textContent = filesState.picker
|
||||
? 'Nenhum arquivo aqui — envie um ou navegue pelas pastas.'
|
||||
: 'Solte arquivos aqui ou use “⬆ Enviar”.';
|
||||
F.list.appendChild(d);
|
||||
return;
|
||||
}
|
||||
for (const f of arr) F.list.appendChild(fileRow(f));
|
||||
}
|
||||
|
||||
async function createFolder(parent) {
|
||||
const name = window.prompt(parent ? 'Nome da subpasta:' : 'Nome da nova pasta:');
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
await api('POST', '/api/folders', { name: name.trim(), parent: parent || '' });
|
||||
if (parent) filesState.expanded.add(parent);
|
||||
localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded]));
|
||||
await loadFiles();
|
||||
} catch (err) { toast('Criar pasta: ' + err.message); }
|
||||
}
|
||||
|
||||
async function uploadOne(file, folder, note) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file, file.name);
|
||||
if (folder) fd.append('folder_id', folder);
|
||||
if (note) fd.append('note_id', note);
|
||||
const resp = await fetch('/api/files/upload', { method: 'POST', body: fd });
|
||||
if (resp.status === 401) { showLogin(); throw new ApiError('não autenticado', 401); }
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) { const e = new ApiError(data.error || resp.statusText, resp.status); e.data = data; throw e; }
|
||||
return data;
|
||||
}
|
||||
|
||||
async function handleFiles(fileList, target) {
|
||||
const files = [...fileList];
|
||||
if (!files.length) return;
|
||||
const t = activeTab();
|
||||
const note = t && t.id ? t.id : '';
|
||||
const folder = target === 'manager' ? (filesState.folder === '__all__' ? '' : filesState.folder) : '';
|
||||
toast(`Enviando ${files.length} arquivo(s)…`);
|
||||
const done = [];
|
||||
for (const file of files) {
|
||||
try { done.push(await uploadOne(file, folder, note)); }
|
||||
catch (err) { toast(`Falha em ${file.name}: ${err.message}`); }
|
||||
}
|
||||
if (target === 'manager') { await loadFiles(); toast(`${done.length} arquivo(s) no gerenciador.`); return; }
|
||||
for (const f of done) insertAttachment(f);
|
||||
await loadFiles().catch(() => {});
|
||||
if (done.length) toast(done.length === files.length
|
||||
? 'Anexo(s) inserido(s) no editor' : `Inseridos ${done.length}/${files.length}`);
|
||||
}
|
||||
|
||||
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})`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
return `<a href="${f.url}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
|
||||
}
|
||||
|
||||
function insertAttachment(f) {
|
||||
const fmt = currentFormat();
|
||||
if (modeState.mode === 'wysiwyg') {
|
||||
E.wysiwyg.focus();
|
||||
document.execCommand('insertHTML', false, attachmentHtml(f));
|
||||
E.wysiwyg.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
if (modeState.mode === 'preview') setMode('edit');
|
||||
const snippet = attachmentSyntax(f, fmt);
|
||||
E.editor.focus();
|
||||
insertText(snippet);
|
||||
markDirty();
|
||||
scheduleAutosave();
|
||||
}
|
||||
|
||||
function pickFile(f) {
|
||||
insertAttachment(f);
|
||||
if (f.note !== (activeTab() && activeTab().id) && activeTab() && activeTab().id) {
|
||||
api('PUT', '/api/files/' + f.id + '/move', { note: activeTab().id }).catch(() => {});
|
||||
}
|
||||
closeFiles();
|
||||
}
|
||||
|
||||
function openFiles(picker) {
|
||||
filesState.picker = !!picker;
|
||||
F.overlay.classList.remove('hidden');
|
||||
F.overlay.setAttribute('aria-hidden', 'false');
|
||||
if (picker) filesState.filter = 'all';
|
||||
loadFiles().catch((err) => toast('Arquivos: ' + err.message));
|
||||
}
|
||||
function closeFiles() {
|
||||
filesState.picker = false;
|
||||
F.overlay.classList.add('hidden');
|
||||
F.overlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
async function previewFile(f) {
|
||||
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')) {
|
||||
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; }
|
||||
else { el = document.createElement('iframe'); el.src = f.url; el.title = f.name; }
|
||||
F.lbBody.appendChild(el);
|
||||
} else {
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'lightbox-text';
|
||||
pre.textContent = 'carregando…';
|
||||
F.lbBody.appendChild(pre);
|
||||
try {
|
||||
const r = await fetch(f.url);
|
||||
pre.textContent = (await r.text()).slice(0, 200000);
|
||||
} catch { pre.textContent = '(sem conexão para pré-visualizar)'; }
|
||||
}
|
||||
F.lightbox.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// wiring de eventos do gerenciador
|
||||
F.closeBtn.addEventListener('click', closeFiles);
|
||||
F.lbClose.addEventListener('click', () => F.lightbox.classList.add('hidden'));
|
||||
F.lightbox.addEventListener('click', (e) => { if (e.target === F.lightbox) F.lightbox.classList.add('hidden'); });
|
||||
F.uploadBtn.addEventListener('click', () => F.input.click());
|
||||
F.input.addEventListener('change', () => { handleFiles(F.input.files, 'manager'); F.input.value = ''; });
|
||||
F.newFolderBtn.addEventListener('click', () =>
|
||||
createFolder(filesState.folder !== '__all__' ? filesState.folder : ''));
|
||||
F.search.addEventListener('input', () => { filesState.search = F.search.value.trim(); renderFilesList(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (!F.lightbox.classList.contains('hidden')) F.lightbox.classList.add('hidden');
|
||||
else if (!F.overlay.classList.contains('hidden')) closeFiles();
|
||||
}
|
||||
});
|
||||
// drag & drop no gerenciador
|
||||
['dragenter', 'dragover'].forEach((ev) => F.list.addEventListener(ev, (e) => {
|
||||
e.preventDefault(); F.list.classList.add('drop-hl');
|
||||
}));
|
||||
['dragleave', 'drop'].forEach((ev) => F.list.addEventListener(ev, (e) => {
|
||||
e.preventDefault(); F.list.classList.remove('drop-hl');
|
||||
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length) handleFiles(e.dataTransfer.files, 'manager');
|
||||
}));
|
||||
// drag & drop no editor (textarea + wysiwyg)
|
||||
const host = $('#editor-host');
|
||||
['dragenter', 'dragover'].forEach((ev) => host.addEventListener(ev, (e) => {
|
||||
if (e.dataTransfer && [...e.dataTransfer.types].includes('Files')) { e.preventDefault(); host.classList.add('drop-hl'); }
|
||||
}));
|
||||
['dragleave', 'drop'].forEach((ev) => host.addEventListener(ev, (e) => {
|
||||
host.classList.remove('drop-hl');
|
||||
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length) {
|
||||
e.preventDefault();
|
||||
handleFiles(e.dataTransfer.files, 'editor');
|
||||
}
|
||||
}));
|
||||
// paste com arquivos no textarea (o wysiwyg tem handler próprio que cai aqui)
|
||||
E.editor.addEventListener('paste', (e) => {
|
||||
const files = e.clipboardData && e.clipboardData.files;
|
||||
if (files && files.length) { e.preventDefault(); handleFiles(files, 'editor'); }
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ PWA */
|
||||
|
||||
const isStandalone = () =>
|
||||
|
||||
Reference in new issue
Block a user