349 lines
7.3 KiB
Go
349 lines
7.3 KiB
Go
package files
|
|
|
|
import (
|
|
"crypto/md5" //nolint:gosec
|
|
"crypto/sha1" //nolint:gosec
|
|
"crypto/sha256"
|
|
"crypto/sha512"
|
|
"encoding/hex"
|
|
"hash"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
|
|
"github.com/filebrowser/filebrowser/v2/errors"
|
|
"github.com/filebrowser/filebrowser/v2/rules"
|
|
)
|
|
|
|
// FileInfo describes a file.
|
|
type FileInfo struct {
|
|
*Listing
|
|
Fs afero.Fs `json:"-"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
Extension string `json:"extension"`
|
|
ModTime time.Time `json:"modified"`
|
|
Mode os.FileMode `json:"mode"`
|
|
IsDir bool `json:"isDir"`
|
|
IsSymlink bool `json:"isSymlink"`
|
|
Type string `json:"type"`
|
|
Subtitles []string `json:"subtitles,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Checksums map[string]string `json:"checksums,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
}
|
|
|
|
// FileOptions are the options when getting a file info.
|
|
type FileOptions struct {
|
|
Fs afero.Fs
|
|
Path string
|
|
Modify bool
|
|
Expand bool
|
|
ReadHeader bool
|
|
Token string
|
|
Checker rules.Checker
|
|
Content bool
|
|
}
|
|
|
|
// NewFileInfo creates a File object from a path and a given user. This File
|
|
// object will be automatically filled depending on if it is a directory
|
|
// or a file. If it's a video file, it will also detect any subtitles.
|
|
func NewFileInfo(opts FileOptions) (*FileInfo, error) {
|
|
if !opts.Checker.Check(opts.Path) {
|
|
return nil, os.ErrPermission
|
|
}
|
|
|
|
file, err := stat(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if opts.Expand {
|
|
if file.IsDir {
|
|
if err := file.readListing(opts.Checker, opts.ReadHeader); err != nil { //nolint:govet
|
|
return nil, err
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
err = file.detectType(opts.Modify, opts.Content, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return file, err
|
|
}
|
|
|
|
func stat(opts FileOptions) (*FileInfo, error) {
|
|
var file *FileInfo
|
|
|
|
if lstaterFs, ok := opts.Fs.(afero.Lstater); ok {
|
|
info, _, err := lstaterFs.LstatIfPossible(opts.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
file = &FileInfo{
|
|
Fs: opts.Fs,
|
|
Path: opts.Path,
|
|
Name: info.Name(),
|
|
ModTime: info.ModTime(),
|
|
Mode: info.Mode(),
|
|
IsDir: info.IsDir(),
|
|
IsSymlink: IsSymlink(info.Mode()),
|
|
Size: info.Size(),
|
|
Extension: filepath.Ext(info.Name()),
|
|
Token: opts.Token,
|
|
}
|
|
}
|
|
|
|
// regular file
|
|
if file != nil && !file.IsSymlink {
|
|
return file, nil
|
|
}
|
|
|
|
// fs doesn't support afero.Lstater interface or the file is a symlink
|
|
info, err := opts.Fs.Stat(opts.Path)
|
|
if err != nil {
|
|
// can't follow symlink
|
|
if file != nil && file.IsSymlink {
|
|
return file, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// set correct file size in case of symlink
|
|
if file != nil && file.IsSymlink {
|
|
file.Size = info.Size()
|
|
file.IsDir = info.IsDir()
|
|
return file, nil
|
|
}
|
|
|
|
file = &FileInfo{
|
|
Fs: opts.Fs,
|
|
Path: opts.Path,
|
|
Name: info.Name(),
|
|
ModTime: info.ModTime(),
|
|
Mode: info.Mode(),
|
|
IsDir: info.IsDir(),
|
|
Size: info.Size(),
|
|
Extension: filepath.Ext(info.Name()),
|
|
Token: opts.Token,
|
|
}
|
|
|
|
return file, nil
|
|
}
|
|
|
|
// Checksum checksums a given File for a given User, using a specific
|
|
// algorithm. The checksums data is saved on File object.
|
|
func (i *FileInfo) Checksum(algo string) error {
|
|
if i.IsDir {
|
|
return errors.ErrIsDirectory
|
|
}
|
|
|
|
if i.Checksums == nil {
|
|
i.Checksums = map[string]string{}
|
|
}
|
|
|
|
reader, err := i.Fs.Open(i.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer reader.Close()
|
|
|
|
var h hash.Hash
|
|
|
|
//nolint:gosec
|
|
switch algo {
|
|
case "md5":
|
|
h = md5.New()
|
|
case "sha1":
|
|
h = sha1.New()
|
|
case "sha256":
|
|
h = sha256.New()
|
|
case "sha512":
|
|
h = sha512.New()
|
|
default:
|
|
return errors.ErrInvalidOption
|
|
}
|
|
|
|
_, err = io.Copy(h, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
i.Checksums[algo] = hex.EncodeToString(h.Sum(nil))
|
|
return nil
|
|
}
|
|
|
|
//nolint:goconst
|
|
//TODO: use constants
|
|
func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
|
|
if IsNamedPipe(i.Mode) {
|
|
i.Type = "blob"
|
|
return nil
|
|
}
|
|
// failing to detect the type should not return error.
|
|
// imagine the situation where a file in a dir with thousands
|
|
// of files couldn't be opened: we'd have immediately
|
|
// a 500 even though it doesn't matter. So we just log it.
|
|
|
|
mimetype := mime.TypeByExtension(i.Extension)
|
|
|
|
var buffer []byte
|
|
if readHeader {
|
|
buffer = i.readFirstBytes()
|
|
|
|
if mimetype == "" {
|
|
mimetype = http.DetectContentType(buffer)
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(mimetype, "video"):
|
|
i.Type = "video"
|
|
i.detectSubtitles()
|
|
return nil
|
|
case strings.HasPrefix(mimetype, "audio"):
|
|
i.Type = "audio"
|
|
return nil
|
|
case strings.HasPrefix(mimetype, "image"):
|
|
i.Type = "image"
|
|
return nil
|
|
case (strings.HasPrefix(mimetype, "text") || !isBinary(buffer)) && i.Size <= 10*1024*1024: // 10 MB
|
|
i.Type = "text"
|
|
|
|
if !modify {
|
|
i.Type = "textImmutable"
|
|
}
|
|
|
|
if saveContent {
|
|
afs := &afero.Afero{Fs: i.Fs}
|
|
content, err := afs.ReadFile(i.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
i.Content = string(content)
|
|
}
|
|
return nil
|
|
default:
|
|
i.Type = "blob"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (i *FileInfo) readFirstBytes() []byte {
|
|
reader, err := i.Fs.Open(i.Path)
|
|
if err != nil {
|
|
log.Print(err)
|
|
i.Type = "blob"
|
|
return nil
|
|
}
|
|
defer reader.Close()
|
|
|
|
buffer := make([]byte, 512) //nolint:gomnd
|
|
n, err := reader.Read(buffer)
|
|
if err != nil && err != io.EOF {
|
|
log.Print(err)
|
|
i.Type = "blob"
|
|
return nil
|
|
}
|
|
|
|
return buffer[:n]
|
|
}
|
|
|
|
func (i *FileInfo) detectSubtitles() {
|
|
if i.Type != "video" {
|
|
return
|
|
}
|
|
|
|
i.Subtitles = []string{}
|
|
ext := filepath.Ext(i.Path)
|
|
|
|
// detect multiple languages. Base*.vtt
|
|
// TODO: give subtitles descriptive names (lang) and track attributes
|
|
parentDir := strings.TrimRight(i.Path, i.Name)
|
|
dir, err := afero.ReadDir(i.Fs, parentDir)
|
|
if err == nil {
|
|
base := strings.TrimSuffix(i.Name, ext)
|
|
for _, f := range dir {
|
|
if !f.IsDir() && strings.HasPrefix(f.Name(), base) && strings.HasSuffix(f.Name(), ".vtt") {
|
|
i.Subtitles = append(i.Subtitles, path.Join(parentDir, f.Name()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
|
|
afs := &afero.Afero{Fs: i.Fs}
|
|
dir, err := afs.ReadDir(i.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
listing := &Listing{
|
|
Items: []*FileInfo{},
|
|
NumDirs: 0,
|
|
NumFiles: 0,
|
|
}
|
|
|
|
for _, f := range dir {
|
|
name := f.Name()
|
|
fPath := path.Join(i.Path, name)
|
|
|
|
if !checker.Check(fPath) {
|
|
continue
|
|
}
|
|
|
|
isSymlink := false
|
|
if IsSymlink(f.Mode()) {
|
|
isSymlink = true
|
|
// It's a symbolic link. We try to follow it. If it doesn't work,
|
|
// we stay with the link information instead of the target's.
|
|
info, err := i.Fs.Stat(fPath)
|
|
if err == nil {
|
|
f = info
|
|
}
|
|
}
|
|
|
|
file := &FileInfo{
|
|
Fs: i.Fs,
|
|
Name: name,
|
|
Size: f.Size(),
|
|
ModTime: f.ModTime(),
|
|
Mode: f.Mode(),
|
|
IsDir: f.IsDir(),
|
|
IsSymlink: isSymlink,
|
|
Extension: filepath.Ext(name),
|
|
Path: fPath,
|
|
}
|
|
|
|
if file.IsDir {
|
|
listing.NumDirs++
|
|
} else {
|
|
listing.NumFiles++
|
|
|
|
err := file.detectType(true, false, readHeader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
listing.Items = append(listing.Items, file)
|
|
}
|
|
|
|
i.Listing = listing
|
|
return nil
|
|
}
|