Files
lgcosta f6a74e1092
ci / check (push) Successful in 1m12s
feat(files): add archive content listing preview
Lista conteudo de .zip, .tar, .tar.gz e .gz sem extrair, com limite anti-bomba.
2026-11-06 09:55:56 -03:00

861 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}