diff --git a/internal/api/files.go b/internal/api/files.go index b6d8199..050bb18 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -55,6 +55,19 @@ func (s *Server) handleFileTree(w http.ResponseWriter, r *http.Request) { }) } +// handleFileArchiveList devolve a listagem amigável do conteúdo de um +// compactado (.zip, .tar, .tar.gz/.tgz, .gz) sem extrair nada. +func (s *Server) handleFileArchiveList(w http.ResponseWriter, r *http.Request) { + entries, total, truncated, err := s.st.ListArchive(r.PathValue("id")) + if err != nil { + mapStoreErrorFor(w, err, "arquivo") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "entries": entries, "total": total, "truncated": truncated, + }) +} + func (s *Server) handleFileStatic(w http.ResponseWriter, r *http.Request) { if err := s.st.ServeFile(w, r, r.PathValue("id")); err != nil { mapStoreErrorFor(w, err, "arquivo") diff --git a/internal/api/server.go b/internal/api/server.go index 0ec7f85..ad5e020 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -98,6 +98,7 @@ func (s *Server) Handler() http.Handler { // 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/archive/{id}", s.auth(s.handleFileArchiveList)) 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)) diff --git a/internal/store/files.go b/internal/store/files.go index 4e8d7d8..07c0404 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -1,6 +1,9 @@ package store import ( + "archive/tar" + "archive/zip" + "compress/gzip" "crypto/sha256" "encoding/hex" "errors" @@ -56,6 +59,162 @@ type FileNode struct { 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 diff --git a/internal/store/files_archive_test.go b/internal/store/files_archive_test.go new file mode 100644 index 0000000..b50bda6 --- /dev/null +++ b/internal/store/files_archive_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "archive/tar" + "archive/zip" + "bytes" + "strings" + "testing" +) + +func makeZip(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + fw, err := w.Create("docs/nota.txt") + if err != nil { + t.Fatal(err) + } + fw.Write([]byte("ola")) + w.Create("vazia/") + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func makeTar(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := tar.NewWriter(&buf) + body := []byte("abc") + w.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o600, Size: int64(len(body))}) + w.Write(body) + w.WriteHeader(&tar.Header{Name: "dir", Typeflag: tar.TypeDir, Mode: 0o755}) + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestListArchiveZip(t *testing.T) { + s := newTestStore(t) + m, err := s.AddFile("backup.zip", "", "", bytes.NewReader(makeZip(t))) + if err != nil { + t.Fatalf("AddFile: %v", err) + } + entries, total, trunc, err := s.ListArchive(m.ID) + if err != nil { + t.Fatalf("ListArchive: %v", err) + } + if total != 2 || trunc || len(entries) != 2 { + t.Fatalf("inesperado: total=%d trunc=%v entries=%v", total, trunc, entries) + } + if entries[0].Name != "docs/nota.txt" || entries[0].Dir || entries[0].Size != 3 { + t.Errorf("entry0: %+v", entries[0]) + } + if entries[1].Name != "vazia/" || !entries[1].Dir { + t.Errorf("entry1: %+v", entries[1]) + } +} + +func TestListArchiveTarAndRejectsOthers(t *testing.T) { + s := newTestStore(t) + m, err := s.AddFile("dados.tar", "", "", bytes.NewReader(makeTar(t))) + if err != nil { + t.Fatalf("AddFile: %v", err) + } + entries, total, _, err := s.ListArchive(m.ID) + if err != nil { + t.Fatalf("ListArchive: %v", err) + } + if total != 2 || len(entries) != 2 || entries[0].Name != "a.txt" { + t.Fatalf("inesperado: total=%d entries=%v", total, entries) + } + txt, err := s.AddFile("nota.txt", "", "", strings.NewReader("oi")) + if err != nil { + t.Fatalf("AddFile: %v", err) + } + if _, _, _, err := s.ListArchive(txt.ID); err == nil { + t.Fatalf("esperava erro para .txt") + } + if _, _, _, err := s.ListArchive("inexistente01"); err == nil { + t.Fatalf("esperava erro para id inexistente") + } +} diff --git a/web/css/app.css b/web/css/app.css index f600b9b..5532cb1 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -1873,6 +1873,17 @@ html[data-theme="carbon-paper"] #revisions-list li:hover { background: #e6e5dd; .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; } +.lightbox-empty { width: 100%; max-height: 66vh; overflow: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; text-align: center; padding: 40px 20px; color: var(--ink-2); white-space: pre-line; } +.lightbox-empty-icon { font-size: 40px; } +.lightbox-archive { width: 100%; max-height: 66vh; overflow: auto; background: var(--panel-2); border-radius: 8px; padding: 12px; } +.archive-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archive-meta { font-size: 12px; color: var(--ink-2); margin: 2px 0 8px; } +.archive-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.archive-list li { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 6px; font-size: 12.5px; } +.archive-list li:nth-child(odd) { background: rgba(127, 127, 127, .08); } +.archive-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archive-size { flex: 0 0 auto; color: var(--ink-2); font-size: 12px; } +.archive-note { font-size: 12px; color: var(--ink-2); margin: 8px 2px 0; } @media (max-width: 760px) { .files-overlay { inset: 0; border-radius: 0; } diff --git a/web/js/app.js b/web/js/app.js index 7145c37..27c993a 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -2433,27 +2433,132 @@ F.lbDl.href = f.url; F.lbDl.setAttribute('download', f.name); F.lbBody.textContent = ''; - const kind = attachmentKind(f); - if (kind !== 'doc' || (f.mime || '').includes('pdf')) { - let el; - if (kind === 'img') { el = document.createElement('img'); el.src = f.url; el.alt = f.name; } - else if (kind === 'vid') { el = document.createElement('video'); el.src = f.url; el.controls = true; } - else if (kind === 'aud') { el = document.createElement('audio'); el.src = f.url; el.controls = true; } - else { el = document.createElement('iframe'); el.src = f.url; el.title = f.name; } + if (isPreviewableImage(f)) { + const el = document.createElement('img'); + el.src = f.url; + el.alt = f.name; F.lbBody.appendChild(el); + } else if (isSupportedArchive(f)) { + await previewArchive(f); + } else if (isArchiveFile(f)) { + previewNotice(`Não foi possível mostrar o conteúdo de “${f.name}”.\nEsse formato de compactado não tem listagem suportada (vale para .zip, .tar, .tar.gz e .gz). Use Baixar para abrir o arquivo.`); } 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)'; } + const kind = attachmentKind(f); + if (kind !== 'doc' || (f.mime || '').includes('pdf')) { + let el; + if (kind === 'img') { el = document.createElement('img'); el.src = f.url; el.alt = f.name; } + else if (kind === 'vid') { el = document.createElement('video'); el.src = f.url; el.controls = true; } + else if (kind === 'aud') { 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'); } + function isPreviewableImage(f) { + const mime = f.mime || ''; + // svg é servido como attachment (anti-XSS), então não dá para exibir inline + return mime.startsWith('image/') && mime !== 'image/svg+xml'; + } + function archiveExt(name) { + const l = (name || '').toLowerCase(); + if (l.endsWith('.tar.gz')) return '.tar.gz'; + const i = l.lastIndexOf('.'); + return i >= 0 ? l.slice(i) : ''; + } + const SUPPORTED_ARCHIVE_EXTS = new Set(['.zip', '.tar', '.tar.gz', '.tgz', '.gz']); + const ARCHIVE_EXTS = new Set(['.zip', '.tar', '.tar.gz', '.tgz', '.gz', '.rar', '.7z', '.bz2', '.xz', '.cab', '.iso']); + function isSupportedArchive(f) { + const mime = f.mime || ''; + if (SUPPORTED_ARCHIVE_EXTS.has(archiveExt(f.name))) return true; + return mime.includes('zip') || mime.includes('x-tar') || mime.includes('x-gzip') || mime.includes('gzip') || mime.includes('compressed'); + } + function isArchiveFile(f) { + if (isSupportedArchive(f)) return true; + return ARCHIVE_EXTS.has(archiveExt(f.name)); + } + function previewNotice(msg) { + const div = document.createElement('div'); + div.className = 'lightbox-empty'; + const icon = document.createElement('div'); + icon.className = 'lightbox-empty-icon'; + icon.textContent = '📦'; + const p = document.createElement('p'); + p.textContent = msg; + div.append(icon, p); + F.lbBody.appendChild(div); + } + async function previewArchive(f) { + const loading = document.createElement('div'); + loading.className = 'lightbox-empty'; + loading.textContent = 'Listando conteúdo…'; + F.lbBody.appendChild(loading); + let data; + try { + data = await api('GET', '/api/files/archive/' + f.id); + } catch (err) { + loading.textContent = ''; + previewNotice(err.message || `Não foi possível listar “${f.name}”. Use Baixar para abrir o arquivo.`); + return; + } + F.lbBody.textContent = ''; + const box = document.createElement('div'); + box.className = 'lightbox-archive'; + const title = document.createElement('div'); + title.className = 'archive-title'; + title.textContent = '🗜️ ' + f.name; + const meta = document.createElement('div'); + meta.className = 'archive-meta'; + const total = data.total || 0; + meta.textContent = total === 0 ? 'Arquivo vazio.' + : total === 1 ? '1 item · ' + fmtBytes(f.size || 0) + : `${total} itens · ` + fmtBytes(f.size || 0); + box.append(title, meta); + const entries = data.entries || []; + if (!entries.length) { + const p = document.createElement('p'); + p.className = 'archive-note'; + p.textContent = total === 0 ? 'Nada para listar neste compactado.' : 'Nada para exibir.'; + box.appendChild(p); + } else { + const ul = document.createElement('ul'); + ul.className = 'archive-list'; + for (const e of entries) { + const li = document.createElement('li'); + const ic = document.createElement('span'); + ic.className = 'archive-icon'; + ic.textContent = e.dir ? '📁' : '📄'; + const nm = document.createElement('span'); + nm.className = 'archive-name'; + nm.textContent = e.name; + nm.title = e.name; + const sz = document.createElement('span'); + sz.className = 'archive-size'; + sz.textContent = e.dir ? '' : fmtBytes(e.size < 0 ? 0 : e.size); + li.append(ic, nm, sz); + ul.appendChild(li); + } + box.appendChild(ul); + } + if (data.truncated) { + const note = document.createElement('p'); + note.className = 'archive-note'; + note.textContent = `Mostrando os primeiros ${entries.length} de ${total} itens. Baixe o arquivo para ver o restante.`; + box.appendChild(note); + } + F.lbBody.appendChild(box); + } + // wiring de eventos do gerenciador F.closeBtn.addEventListener('click', closeFiles); F.lbClose.addEventListener('click', () => F.lightbox.classList.add('hidden'));