Skip to content

Commit 17c7b06

Browse files
authored
feat: add ability to download folders as archives (#17)
1 parent 061a013 commit 17c7b06

File tree

7 files changed

+839
-449
lines changed

7 files changed

+839
-449
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Vagrantfile
55
.vagrant/
66
/.idea
77

8+
build/
89
dist/builds/
910
dist/release/
1011
dist/casket_*

caskethttp/browse/browse.go

Lines changed: 165 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ package browse
1818

1919
import (
2020
"bytes"
21+
"compress/flate"
2122
"encoding/json"
23+
"fmt"
24+
"io"
2225
"net/http"
2326
"net/url"
2427
"os"
@@ -29,9 +32,11 @@ import (
2932
"text/template"
3033
"time"
3134

35+
"github.com/dustin/go-humanize"
36+
"github.com/mholt/archiver/v3"
37+
"github.com/rakyll/statik/fs"
3238
"github.com/tmpim/casket/caskethttp/httpserver"
3339
"github.com/tmpim/casket/caskethttp/staticfiles"
34-
"github.com/dustin/go-humanize"
3540
)
3641

3742
const (
@@ -41,6 +46,35 @@ const (
4146
sortByTime = "time"
4247
)
4348

49+
type ArchiveType string
50+
51+
const (
52+
ArchiveZip ArchiveType = "zip"
53+
ArchiveTar ArchiveType = "tar"
54+
ArchiveTarGz ArchiveType = "tar.gz"
55+
ArchiveTarXz ArchiveType = "tar.xz"
56+
ArchiveTarBrotli ArchiveType = "tar.br"
57+
ArchiveTarBz2 ArchiveType = "tar.bz2"
58+
ArchiveTarLz4 ArchiveType = "tar.lz4"
59+
ArchiveTarSz ArchiveType = "tar.sz"
60+
ArchiveTarZstd ArchiveType = "tar.zst"
61+
)
62+
63+
var (
64+
ArchiveTypes = []ArchiveType{ArchiveZip, ArchiveTar, ArchiveTarGz, ArchiveTarXz, ArchiveTarBrotli, ArchiveTarBz2, ArchiveTarLz4, ArchiveTarSz, ArchiveTarZstd}
65+
ArchiveTypeToMime = map[ArchiveType]string{
66+
ArchiveZip: "application/zip",
67+
ArchiveTar: "application/tar",
68+
ArchiveTarGz: "application/tar+gzip",
69+
ArchiveTarXz: "application/tar+xz",
70+
ArchiveTarBrotli: "application/tar+brotli",
71+
ArchiveTarBz2: "application/tar+bzip2",
72+
ArchiveTarLz4: "application/tar+lz4",
73+
ArchiveTarSz: "application/tar+snappy",
74+
ArchiveTarZstd: "application/tar+zstd",
75+
}
76+
)
77+
4478
// Browse is an http.Handler that can show a file listing when
4579
// directories in the given paths are specified.
4680
type Browse struct {
@@ -51,10 +85,11 @@ type Browse struct {
5185

5286
// Config is a configuration for browsing in a particular path.
5387
type Config struct {
54-
PathScope string // the base path the URL must match to enable browsing
55-
Fs staticfiles.FileServer
56-
Variables interface{}
57-
Template *template.Template
88+
PathScope string // the base path the URL must match to enable browsing
89+
Fs staticfiles.FileServer
90+
Variables interface{}
91+
Template *template.Template
92+
ArchiveTypes []ArchiveType
5893
}
5994

6095
// A Listing is the context used to fill out a template.
@@ -86,6 +121,8 @@ type Listing struct {
86121
// If ≠0 then Items have been limited to that many elements.
87122
ItemsLimitedTo int
88123

124+
ArchiveTypes []ArchiveType
125+
89126
// Optional custom variables for use in browse templates.
90127
User interface{}
91128

@@ -286,12 +323,13 @@ func directoryListing(files []os.FileInfo, canGoUp bool, urlPath string, config
286323
}
287324

288325
return Listing{
289-
Name: path.Base(urlPath),
290-
Path: urlPath,
291-
CanGoUp: canGoUp,
292-
Items: fileInfos,
293-
NumDirs: dirCount,
294-
NumFiles: fileCount,
326+
Name: path.Base(urlPath),
327+
Path: urlPath,
328+
CanGoUp: canGoUp,
329+
Items: fileInfos,
330+
NumDirs: dirCount,
331+
NumFiles: fileCount,
332+
ArchiveTypes: config.ArchiveTypes,
295333
}, hasIndexFile
296334
}
297335

@@ -387,7 +425,7 @@ func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
387425
return http.StatusMovedPermanently, nil
388426
}
389427

390-
return b.ServeListing(w, r, requestedFilepath, bc)
428+
return b.ServeListing(w, r, requestedFilepath, info, bc)
391429
}
392430

393431
func (b Browse) loadDirectoryContents(requestedFilepath http.File, urlPath string, config *Config) (*Listing, bool, error) {
@@ -451,7 +489,7 @@ func (b Browse) handleSortOrder(w http.ResponseWriter, r *http.Request, scope st
451489
}
452490

453491
// ServeListing returns a formatted view of 'requestedFilepath' contents'.
454-
func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFilepath http.File, bc *Config) (int, error) {
492+
func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFilepath http.File, info os.FileInfo, bc *Config) (int, error) {
455493
listing, containsIndex, err := b.loadDirectoryContents(requestedFilepath, r.URL.Path, bc)
456494
if err != nil {
457495
switch {
@@ -473,6 +511,20 @@ func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFi
473511
}
474512
listing.User = bc.Variables
475513

514+
// Check if this is an archive request
515+
archiveTypeStr := r.URL.Query().Get("archive")
516+
if archiveTypeStr != "" {
517+
archiveType := ArchiveType(archiveTypeStr)
518+
for _, t := range bc.ArchiveTypes {
519+
if t == archiveType {
520+
return b.ServeArchive(w, r, path.Clean(r.URL.Path), info, archiveType, bc)
521+
}
522+
}
523+
524+
// We cannot produce an archive of this type, return 404 Not Found
525+
return http.StatusNotFound, nil
526+
}
527+
476528
// Copy the query values into the Listing struct
477529
var limit int
478530
listing.Sort, listing.Order, limit, err = b.handleSortOrder(w, r, bc.PathScope)
@@ -509,6 +561,106 @@ func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFi
509561
return http.StatusOK, nil
510562
}
511563

564+
func (b Browse) ServeArchive(w http.ResponseWriter, r *http.Request, dirPath string, dirInfo os.FileInfo, archiveType ArchiveType, bc *Config) (int, error) {
565+
contentType := ArchiveTypeToMime[archiveType]
566+
567+
fileBaseName := path.Base(dirPath)
568+
if fileBaseName == "/" {
569+
fileBaseName = "root"
570+
}
571+
572+
w.Header().Set("Content-Type", contentType)
573+
w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(fileBaseName+"."+string(archiveType)))
574+
575+
writer := archiveType.GetWriter()
576+
err := writer.Create(w)
577+
if err != nil {
578+
return http.StatusInternalServerError, err
579+
}
580+
defer writer.Close()
581+
582+
err = fs.Walk(bc.Fs.Root, dirPath, func(path string, info os.FileInfo, err error) error {
583+
if err != nil {
584+
return err
585+
}
586+
587+
if info == nil {
588+
return fmt.Errorf("file info was nil")
589+
}
590+
591+
if path == dirPath {
592+
return nil // Skip the containing directory
593+
}
594+
595+
var file io.ReadCloser
596+
if info.Mode().IsRegular() {
597+
file, err = bc.Fs.Root.Open(path)
598+
if err != nil {
599+
return fmt.Errorf("%s: opening: %v", path, err)
600+
}
601+
defer file.Close()
602+
}
603+
604+
archiveFileName, err := archiver.NameInArchive(dirInfo, dirPath, path)
605+
if err != nil {
606+
return err
607+
}
608+
609+
err = writer.Write(archiver.File{
610+
FileInfo: archiver.FileInfo{
611+
FileInfo: info,
612+
CustomName: archiveFileName,
613+
},
614+
ReadCloser: file,
615+
})
616+
if err != nil {
617+
return fmt.Errorf("writing file to archive: %v", err)
618+
}
619+
620+
return nil
621+
})
622+
623+
if err != nil {
624+
return http.StatusInternalServerError, err
625+
}
626+
627+
// Returning 0 indicates we intend to stream the file
628+
return 0, nil
629+
}
630+
631+
func (a ArchiveType) GetWriter() archiver.Writer {
632+
switch a {
633+
case ArchiveZip:
634+
return &archiver.Zip{
635+
FileMethod: archiver.Deflate,
636+
CompressionLevel: flate.DefaultCompression,
637+
MkdirAll: true,
638+
SelectiveCompression: true,
639+
ImplicitTopLevelFolder: true,
640+
}
641+
642+
case ArchiveTar:
643+
return &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}
644+
case ArchiveTarGz:
645+
return &archiver.TarGz{Tar: &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}, CompressionLevel: flate.DefaultCompression}
646+
case ArchiveTarXz:
647+
return &archiver.TarXz{Tar: &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}}
648+
case ArchiveTarBrotli:
649+
return &archiver.TarBrotli{Tar: &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}, Quality: 3}
650+
case ArchiveTarBz2:
651+
return &archiver.TarBz2{Tar: &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}, CompressionLevel: 2}
652+
case ArchiveTarLz4:
653+
return &archiver.TarLz4{Tar: &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}, CompressionLevel: 1}
654+
case ArchiveTarSz:
655+
return &archiver.TarSz{Tar: &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}}
656+
case ArchiveTarZstd:
657+
return &archiver.TarZstd{Tar: &archiver.Tar{MkdirAll: true, ImplicitTopLevelFolder: true}}
658+
659+
default:
660+
panic("unknown archive type: " + a)
661+
}
662+
}
663+
512664
func (b Browse) formatAsJSON(listing *Listing, bc *Config) (*bytes.Buffer, error) {
513665
marsh, err := json.Marshal(listing.Items)
514666
if err != nil {

0 commit comments

Comments
 (0)