package files import ( "crypto/md5" //nolint:gosec "crypto/sha1" //nolint:gosec "crypto/sha256" "crypto/sha512" "encoding/hex" "errors" "hash" "image" "io" "io/fs" "log" "mime" "net/http" "os" "path" "path/filepath" "regexp" "strings" "time" "github.com/spf13/afero" fbErrors "github.com/filebrowser/filebrowser/v2/errors" "github.com/filebrowser/filebrowser/v2/rules" ) const PermFile = 0644 const PermDir = 0755 var ( reSubDirs = regexp.MustCompile("(?i)^sub(s|titles)$") reSubExts = regexp.MustCompile("(?i)(.vtt|.srt|.ass|.ssa)$") ) // 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"` currentDir []os.FileInfo `json:"-"` Resolution *ImageResolution `json:"resolution,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 } type ImageResolution struct { Width int `json:"width"` Height int `json:"height"` } // 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 fbErrors.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 fbErrors.ErrInvalidOption } _, err = io.Copy(h, reader) if err != nil { return err } i.Checksums[algo] = hex.EncodeToString(h.Sum(nil)) return nil } func (i *FileInfo) RealPath() string { if realPathFs, ok := i.Fs.(interface { RealPath(name string) (fPath string, err error) }); ok { realPath, err := realPathFs.RealPath(i.Path) if err == nil { return realPath } } return i.Path } //nolint:goconst 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" resolution, err := calculateImageResolution(i.Fs, i.Path) if err != nil { log.Printf("Error calculating image resolution: %v", err) } else { i.Resolution = resolution } 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 calculateImageResolution(fSys afero.Fs, filePath string) (*ImageResolution, error) { file, err := fSys.Open(filePath) if err != nil { return nil, err } defer func() { if cErr := file.Close(); cErr != nil { log.Printf("Failed to close file: %v", cErr) } }() config, _, err := image.DecodeConfig(file) if err != nil { return nil, err } return &ImageResolution{ Width: config.Width, Height: config.Height, }, 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 && !errors.Is(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 parentDir := strings.TrimRight(i.Path, i.Name) var dir []os.FileInfo if len(i.currentDir) > 0 { dir = i.currentDir } else { var err error dir, err = afero.ReadDir(i.Fs, parentDir) if err != nil { return } } base := strings.TrimSuffix(i.Name, ext) for _, f := range dir { // load all supported subtitles from subs directories // should cover all instances of subtitle distributions // like tv-shows with multiple episodes in single dir if f.IsDir() && reSubDirs.MatchString(f.Name()) { subsDir := path.Join(parentDir, f.Name()) i.loadSubtitles(subsDir, base, true) } else if isSubtitleMatch(f, base) { i.addSubtitle(path.Join(parentDir, f.Name())) } } } func (i *FileInfo) loadSubtitles(subsPath, baseName string, recursive bool) { dir, err := afero.ReadDir(i.Fs, subsPath) if err == nil { for _, f := range dir { if isSubtitleMatch(f, "") { i.addSubtitle(path.Join(subsPath, f.Name())) } else if f.IsDir() && recursive && strings.HasPrefix(f.Name(), baseName) { subsDir := path.Join(subsPath, f.Name()) i.loadSubtitles(subsDir, baseName, false) } } } } func IsSupportedSubtitle(fileName string) bool { return reSubExts.MatchString(fileName) } func isSubtitleMatch(f fs.FileInfo, baseName string) bool { return !f.IsDir() && strings.HasPrefix(f.Name(), baseName) && IsSupportedSubtitle(f.Name()) } func (i *FileInfo) addSubtitle(fPath string) { i.Subtitles = append(i.Subtitles, fPath) } 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, isInvalidLink := false, 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 } else { isInvalidLink = true } } 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, currentDir: dir, } if !file.IsDir && strings.HasPrefix(mime.TypeByExtension(file.Extension), "image/") { resolution, err := calculateImageResolution(file.Fs, file.Path) if err != nil { log.Printf("Error calculating resolution for image %s: %v", file.Path, err) } else { file.Resolution = resolution } } if file.IsDir { listing.NumDirs++ } else { listing.NumFiles++ if isInvalidLink { file.Type = "invalid_link" } else { err := file.detectType(true, false, readHeader) if err != nil { return err } } } listing.Items = append(listing.Items, file) } i.Listing = listing return nil }