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

@@ -135,6 +135,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview) mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview)
mux.HandleFunc("GET /d/{boxID}/archive/{fileID}", a.ArchiveListing)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /robots.txt", a.RobotsTxt) mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML) mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)

View File

@@ -48,8 +48,10 @@ type fileView struct {
DownloadURL string DownloadURL string
ThumbnailURL string ThumbnailURL string
SceneURL string SceneURL string
ArchiveURL string
HasThumbnail bool HasThumbnail bool
HasScene bool HasScene bool
HasArchive bool
IconURL string IconURL string
IconRetroURL string IconRetroURL string
ReactURL string ReactURL string
@@ -384,6 +386,51 @@ func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body)) http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
} }
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if !jobs.NeedsArchiveListing(file) {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
return
}
}
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string { func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) { if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) {
return "" return ""
@@ -432,6 +479,31 @@ func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services
return scene return scene
} }
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) {
return ""
}
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
if err != nil || listing == "" {
if err != nil {
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].ArchiveListing = listing
box.Files[i].ArchiveListingObjectKey = ""
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return listing
}
// servePlaceholderThumbnail serves the fallback image with no-store so the // servePlaceholderThumbnail serves the fallback image with no-store so the
// browser re-requests on the next load and picks up the real thumbnail as soon // browser re-requests on the next load and picks up the real thumbnail as soon
// as it has been generated. // as it has been generated.
@@ -577,8 +649,10 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID), DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID), SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file), HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file), HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file),
IconURL: fileIconURL("standard", icon.Standard), IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro), IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),

View File

