Files
Felis/internal/fileedit/download.go
T

216 lines
5.8 KiB
Go

package fileedit
import (
"archive/zip"
"bytes"
"compress/flate"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
)
// Content types of a download: a file goes out as its bytes, a folder as a zip.
const (
DownloadFileType = "application/octet-stream"
DownloadZipType = "application/zip"
)
// Download is one file or folder of a world on its way to the owner's browser,
// run by the export Job (cmd/felis export --mode files). It passes the same
// guards a read does (Guard): the forwarding-secret file never leaves, and
// server.properties leaves with its RCON password redacted.
type Download struct {
// Size is a file download's exact length, or -1 for a folder, whose zip is
// written as it streams.
Size int64
ContentType string
// Skipped and Withheld count, once WriteTo has run, the entries a folder
// download left out: links, devices and sockets, and guarded files.
Skipped, Withheld int
root *os.Root
name string
file *os.File // a file download
body []byte // a redacted file download
guard Guard
}
// OpenDownload opens name under rootPath for download. dir is what the caller
// saw at name when it asked (the panel's listing): a download of a file that has
// since become a folder, or the reverse, is refused rather than sent as the
// other thing. The world root itself is refused; the world export sends that.
func OpenDownload(rootPath, name string, dir bool) (*Download, error) {
name = path.Clean(name)
if name == "." || name == "/" || !fs.ValidPath(name) {
return nil, fmt.Errorf("%s is not a file or folder inside the world", name)
}
r, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open the world root: %w", err)
}
d := &Download{root: r, name: name, guard: NewGuard(r)}
if err := d.open(dir); err != nil {
r.Close()
return nil, err
}
return d, nil
}
func (d *Download) open(dir bool) error {
info, err := d.root.Stat(d.name)
if err != nil {
return err
}
if info.IsDir() != dir {
if info.IsDir() {
return fmt.Errorf("%s is a folder now; reload the file list and download it again", d.name)
}
return fmt.Errorf("%s is not a folder now; reload the file list and download it again", d.name)
}
if dir {
d.Size, d.ContentType = -1, DownloadZipType
return nil
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", d.name)
}
withhold, redact := d.guard.Rule(info)
if withhold {
return fmt.Errorf("%s is the file holding the proxy forwarding secret, which is shared cluster-wide, and cannot be downloaded", d.name)
}
f, err := d.root.Open(d.name)
if err != nil {
return err
}
d.ContentType = DownloadFileType
if !redact {
d.file, d.Size = f, info.Size()
return nil
}
defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, MaxReadBytes+1))
if err != nil {
return err
}
if len(b) > MaxReadBytes {
return fmt.Errorf("%s is over %d bytes and cannot be redacted for download", d.name, MaxReadBytes)
}
d.body = RedactProps(b)
d.Size = int64(len(d.body))
return nil
}
// Close releases what OpenDownload opened.
func (d *Download) Close() error {
if d.file != nil {
d.file.Close()
}
return d.root.Close()
}
// WriteTo writes the download to w: exactly Size bytes of a file, or a zip of a
// folder whose entries sit under the folder's own name, so unpacking it makes
// that one folder. A file that shrank since it was opened is an error, never a
// short download passed off as whole.
func (d *Download) WriteTo(ctx context.Context, w io.Writer) error {
switch {
case d.body != nil:
_, err := w.Write(d.body)
return err
case d.file != nil:
_, err := io.CopyN(w, ctxReader{ctx, d.file}, d.Size)
if errors.Is(err, io.EOF) {
return fmt.Errorf("%s shrank while it was being downloaded", d.name)
}
return err
}
return d.writeZip(ctx, w)
}
// writeZip streams the folder as a zip. Everything is deflated at the fastest
// level: the Job has one CPU and the owner's connection is the slower end, and
// already-compressed files (jars, region files) come out as stored blocks
// without costing much. Links, devices and sockets are left out, like a world
// export leaves them out.
func (d *Download) writeZip(ctx context.Context, w io.Writer) error {
zw := zip.NewWriter(w)
zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
return flate.NewWriter(out, flate.BestSpeed)
})
base := path.Base(d.name)
err := fs.WalkDir(d.root.FS(), d.name, func(p string, de fs.DirEntry, err error) error {
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
entry := base + p[len(d.name):]
info, err := de.Info()
if err != nil {
return err
}
switch {
case de.IsDir():
hdr := &zip.FileHeader{Name: entry + "/", Modified: info.ModTime()}
hdr.SetMode(info.Mode().Perm() | fs.ModeDir)
_, err := zw.CreateHeader(hdr)
return err
case !de.Type().IsRegular():
d.Skipped++
return nil
}
withhold, redact := d.guard.Rule(info)
if withhold {
d.Withheld++
return nil
}
f, err := d.root.Open(p)
if err != nil {
return err
}
defer f.Close()
var src io.Reader = ctxReader{ctx, f}
if redact {
b, err := io.ReadAll(io.LimitReader(f, MaxReadBytes+1))
if err != nil {
return err
}
if len(b) > MaxReadBytes {
d.Withheld++
return nil
}
src = bytes.NewReader(RedactProps(b))
}
hdr := &zip.FileHeader{Name: entry, Method: zip.Deflate, Modified: info.ModTime()}
hdr.SetMode(info.Mode().Perm())
fw, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
_, err = io.Copy(fw, src)
return err
})
if err != nil {
return err
}
return zw.Close()
}
// ctxReader stops a long copy once ctx is done.
type ctxReader struct {
ctx context.Context
r io.Reader
}
func (c ctxReader) Read(p []byte) (int, error) {
if err := c.ctx.Err(); err != nil {
return 0, err
}
return c.r.Read(p)
}