352 lines
		
	
	
		
			7.3 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			352 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.HasSuffix(mimetype, "pdf"):
 | 
						|
		i.Type = "pdf"
 | 
						|
		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
 | 
						|
}
 |