@@ -24,6 +24,7 @@ Disallow: /d/*/f/*/download
Disallow: /d/*/zip Disallow: /d/*/zip
Disallow: /d/*/thumb/ Disallow: /d/*/thumb/
Disallow: /d/*/scene/ Disallow: /d/*/scene/
Disallow: /d/*/archive/
Disallow: /d/*/og-image.jpg Disallow: /d/*/og-image.jpg
Disallow: /d/*/unlock Disallow: /d/*/unlock
Disallow: /d/*/manage/ Disallow: /d/*/manage/

View File

@@ -1,8 +1,10 @@
package jobs package jobs
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"html" "html"
"image" "image"
@@ -17,6 +19,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -112,7 +115,8 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
file := &box.Files[i] file := &box.Files[i]
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file) needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file) needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
if !needsPrimary && !needsScenes { needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
if !needsPrimary && !needsScenes && !needsArchive {
continue continue
} }
result.Scanned++ result.Scanned++
@@ -144,6 +148,21 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
result.Generated++ 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 { if changed {
@@ -170,6 +189,10 @@ func NeedsVideoScenes(file services.File) bool {
return needsVideoScenes(file) 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) { func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateThumbnail(uploadService, box, file) return generateThumbnail(uploadService, box, file)
} }
@@ -178,6 +201,10 @@ func GenerateVideoScenesForFile(uploadService *services.UploadService, box servi
return generateVideoScenesThumbnail(uploadService, box, file) 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) { func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
thumbnailName := "@thumb@" + file.ID + ".jpg" thumbnailName := "@thumb@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file) object, err := uploadService.OpenFileObject(context.Background(), box, file)
@@ -232,6 +259,25 @@ func generateVideoScenesThumbnail(uploadService *services.UploadService, box ser
return sceneName, err 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 { func isTextThumbnailCandidate(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType)) contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 { 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) { func createImageThumbnail(source io.Reader) ([]byte, error) {
img, _, err := image.Decode(source) img, _, err := image.Decode(source)
if err != nil { if err != nil {

View File

@@ -1,7 +1,9 @@
package jobs package jobs
import ( import (
"archive/zip"
"bytes" "bytes"
"encoding/json"
"image" "image"
"image/color" "image/color"
"image/jpeg" "image/jpeg"
@@ -108,6 +110,51 @@ func TestRenderVideoScenesThumbnailReturnsLargeJPEG(t *testing.T) {
} }
} }
func TestCreateArchiveListingRendersZipTree(t *testing.T) {
var archive bytes.Buffer
writer := zip.NewWriter(&archive)
addZipTestFile(t, writer, "docs/readme.md", "hello")
addZipTestFile(t, writer, "src/main.go", "package main\n")
if err := writer.Close(); err != nil {
t.Fatalf("zip.Close returned error: %v", err)
}
data, err := createArchiveListing(services.File{Name: "bundle.zip", ContentType: "application/zip"}, bytes.NewReader(archive.Bytes()))
if err != nil {
t.Fatalf("createArchiveListing returned error: %v", err)
}
var listing archiveListingData
if err := json.Unmarshal(data, &listing); err != nil {
t.Fatalf("json.Unmarshal returned error: %v\n%s", err, string(data))
}
if listing.Name != "bundle.zip" || listing.FileCount != 2 || listing.FolderCount != 2 {
t.Fatalf("archive listing metadata = %+v", listing)
}
if listing.Root == nil || len(listing.Root.Items) != 2 {
t.Fatalf("archive listing root = %+v", listing.Root)
}
if listing.Root.Items[0].Name != "docs" || listing.Root.Items[0].Icon != "folder" {
t.Fatalf("first archive folder = %+v", listing.Root.Items[0])
}
if listing.Root.Items[0].Items[0].Name != "readme.md" || listing.Root.Items[0].Items[0].Icon != "txt" {
t.Fatalf("markdown archive file = %+v", listing.Root.Items[0].Items[0])
}
if listing.Root.Items[1].Items[0].Name != "main.go" || listing.Root.Items[1].Items[0].Icon != "code" {
t.Fatalf("go archive file = %+v", listing.Root.Items[1].Items[0])
}
}
func addZipTestFile(t *testing.T, writer *zip.Writer, name, body string) {
t.Helper()
file, err := writer.Create(name)
if err != nil {
t.Fatalf("zip.Create returned error: %v", err)
}
if _, err := file.Write([]byte(body)); err != nil {
t.Fatalf("zip file write returned error: %v", err)
}
}
func solidTestImage(c color.Color) image.Image { func solidTestImage(c color.Color) image.Image {
img := image.NewRGBA(image.Rect(0, 0, 32, 24)) img := image.NewRGBA(image.Rect(0, 0, 32, 24))
for y := 0; y < img.Bounds().Dy(); y++ { for y := 0; y < img.Bounds().Dy(); y++ {

View File

@@ -129,9 +129,11 @@ type File struct {
PreviewKind string `json:"previewKind"` PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
SceneThumbnail string `json:"sceneThumbnail,omitempty"` SceneThumbnail string `json:"sceneThumbnail,omitempty"`
ArchiveListing string `json:"archiveListing,omitempty"`
ObjectKey string `json:"objectKey,omitempty"` ObjectKey string `json:"objectKey,omitempty"`
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"` ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"` SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
Processing bool `json:"processing,omitempty"` Processing bool `json:"processing,omitempty"`
ProcessingError string `json:"processingError,omitempty"` ProcessingError string `json:"processingError,omitempty"`
UploadedAt time.Time `json:"uploadedAt"` UploadedAt time.Time `json:"uploadedAt"`
@@ -736,6 +738,9 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
if key := s.SceneThumbnailObjectKey(box, file); key != "" { if key := s.SceneThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key) _ = backend.Delete(context.Background(), key)
} }
if key := s.ArchiveListingObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
} }
box.Files = append(box.Files[:index], box.Files[index+1:]...) box.Files = append(box.Files[:index], box.Files[index+1:]...)
@@ -833,6 +838,16 @@ func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string {
return boxObjectKey(box.ID, file.SceneThumbnail) return boxObjectKey(box.ID, file.SceneThumbnail)
} }
func (s *UploadService) ArchiveListingObjectKey(box Box, file File) string {
if file.ArchiveListingObjectKey != "" {
return file.ArchiveListingObjectKey
}
if file.ArchiveListing == "" {
return ""
}
return boxObjectKey(box.ID, file.ArchiveListing)
}
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) { func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
if file.Processing { if file.Processing {
return StorageObject{}, fmt.Errorf("file is still processing") return StorageObject{}, fmt.Errorf("file is still processing")
@@ -868,6 +883,18 @@ func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, f
return backend.Get(ctx, key) return backend.Get(ctx, key)
} }
func (s *UploadService) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) {
key := s.ArchiveListingObjectKey(box, file)
if key == "" {
return StorageObject{}, os.ErrNotExist
}
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return StorageObject{}, err
}
return backend.Get(ctx, key)
}
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) { func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil { if err != nil {

View File

@@ -271,6 +271,170 @@
height: auto; height: auto;
} }
.archive-browser-preview {
width: 100%;
height: clamp(18rem, 64vh, 38rem);
overflow: auto;
background: color-mix(in srgb, var(--card) 86%, black 14%);
color: var(--foreground);
}
.archive-browser-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, black 8%);
}
.archive-browser-header strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 0.98rem;
}
.archive-browser-header span {
color: var(--muted-foreground);
font-size: 0.82rem;
}
.archive-tree {
padding: 0.6rem 0.8rem 1rem;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.88rem;
line-height: 1.45;
}
.archive-node {
min-width: max-content;
}
.archive-node-row {
display: grid;
grid-template-columns: 1.25rem 1.45rem minmax(12rem, 1fr) auto;
align-items: center;
gap: 0.45rem;
min-height: 2.1rem;
padding: 0.18rem 0.45rem;
border-radius: 6px;
color: var(--foreground);
}
.archive-node-row:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.archive-folder > summary {
cursor: pointer;
list-style: none;
}
.archive-folder > summary::-webkit-details-marker {
display: none;
}
.archive-chevron,
.archive-chevron-spacer,
.archive-file-icon {
display: inline-grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
}
.archive-chevron {
color: var(--muted-foreground);
transition: transform 140ms ease, color 140ms ease;
}
.archive-folder[open] > summary .archive-chevron {
transform: rotate(90deg);
color: var(--accent);
}
.archive-chevron svg {
width: 1.18rem;
height: 1.18rem;
}
.archive-file-icon {
color: var(--muted-foreground);
}
.archive-file-icon svg,
.archive-chevron svg {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.archive-file-icon-folder {
color: var(--accent);
}
.archive-file-icon-folder svg {
fill: color-mix(in srgb, var(--accent) 18%, transparent);
}
.archive-file-icon-img {
color: #67e8f9;
}
.archive-file-icon-vid {
color: #f9a8d4;
}
.archive-file-icon-aud {
color: #86efac;
}
.archive-file-icon-code {
color: #c4b5fd;
}
.archive-file-icon-arc {
color: #fcd34d;
}
.archive-file-icon-txt {
color: #f8fafc;
}
.archive-node-name {
min-width: 0;
overflow-wrap: anywhere;
}
.archive-node-size {
color: var(--muted-foreground);
font-size: 0.78rem;
}
.archive-browser-empty {
margin: 0;
padding: 1rem;
color: var(--muted-foreground);
}
.archive-browser-legacy {
min-width: max-content;
margin: 0;
padding: 1rem;
color: var(--foreground);
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.88rem;
line-height: 1.55;
white-space: pre;
}
.preview-placeholder { .preview-placeholder {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -283,6 +447,7 @@
.preview-placeholder[hidden], .preview-placeholder[hidden],
.default-preview[hidden], .default-preview[hidden],
.native-preview[hidden], .native-preview[hidden],
.archive-browser-preview[hidden],
.large-preview-gate[hidden], .large-preview-gate[hidden],
.code-preview[hidden], .code-preview[hidden],
.render-preview[hidden] { .render-preview[hidden] {

View File

@@ -17,6 +17,7 @@
downloadURL: preview.dataset.downloadUrl || "", downloadURL: preview.dataset.downloadUrl || "",
iconURL: preview.dataset.iconUrl || "", iconURL: preview.dataset.iconUrl || "",
sceneURL: preview.dataset.sceneUrl || "", sceneURL: preview.dataset.sceneUrl || "",
archiveURL: preview.dataset.archiveUrl || "",
activeMode: "", activeMode: "",
defaultMode: "default", defaultMode: "default",
pendingMode: "", pendingMode: "",
@@ -26,6 +27,10 @@
prismLoaded: false, prismLoaded: false,
renderLoaded: false, renderLoaded: false,
sceneLoaded: false, sceneLoaded: false,
archiveLoaded: false,
archiveUIRendered: false,
archiveData: null,
archiveText: "",
renderFullscreenFallback: false, renderFullscreenFallback: false,
confirmedLargeModes: {}, confirmedLargeModes: {},
tabs: [] tabs: []
@@ -43,6 +48,9 @@
rawOutput: preview.querySelector("[data-raw-output]"), rawOutput: preview.querySelector("[data-raw-output]"),
codePane: preview.querySelector("[data-code-preview]"), codePane: preview.querySelector("[data-code-preview]"),
codeOutput: preview.querySelector("[data-code-output]"), codeOutput: preview.querySelector("[data-code-output]"),
archiveBrowserPane: preview.querySelector("[data-archive-browser-preview]"),
archivePane: preview.querySelector("[data-archive-preview]"),
archiveOutput: preview.querySelector("[data-archive-output]"),
renderPane: preview.querySelector("[data-render-preview]"), renderPane: preview.querySelector("[data-render-preview]"),
fullscreenButton: preview.querySelector("[data-render-fullscreen]"), fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
gatePane: preview.querySelector("[data-large-preview-gate]"), gatePane: preview.querySelector("[data-large-preview-gate]"),
@@ -68,6 +76,7 @@
var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml"; var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml";
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0; var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0; var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType);
return { return {
extension: extension, extension: extension,
@@ -79,6 +88,7 @@
isImage: isImage, isImage: isImage,
isVideo: isVideo, isVideo: isVideo,
isAudio: isAudio, isAudio: isAudio,
isArchive: isArchive,
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
}; };
} }
@@ -104,6 +114,12 @@
return tabs; return tabs;
} }
if (type.isArchive) {
tabs.push({ mode: "archive-ui", label: "Archive Preview" });
tabs.push({ mode: "archive", label: "Text Tree" });
return tabs;
}
if (type.isTextLike) { if (type.isTextLike) {
if (type.isHTML || type.isMarkdown) { if (type.isHTML || type.isMarkdown) {
tabs.push({ mode: "render", label: "Render Preview" }); tabs.push({ mode: "render", label: "Render Preview" });
@@ -122,6 +138,9 @@
if (type.isVideo) { if (type.isVideo) {
return "video"; return "video";
} }
if (type.isArchive) {
return "archive-ui";
}
if (state.sizeBytes > LARGE_PREVIEW_BYTES) { if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
if (type.isAudio && hasMode(tabs, "browser-audio")) { if (type.isAudio && hasMode(tabs, "browser-audio")) {
return "browser-audio"; return "browser-audio";
@@ -198,6 +217,12 @@
} else if (mode === "code") { } else if (mode === "code") {
show(els.codePane); show(els.codePane);
ensurePrismPreview(); ensurePrismPreview();
} else if (mode === "archive-ui") {
show(els.archiveBrowserPane);
ensureArchiveBrowserPreview();
} else if (mode === "archive") {
show(els.archivePane);
ensureArchivePreview();
} else if (mode === "render") { } else if (mode === "render") {
show(els.renderPane); show(els.renderPane);
if (fileType.isMarkdown) { if (fileType.isMarkdown) {
@@ -416,6 +441,8 @@
hide(els.browserAudioPane); hide(els.browserAudioPane);
hide(els.rawPane); hide(els.rawPane);
hide(els.codePane); hide(els.codePane);
hide(els.archiveBrowserPane);
hide(els.archivePane);
hide(els.renderPane); hide(els.renderPane);
hide(els.gatePane); hide(els.gatePane);
hide(els.placeholder); hide(els.placeholder);
@@ -512,6 +539,8 @@
"browser-audio": "Browser preview", "browser-audio": "Browser preview",
"raw": "Raw preview", "raw": "Raw preview",
"code": "Code preview", "code": "Code preview",
"archive-ui": "Archive preview",
"archive": "Archive preview",
"render": "Render preview" "render": "Render preview"
}; };
return labels[mode] || "Preview"; return labels[mode] || "Preview";
@@ -529,6 +558,227 @@
state.sceneLoaded = true; state.sceneLoaded = true;
} }
function ensureArchivePreview() {
if (state.archiveLoaded || !els.archiveOutput || !state.archiveURL) {
return;
}
ensureArchiveData()
.then(function () {
var text = state.archiveText || archiveDataToText(state.archiveData);
els.archiveOutput.textContent = text;
state.archiveLoaded = true;
hide(els.placeholder);
show(els.archivePane);
})
.catch(function () {
showError("Archive preview could not be loaded.");
});
}
function ensureArchiveBrowserPreview() {
if (state.archiveUIRendered || !els.archiveBrowserPane || !state.archiveURL) {
return;
}
ensureArchiveData()
.then(function () {
renderArchiveBrowser(state.archiveData);
state.archiveUIRendered = true;
hide(els.placeholder);
show(els.archiveBrowserPane);
})
.catch(function () {
showError("Archive preview could not be loaded.");
});
}
function ensureArchiveData() {
if (state.archiveData || state.archiveText) {
return Promise.resolve();
}
showLoading("Loading archive contents...");
return fetch(state.archiveURL, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("Archive preview could not be loaded.");
}
return response.text();
})
.then(function (text) {
try {
state.archiveData = JSON.parse(text);
} catch (error) {
state.archiveText = text;
}
});
}
function renderArchiveBrowser(data) {
if (!els.archiveBrowserPane) {
return;
}
els.archiveBrowserPane.innerHTML = "";
if (!data || !data.root) {
var fallback = document.createElement("pre");
fallback.className = "archive-browser-legacy";
fallback.textContent = state.archiveText || "Archive preview is unavailable.";
els.archiveBrowserPane.appendChild(fallback);
return;
}
var header = document.createElement("div");
header.className = "archive-browser-header";
header.innerHTML = "<strong></strong><span></span>";
header.querySelector("strong").textContent = data.name || state.fileName || "Archive";
header.querySelector("span").textContent = [
data.type || "Archive",
formatArchiveCount(data.fileCount, "file"),
formatArchiveCount(data.folderCount, "folder"),
formatBytes(data.uncompressedSize || 0)
].filter(Boolean).join(" · ");
els.archiveBrowserPane.appendChild(header);
var tree = document.createElement("div");
tree.className = "archive-tree";
var items = data.root.items || [];
if (items.length === 0) {
var emptyTree = document.createElement("p");
emptyTree.className = "archive-browser-empty";
emptyTree.textContent = "This archive is empty.";
tree.appendChild(emptyTree);
} else {
items.forEach(function (item) {
tree.appendChild(renderArchiveNode(item, 0));
});
}
els.archiveBrowserPane.appendChild(tree);
}
function renderArchiveNode(node, depth) {
var row = document.createElement(node.dir ? "details" : "div");
row.className = node.dir ? "archive-node archive-folder" : "archive-node archive-file";
if (node.dir && depth < 1) {
row.open = true;
}
var summary = document.createElement(node.dir ? "summary" : "div");
summary.className = "archive-node-row";
summary.style.paddingLeft = (0.45 + depth * 1.15).toFixed(2) + "rem";
if (node.dir) {
summary.appendChild(createArchiveChevron());
} else {
var spacer = document.createElement("span");
spacer.className = "archive-chevron-spacer";
summary.appendChild(spacer);
}
summary.appendChild(createArchiveIcon(node.icon || (node.dir ? "folder" : "file")));
var name = document.createElement("span");
name.className = "archive-node-name";
name.textContent = node.name + (node.dir ? "/" : "");
summary.appendChild(name);
if (!node.dir) {
var size = document.createElement("span");
size.className = "archive-node-size";
size.textContent = formatBytes(node.size || 0);
summary.appendChild(size);
}
row.appendChild(summary);
if (node.dir) {
(node.items || []).forEach(function (child) {
row.appendChild(renderArchiveNode(child, depth + 1));
});
}
return row;
}
function createArchiveChevron() {
var chevron = document.createElement("span");
chevron.className = "archive-chevron";
chevron.setAttribute("aria-hidden", "true");
chevron.innerHTML = '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
return chevron;
}
function createArchiveIcon(icon) {
var element = document.createElement("span");
element.className = "archive-file-icon archive-file-icon-" + icon;
element.setAttribute("aria-hidden", "true");
element.innerHTML = archiveIconSVG(icon);
return element;
}
function archiveDataToText(data) {
if (!data || !data.root) {
return state.archiveText || "";
}
var lines = [
"Archive preview",
"Name: " + (data.name || state.fileName || "Archive"),
"Type: " + (data.type || "Archive"),
"Entries: " + (data.fileCount || 0) + " files, " + (data.folderCount || 0) + " folders",
"Uncompressed size: " + formatBytes(data.uncompressedSize || 0),
"",
"."
];
appendArchiveTextLines(lines, data.root.items || [], "");
if (!(data.root.items || []).length) {
lines.push("(empty archive)");
}
return lines.join("\n");
}
function appendArchiveTextLines(lines, items, prefix) {
items.forEach(function (item, index) {
var last = index === items.length - 1;
var branch = last ? "`-- " : "|-- ";
var nextPrefix = prefix + (last ? " " : "| ");
var label = item.dir ? "[DIR] " + item.name + "/" : "[" + (item.icon || "file").toUpperCase() + "] " + item.name + " (" + formatBytes(item.size || 0) + ")";
lines.push(prefix + branch + label);
if (item.dir) {
appendArchiveTextLines(lines, item.items || [], nextPrefix);
}
});
}
function archiveIconSVG(icon) {
var icons = {
folder: '<svg viewBox="0 0 24 24" focusable="false"><path d="M3 6.75A2.75 2.75 0 0 1 5.75 4h4.1l2 2.2h6.4A2.75 2.75 0 0 1 21 8.95v8.3A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25Z"/></svg>',
img: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 3.2-3.2 2.6 2.6 2.2-2.2L19 17"/><circle cx="9" cy="9" r="1.4"/></svg>',
vid: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="6" width="12" height="12" rx="2"/><path d="m16 10 4-2.5v9L16 14"/></svg>',
aud: '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>',
txt: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M9 12h6M9 15h6M9 18h4"/></svg>',
code: '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 8-4 4 4 4M15 8l4 4-4 4M13 5l-2 14"/></svg>',
arc: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M10 6h2M10 9h2M10 12h2M10 15h2M10 18h2"/></svg>',
file: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5"/></svg>'
};
return icons[icon] || icons.file;
}
function formatArchiveCount(value, label) {
value = Number(value || 0);
return value + " " + label + (value === 1 ? "" : "s");
}
function formatBytes(value) {
value = Number(value || 0);
if (value < 1024) {
return value + " B";
}
var units = ["KiB", "MiB", "GiB", "TiB"];
var size = value / 1024;
for (var i = 0; i < units.length; i++) {
if (size < 1024 || i === units.length - 1) {
return size.toFixed(1) + " " + units[i];
}
size /= 1024;
}
return value + " B";
}
function loadPrism() { function loadPrism() {
if (window.Prism) { if (window.Prism) {
return Promise.resolve(); return Promise.resolve();
@@ -655,6 +905,28 @@
return parts.length > 1 ? parts.pop() : ""; return parts.length > 1 ? parts.pop() : "";
} }
function isArchiveFile(extension, baseType) {
var archiveExtensions = {
"apk": true,
"docx": true,
"ear": true,
"epub": true,
"jar": true,
"pptx": true,
"war": true,
"xlsx": true,
"zip": true
};
var archiveTypes = {
"application/epub+zip": true,
"application/java-archive": true,
"application/vnd.android.package-archive": true,
"application/x-zip-compressed": true,
"application/zip": true
};
return Boolean(archiveExtensions[extension] || archiveTypes[baseType]);
}
function languageFor(extension, baseType) { function languageFor(extension, baseType) {
var extensionMap = { var extensionMap = {
"c": "c", "c": "c",

View File

@@ -23,7 +23,7 @@
</a> </a>
</header> </header>
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}"> <div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}" data-archive-url="{{.Data.File.ArchiveURL}}">
<div class="preview-window-titlebar"> <div class="preview-window-titlebar">
<div> <div>
<strong data-preview-mode-label>Preview</strong> <strong data-preview-mode-label>Preview</strong>
@@ -57,6 +57,10 @@
<div class="code-preview prism-code-preview" data-code-preview hidden> <div class="code-preview prism-code-preview" data-code-preview hidden>
<pre class="line-numbers"><code data-code-output></code></pre> <pre class="line-numbers"><code data-code-output></code></pre>
</div> </div>
{{if .Data.File.HasArchive}}<div class="archive-browser-preview" data-archive-browser-preview hidden></div>
<div class="archive-preview code-preview" data-archive-preview hidden>
<pre><code data-archive-output></code></pre>
</div>{{end}}
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe> <iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
<div class="large-preview-gate" data-large-preview-gate hidden> <div class="large-preview-gate" data-large-preview-gate hidden>
<strong>Large preview</strong> <strong>Large preview</strong>