Lista conteudo de .zip, .tar, .tar.gz e .gz sem extrair, com limite anti-bomba.
This commit is contained in:
1 parent
dc829b9d43
commit
f6a74e1092
6 files changed
+389
-15
No files matched your search
@@ -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) {
|
func (s *Server) handleFileStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.st.ServeFile(w, r, r.PathValue("id")); err != nil {
|
if err := s.st.ServeFile(w, r, r.PathValue("id")); err != nil {
|
||||||
mapStoreErrorFor(w, err, "arquivo")
|
mapStoreErrorFor(w, err, "arquivo")
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// gerenciador de arquivos / anexos
|
// gerenciador de arquivos / anexos
|
||||||
mux.HandleFunc("POST /api/files/upload", s.auth(s.handleFileUpload))
|
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/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("GET /api/files/static/{id}", s.auth(s.handleFileStatic))
|
||||||
mux.HandleFunc("DELETE /api/files/{id}", s.auth(s.handleFileDelete))
|
mux.HandleFunc("DELETE /api/files/{id}", s.auth(s.handleFileDelete))
|
||||||
mux.HandleFunc("PUT /api/files/{id}/move", s.auth(s.handleFileMove))
|
mux.HandleFunc("PUT /api/files/{id}/move", s.auth(s.handleFileMove))
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -56,6 +59,162 @@ type FileNode struct {
|
|||||||
URL string `json:"url"`
|
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).
|
// TreeNode é a hierarquia completa (pastas aninhadas + arquivos).
|
||||||
type TreeNode struct {
|
type TreeNode struct {
|
||||||
Folder
|
Folder
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1873,6 +1873,17 @@ html[data-theme="carbon-paper"] #revisions-list li:hover { background: #e6e5dd;
|
|||||||
.lightbox-body audio { width: 100%; }
|
.lightbox-body audio { width: 100%; }
|
||||||
.lightbox-body iframe { width: 100%; height: 70vh; border: 1px solid var(--border-soft); border-radius: 8px; }
|
.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-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) {
|
@media (max-width: 760px) {
|
||||||
.files-overlay { inset: 0; border-radius: 0; }
|
.files-overlay { inset: 0; border-radius: 0; }
|
||||||
|
|||||||
+120
-15
@@ -2433,27 +2433,132 @@
|
|||||||
F.lbDl.href = f.url;
|
F.lbDl.href = f.url;
|
||||||
F.lbDl.setAttribute('download', f.name);
|
F.lbDl.setAttribute('download', f.name);
|
||||||
F.lbBody.textContent = '';
|
F.lbBody.textContent = '';
|
||||||
const kind = attachmentKind(f);
|
if (isPreviewableImage(f)) {
|
||||||
if (kind !== 'doc' || (f.mime || '').includes('pdf')) {
|
const el = document.createElement('img');
|
||||||
let el;
|
el.src = f.url;
|
||||||
if (kind === 'img') { el = document.createElement('img'); el.src = f.url; el.alt = f.name; }
|
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);
|
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 {
|
} else {
|
||||||
const pre = document.createElement('pre');
|
const kind = attachmentKind(f);
|
||||||
pre.className = 'lightbox-text';
|
if (kind !== 'doc' || (f.mime || '').includes('pdf')) {
|
||||||
pre.textContent = 'carregando…';
|
let el;
|
||||||
F.lbBody.appendChild(pre);
|
if (kind === 'img') { el = document.createElement('img'); el.src = f.url; el.alt = f.name; }
|
||||||
try {
|
else if (kind === 'vid') { el = document.createElement('video'); el.src = f.url; el.controls = true; }
|
||||||
const r = await fetch(f.url);
|
else if (kind === 'aud') { el = document.createElement('audio'); el.src = f.url; el.controls = true; }
|
||||||
pre.textContent = (await r.text()).slice(0, 200000);
|
else { el = document.createElement('iframe'); el.src = f.url; el.title = f.name; }
|
||||||
} catch { pre.textContent = '(sem conexão para pré-visualizar)'; }
|
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');
|
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
|
// wiring de eventos do gerenciador
|
||||||
F.closeBtn.addEventListener('click', closeFiles);
|
F.closeBtn.addEventListener('click', closeFiles);
|
||||||
F.lbClose.addEventListener('click', () => F.lightbox.classList.add('hidden'));
|
F.lbClose.addEventListener('click', () => F.lightbox.classList.add('hidden'));
|
||||||
|
|||||||
Reference in new issue
Block a user