ci / check (push) Successful in 1m12s
Lista conteudo de .zip, .tar, .tar.gz e .gz sem extrair, com limite anti-bomba.
861 lines
24 KiB
Go
861 lines
24 KiB
Go
package store
|
||
|
||
import (
|
||
"archive/tar"
|
||
"archive/zip"
|
||
"compress/gzip"
|
||
"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
|
||
|
||
// MaxFilesTotalBytes limita o total de anexos (2 GiB). Sem quota agregada,
|
||
// uploads concorrentes (1000 × 64 MiB) esgotam o disco do host.
|
||
const MaxFilesTotalBytes = 2048 << 20
|
||
|
||
// maxFolderLen limita o nome de pastas (mesmo teto das categorias).
|
||
const maxFolderLen = 100
|
||
|
||
// Folder é um nó da árvore de pastas (parent "" = raiz).
|
||
type Folder struct {
|
||
ID string `json:"id"`
|
||
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"`
|
||
}
|
||
|
||
// ArchiveEntry descreve um item dentro de um arquivo compactado.
|
||
type ArchiveEntry struct {
|
||
Name string `json:"name"`
|
||
Size int64 `json:"size"` // -1 = tamanho desconhecido sem extrair
|
||
Dir bool `json:"dir"`
|
||
}
|
||
|
||
// maxArchiveEntries limita quantos itens da listagem são devolvidos.
|
||
const maxArchiveEntries = 2000
|
||
|
||
// maxArchiveScanBytes limita a leitura ao contar bytes descomprimidos de um
|
||
// .gz simples (proteção contra bomba de descompressão).
|
||
const maxArchiveScanBytes = 1 << 30
|
||
|
||
// isArchiveExt diz se o nome tem extensão de compactado com listagem
|
||
// suportada (zip/tar/tar.gz/tgz/gz, sem extrair nada em disco).
|
||
func isArchiveExt(name string) bool {
|
||
l := strings.ToLower(name)
|
||
return strings.HasSuffix(l, ".zip") || strings.HasSuffix(l, ".tar") ||
|
||
strings.HasSuffix(l, ".tar.gz") || strings.HasSuffix(l, ".tgz") ||
|
||
strings.HasSuffix(l, ".gz")
|
||
}
|
||
|
||
// cleanArchiveName higieniza um nome vindo de dentro do compactado para
|
||
// exibição (UTF-8 válido, tamanho limitado).
|
||
func cleanArchiveName(name string) string {
|
||
name = strings.ToValidUTF8(name, "�")
|
||
name = strings.Trim(name, "\x00")
|
||
if len(name) > 512 {
|
||
name = name[:512] + "…"
|
||
}
|
||
if name == "" {
|
||
name = "(sem nome)"
|
||
}
|
||
return name
|
||
}
|
||
|
||
// ListArchive lista o conteúdo de um compactado (.zip, .tar, .tar.gz, .tgz
|
||
// ou .gz simples) lendo só os cabeçalhos — nada é extraído em disco.
|
||
// Devolve os itens (no máximo maxArchiveEntries), o total e se houve corte.
|
||
func (s *Store) ListArchive(id string) (entries []ArchiveEntry, total int, truncated bool, err error) {
|
||
s.mu.RLock()
|
||
m := s.fileByID(id)
|
||
var p, name string
|
||
if m != nil {
|
||
name = m.Name
|
||
p = s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))
|
||
}
|
||
s.mu.RUnlock()
|
||
if m == nil {
|
||
return nil, 0, false, ErrNotFound
|
||
}
|
||
if !isArchiveExt(name) {
|
||
return nil, 0, false, fmt.Errorf("%w: listagem suportada apenas para .zip, .tar, .tar.gz e .gz", ErrInvalid)
|
||
}
|
||
f, err := os.Open(p)
|
||
if err != nil {
|
||
return nil, 0, false, ErrNotFound
|
||
}
|
||
defer f.Close()
|
||
lower := strings.ToLower(name)
|
||
switch {
|
||
case strings.HasSuffix(lower, ".zip"):
|
||
st, err := f.Stat()
|
||
if err != nil {
|
||
return nil, 0, false, ErrNotFound
|
||
}
|
||
return listZipEntries(f, st.Size())
|
||
case strings.HasSuffix(lower, ".tar"):
|
||
return listTarEntries(f)
|
||
default: // .tar.gz, .tgz ou .gz simples
|
||
single := !strings.Contains(lower, ".tar") && !strings.HasSuffix(lower, ".tgz")
|
||
return listTarGzEntries(f, single, strings.TrimSuffix(name, filepath.Ext(name)))
|
||
}
|
||
}
|
||
|
||
// listZipEntries lê o diretório central do zip (sem descomprimir).
|
||
func listZipEntries(r io.ReaderAt, size int64) ([]ArchiveEntry, int, bool, error) {
|
||
zr, err := zip.NewReader(r, size)
|
||
if err != nil {
|
||
return nil, 0, false, fmt.Errorf("%w: zip inválido ou corrompido", ErrInvalid)
|
||
}
|
||
total := len(zr.File)
|
||
out := make([]ArchiveEntry, 0, min(total, maxArchiveEntries))
|
||
for _, zf := range zr.File {
|
||
if len(out) >= maxArchiveEntries {
|
||
return out, total, true, nil
|
||
}
|
||
out = append(out, ArchiveEntry{
|
||
Name: cleanArchiveName(zf.Name),
|
||
Size: int64(zf.UncompressedSize64),
|
||
Dir: zf.FileInfo().IsDir(),
|
||
})
|
||
}
|
||
return out, total, false, nil
|
||
}
|
||
|
||
// listTarEntries percorre os cabeçalhos do tar (sem extrair).
|
||
func listTarEntries(r io.Reader) ([]ArchiveEntry, int, bool, error) {
|
||
tr := tar.NewReader(r)
|
||
var out []ArchiveEntry
|
||
total := 0
|
||
for {
|
||
hdr, err := tr.Next()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
if total == 0 {
|
||
return nil, 0, false, fmt.Errorf("%w: tar inválido ou corrompido", ErrInvalid)
|
||
}
|
||
return out, total, true, nil
|
||
}
|
||
total++
|
||
if len(out) < maxArchiveEntries {
|
||
out = append(out, ArchiveEntry{
|
||
Name: cleanArchiveName(hdr.Name),
|
||
Size: hdr.Size,
|
||
Dir: hdr.FileInfo().IsDir(),
|
||
})
|
||
}
|
||
}
|
||
if out == nil {
|
||
out = []ArchiveEntry{}
|
||
}
|
||
return out, total, total > len(out), nil
|
||
}
|
||
|
||
// listTarGzEntries abre um .tar.gz/.tgz (lista o tar interno) ou um .gz
|
||
// simples (descreve o arquivo único, contando bytes com teto anti-bomba).
|
||
func listTarGzEntries(r io.Reader, single bool, fallbackName string) ([]ArchiveEntry, int, bool, error) {
|
||
gz, err := gzip.NewReader(r)
|
||
if err != nil {
|
||
return nil, 0, false, fmt.Errorf("%w: gzip inválido ou corrompido", ErrInvalid)
|
||
}
|
||
defer gz.Close()
|
||
if !single {
|
||
return listTarEntries(gz)
|
||
}
|
||
name := strings.TrimSpace(gz.Name)
|
||
if name == "" {
|
||
name = fallbackName
|
||
}
|
||
n, err := io.Copy(io.Discard, io.LimitReader(gz, maxArchiveScanBytes+1))
|
||
if err != nil {
|
||
return nil, 0, false, fmt.Errorf("%w: gzip inválido ou corrompido", ErrInvalid)
|
||
}
|
||
size := n
|
||
truncated := false
|
||
if n > maxArchiveScanBytes {
|
||
size = maxArchiveScanBytes
|
||
truncated = true
|
||
}
|
||
return []ArchiveEntry{{Name: cleanArchiveName(name), Size: size}}, 1, truncated, nil
|
||
}
|
||
|
||
// 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, ok := validTreeName(name, maxFolderLen)
|
||
if !ok || !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)
|
||
}
|
||
if siblingDup(folderNodes(s.folders), "", parent, name) {
|
||
return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, name)
|
||
}
|
||
f := Folder{ID: newID(), Name: name, Parent: parent, CreatedAt: time.Now().UTC()}
|
||
s.folders = append(s.folders, f)
|
||
if err := s.saveFolders(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &f, nil
|
||
}
|
||
|
||
// folderNodes projeta pastas para validação genérica de árvore.
|
||
func folderNodes(folders []Folder) []treeNode {
|
||
out := make([]treeNode, len(folders))
|
||
for i, f := range folders {
|
||
out[i] = treeNode{ID: f.ID, Name: f.Name, Parent: f.Parent}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// UpdateFolder renomeia/move pasta com checagem de ciclo.
|
||
func (s *Store) UpdateFolder(id, name string, parent *string) (*Folder, error) {
|
||
s.mu.Lock()
|
||
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)
|
||
}
|
||
if moveCreatesCycle(s.folderSubtree(id), *parent) {
|
||
return nil, fmt.Errorf("%w: não é possível mover uma pasta para dentro dela mesma", ErrInvalid)
|
||
}
|
||
f.Parent = *parent
|
||
}
|
||
if n := strings.TrimSpace(name); n != "" && n != f.Name {
|
||
if _, ok := validTreeName(n, maxFolderLen); !ok || !fileNameRe.MatchString(n) {
|
||
return nil, fmt.Errorf("%w: nome de pasta inválido", ErrInvalid)
|
||
}
|
||
if siblingDup(folderNodes(s.folders), id, f.Parent, n) {
|
||
return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, n)
|
||
}
|
||
f.Name = n
|
||
}
|
||
if err := s.saveFolders(); err != nil {
|
||
return nil, err
|
||
}
|
||
cp := *f
|
||
return &cp, nil
|
||
}
|
||
|
||
// DeleteFolder remove a pasta e todo o subtree (incluindo arquivos). Ordem
|
||
// transacional: persiste metadados (pastas + arquivos) antes de remover blobs;
|
||
// em falha de gravação o estado em memória é restaurado (sem perda silenciosa).
|
||
func (s *Store) DeleteFolder(id string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if s.folderByID(id) == nil {
|
||
return ErrNotFound
|
||
}
|
||
sub := map[string]bool{}
|
||
for _, d := range s.folderSubtree(id) {
|
||
sub[d] = true
|
||
}
|
||
prevFolders := append([]Folder(nil), s.folders...)
|
||
prevFiles := append([]FileMeta(nil), s.fileMetas...)
|
||
keptFolders := s.folders[:0]
|
||
for _, f := range s.folders {
|
||
if !sub[f.ID] {
|
||
keptFolders = append(keptFolders, f)
|
||
}
|
||
}
|
||
s.folders = keptFolders
|
||
|
||
removed := make([]FileMeta, 0)
|
||
keptFiles := s.fileMetas[:0]
|
||
for _, m := range s.fileMetas {
|
||
if sub[m.Folder] {
|
||
removed = append(removed, m)
|
||
continue
|
||
}
|
||
keptFiles = append(keptFiles, m)
|
||
}
|
||
s.fileMetas = keptFiles
|
||
if err := s.saveFolders(); err != nil {
|
||
s.folders = prevFolders
|
||
s.fileMetas = prevFiles
|
||
return err
|
||
}
|
||
if err := s.saveFileMetas(); err != nil {
|
||
s.folders = prevFolders
|
||
s.fileMetas = prevFiles
|
||
return err
|
||
}
|
||
for _, m := range removed {
|
||
_ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME)))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Arquivos
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// AddFile persiste um upload: valida nome/pasta/nota, calcula SHA-256 e MIME
|
||
// reais (content sniffing) e grava o blob atomicamente. Limite: MaxFileBytes.
|
||
// A cópia para disco ocorre fora do lock global para não bloquear a store
|
||
// durante uploads lentos; o commit (revalidação + quota + rename +
|
||
// persistência) é feito sob lock, com rollback em falha.
|
||
func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, error) {
|
||
clean, err := cleanFileName(name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Pré-validação barata; revalidada no commit sob lock.
|
||
if folder != "" || note != "" {
|
||
s.mu.RLock()
|
||
fok := folder == "" || s.folderByID(folder) != nil
|
||
s.mu.RUnlock()
|
||
if !fok {
|
||
return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid)
|
||
}
|
||
if note != "" {
|
||
if _, err := s.get(note); err != nil {
|
||
return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid)
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
// Limita durante o stream (não só depois): evita gravar >MaxFileBytes+1
|
||
// no disco quando o corpo é aberto ou o Content-Length mente.
|
||
rest, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(r, MaxFileBytes-int64(n)+1))
|
||
if err != nil {
|
||
return fail(err)
|
||
}
|
||
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)
|
||
|
||
// Commit sob lock: revalida referências e quota agregada (corrige corrida
|
||
// entre uploads concorrentes); em falha remove o temporário ou reverte o blob.
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if folder != "" && s.folderByID(folder) == nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid)
|
||
}
|
||
if note != "" {
|
||
if _, err := s.get(note); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid)
|
||
}
|
||
}
|
||
if total := s.totalFileBytesLocked(); size+total > MaxFilesTotalBytes {
|
||
_ = os.Remove(tmp)
|
||
return nil, fmt.Errorf("%w: total de anexos excede %d GiB", ErrTooLarge, MaxFilesTotalBytes>>30)
|
||
}
|
||
blob := s.fileBlobPath(id, ext)
|
||
if err := os.Rename(tmp, blob); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return nil, err
|
||
}
|
||
|
||
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 {
|
||
s.fileMetas = s.fileMetas[:len(s.fileMetas)-1]
|
||
_ = os.Remove(blob)
|
||
return nil, err
|
||
}
|
||
return &meta, nil
|
||
}
|
||
|
||
// totalFileBytesLocked soma o tamanho dos anexos; requer lock de escrita.
|
||
func (s *Store) totalFileBytesLocked() int64 {
|
||
var total int64
|
||
for i := range s.fileMetas {
|
||
total += s.fileMetas[i].Size
|
||
}
|
||
return total
|
||
}
|
||
|
||
// Files devolve todos os metadados (ordenados por data desc). Exportado por
|
||
// necessidade (o campo interno tem o mesmo nome); a API usa FileTree/RootFiles.
|
||
func (s *Store) Files() []FileMeta {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
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. Persiste os metadados antes de remover o
|
||
// blob: em falha de gravação o arquivo continua referenciado (sem perda
|
||
// silenciosa); em falha só no unlink, a reconciliação de órfãos cobre.
|
||
func (s *Store) DeleteFile(id string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
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]
|
||
prev := append([]FileMeta(nil), s.fileMetas...)
|
||
s.fileMetas = append(s.fileMetas[:idx], s.fileMetas[idx+1:]...)
|
||
if err := s.saveFileMetas(); err != nil {
|
||
s.fileMetas = prev
|
||
return err
|
||
}
|
||
_ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME)))
|
||
return nil
|
||
}
|
||
|
||
// FileTree monta a hierarquia de pastas com arquivos.
|
||
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 SanitizeHeaderName(name)
|
||
}
|
||
|
||
// SanitizeHeaderName elimina caracteres que quebrariam Content-Disposition.
|
||
func SanitizeHeaderName(name string) string {
|
||
return strings.Map(func(r rune) rune {
|
||
switch r {
|
||
case '"', '\\', '\n', '\r', '/':
|
||
return '_'
|
||
}
|
||
return r
|
||
}, name)
|
||
}
|
||
|
||
// Folders devolve todas as pastas (lista plana; a UI monta a árvore).
|
||
|
||
// ReconcileBlobs remove blobs sem metadado (órfãos de falhas de commit) e
|
||
// reporta metadados sem blob (órfãos de falhas de unlink). Operação de
|
||
// manutenção (idealmente só no boot): coleta candidatos sob lock curto e
|
||
// remove fora do lock para não prender a store durante I/O de disco.
|
||
// Retorna (blobsRemovidos, metadadosSemBlob, erro).
|
||
func (s *Store) ReconcileBlobs() (removed int, missing []string, rerr error) {
|
||
s.mu.RLock()
|
||
want := map[string]bool{}
|
||
metas := make([]FileMeta, len(s.fileMetas))
|
||
copy(metas, s.fileMetas)
|
||
for _, m := range metas {
|
||
want[s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))] = true
|
||
}
|
||
dir := s.filesRoot()
|
||
s.mu.RUnlock()
|
||
entries, rerr := os.ReadDir(dir)
|
||
if rerr != nil {
|
||
return 0, nil, rerr
|
||
}
|
||
var candidates []string
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
p := dir + string(os.PathSeparator) + e.Name()
|
||
if strings.HasSuffix(e.Name(), ".tmp") || !want[p] {
|
||
candidates = append(candidates, p)
|
||
}
|
||
}
|
||
var statTargets []FileMeta
|
||
for _, m := range metas {
|
||
statTargets = append(statTargets, m)
|
||
}
|
||
for _, p := range candidates {
|
||
_ = os.Remove(p)
|
||
removed++
|
||
}
|
||
for _, m := range statTargets {
|
||
if _, serr := os.Stat(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))); serr != nil {
|
||
missing = append(missing, m.ID)
|
||
}
|
||
}
|
||
return removed, missing, nil
|
||
}
|
||
|
||
// Folders devolve todas as pastas (lista plana; a árvore de API sai de
|
||
// FileTree). Exportado por necessidade: o campo interno tem o mesmo nome.
|
||
func (s *Store) Folders() []Folder {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
out := make([]Folder, len(s.folders))
|
||
copy(out, s.folders)
|
||
return out
|
||
}
|