feat(preview): add archive listing and browser support

Introduces the ability to browse and preview the contents of archive files directly within the web interface.

Changes include:
- Added a new API endpoint `GET /d/{boxID}/archive/{fileID}` to fetch archive listings.
- Implemented on-demand archive listing generation in the backend.
- Updated the frontend preview component to support rendering and navigating archive contents.
This commit is contained in:
2026-06-08 03:43:43 +03:00
parent f9755fa98f
commit cba416b238
9 changed files with 852 additions and 2 deletions

View File

@@ -1,8 +1,10 @@
package jobs
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"image"
@@ -17,6 +19,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -112,7 +115,8 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
file := &box.Files[i]
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
if !needsPrimary && !needsScenes {
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
if !needsPrimary && !needsScenes && !needsArchive {
continue
}
result.Scanned++
@@ -144,6 +148,21 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
result.Generated++
}
}
if needsArchive {
archiveListing, err := generateArchiveListing(uploadService, box, *file)
if err != nil {
logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error())
result.Failed++
} else if archiveListing == "" {
result.Failed++
} else {
file.ArchiveListing = archiveListing
file.ArchiveListingObjectKey = ""
changed = true
result.Generated++
}
}
}
if changed {
@@ -170,6 +189,10 @@ func NeedsVideoScenes(file services.File) bool {
return needsVideoScenes(file)
}
func NeedsArchiveListing(file services.File) bool {
return needsArchiveListing(file)
}
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateThumbnail(uploadService, box, file)
}
@@ -178,6 +201,10 @@ func GenerateVideoScenesForFile(uploadService *services.UploadService, box servi
return generateVideoScenesThumbnail(uploadService, box, file)
}
func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateArchiveListing(uploadService, box, file)
}
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
thumbnailName := "@thumb@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file)
@@ -232,6 +259,25 @@ func generateVideoScenesThumbnail(uploadService *services.UploadService, box ser
return sceneName, err
}
func generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
if !needsArchiveListing(file) {
return "", nil
}
listingName := "@archive@" + file.ID + ".json"
object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil {
return "", err
}
defer object.Body.Close()
data, err := createArchiveListing(file, object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json")
return listingName, err
}
func isTextThumbnailCandidate(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 {
@@ -253,6 +299,219 @@ func isTextThumbnailCandidate(file services.File) bool {
}
}
func needsArchiveListing(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
switch contentType {
case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip":
return true
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
switch ext {
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
return true
default:
return false
}
}
func archiveListingCurrent(file services.File) bool {
return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json"
}
type archiveTreeNode struct {
Name string `json:"name"`
Size uint64 `json:"size,omitempty"`
Dir bool `json:"dir"`
Icon string `json:"icon,omitempty"`
Children map[string]*archiveTreeNode `json:"-"`
Items []*archiveTreeNode `json:"items,omitempty"`
}
type archiveListingData struct {
Name string `json:"name"`
Type string `json:"type"`
FileCount int `json:"fileCount"`
FolderCount int `json:"folderCount"`
UncompressedSize uint64 `json:"uncompressedSize"`
Root *archiveTreeNode `json:"root"`
}
func createArchiveListing(file services.File, source io.Reader) ([]byte, error) {
sourceFile, err := os.CreateTemp("", "warpbox-archive-*")
if err != nil {
return nil, err
}
defer os.Remove(sourceFile.Name())
if _, err := io.Copy(sourceFile, source); err != nil {
sourceFile.Close()
return nil, err
}
if err := sourceFile.Close(); err != nil {
return nil, err
}
archive, err := zip.OpenReader(sourceFile.Name())
if err != nil {
return nil, err
}
defer archive.Close()
root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}}
var totalSize uint64
var fileCount int
var dirCount int
for _, entry := range archive.File {
name := strings.Trim(entry.Name, "/")
if name == "" || strings.HasPrefix(name, "__MACOSX/") {
continue
}
parts := strings.Split(name, "/")
node := root
for i, part := range parts {
if part == "" {
continue
}
if node.Children == nil {
node.Children = map[string]*archiveTreeNode{}
}
child, ok := node.Children[part]
if !ok {
child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}}
node.Children[part] = child
if child.Dir {
dirCount++
}
}
node = child
}
if !entry.FileInfo().IsDir() {
node.Dir = false
node.Size = entry.UncompressedSize64
totalSize += entry.UncompressedSize64
fileCount++
}
}
finalizeArchiveTree(root)
data := archiveListingData{
Name: file.Name,
Type: archiveLabel(file),
FileCount: fileCount,
FolderCount: dirCount,
UncompressedSize: totalSize,
Root: root,
}
return json.MarshalIndent(data, "", " ")
}
func finalizeArchiveTree(node *archiveTreeNode) {
node.Items = sortedArchiveChildren(node)
for _, child := range node.Items {
if child.Dir {
child.Icon = "folder"
finalizeArchiveTree(child)
} else {
child.Icon = archiveFileIconName(child.Name)
}
}
}
func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) {
children := sortedArchiveChildren(node)
for i, child := range children {
last := i == len(children)-1
branch := "|-- "
nextPrefix := prefix + "| "
if last {
branch = "`-- "
nextPrefix = prefix + " "
}
out.WriteString(prefix)
out.WriteString(branch)
out.WriteString(archiveNodeLabel(child))
out.WriteString("\n")
if child.Dir {
writeArchiveTree(out, child, nextPrefix)
}
}
}
func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode {
children := make([]*archiveTreeNode, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, child)
}
sort.Slice(children, func(i, j int) bool {
if children[i].Dir != children[j].Dir {
return children[i].Dir
}
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
})
return children
}
func archiveNodeLabel(node *archiveTreeNode) string {
if node.Dir {
return "[DIR] " + node.Name + "/"
}
return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")"
}
func archiveFileIcon(name string) string {
return "[" + strings.ToUpper(archiveFileIconName(name)) + "]"
}
func archiveFileIconName(name string) string {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") {
case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg":
return "img"
case "mp4", "mov", "webm", "mkv", "avi":
return "vid"
case "mp3", "wav", "flac", "ogg", "m4a":
return "aud"
case "md", "txt", "log", "csv":
return "txt"
case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml":
return "code"
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
return "arc"
default:
return "file"
}
}
func archiveLabel(file services.File) string {
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if ext != "" {
return strings.ToUpper(ext) + " archive"
}
if file.ContentType != "" {
return file.ContentType
}
return "ZIP-compatible archive"
}
func formatArchiveBytes(size uint64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div := float64(unit)
value := float64(size) / div
units := []string{"KiB", "MiB", "GiB", "TiB"}
for _, suffix := range units {
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
value /= div
}
return fmt.Sprintf("%.1f PiB", value)
}
func createImageThumbnail(source io.Reader) ([]byte, error) {
img, _, err := image.Decode(source)
if err != nil {