mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2026-10-20 08:16:40 +00:00
311 lines
8.7 KiB
Go
311 lines
8.7 KiB
Go
package files
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
. "github.com/mickael-kerjean/filestash/server/pkg/config"
|
|
. "github.com/mickael-kerjean/filestash/server/pkg/core"
|
|
. "github.com/mickael-kerjean/filestash/server/pkg/env"
|
|
. "github.com/mickael-kerjean/filestash/server/pkg/kernel"
|
|
. "github.com/mickael-kerjean/filestash/server/pkg/mime"
|
|
. "github.com/mickael-kerjean/filestash/server/pkg/permissions"
|
|
. "github.com/mickael-kerjean/filestash/server/pkg/utils"
|
|
)
|
|
|
|
var (
|
|
fileCache AppCache
|
|
bufferPool = sync.Pool{
|
|
New: func() any {
|
|
return new([]byte)
|
|
},
|
|
}
|
|
)
|
|
|
|
func init() {
|
|
fileCache = NewAppCache()
|
|
fileCache.OnEvict(func(key string, value interface{}) {
|
|
if tmpPath, _, ok := strings.Cut(value.(string), "::"); ok {
|
|
os.RemoveAll(tmpPath)
|
|
}
|
|
})
|
|
}
|
|
|
|
func FileCat(ctx *App, res http.ResponseWriter, req *http.Request) {
|
|
var (
|
|
file io.ReadCloser
|
|
fileMutation bool = false
|
|
contentLength int64 = -1
|
|
needToCreateCache bool = false
|
|
query url.Values = req.URL.Query()
|
|
header http.Header = res.Header()
|
|
)
|
|
http.SetCookie(res, &http.Cookie{
|
|
Name: "download",
|
|
Value: "",
|
|
MaxAge: -1,
|
|
Path: "/",
|
|
})
|
|
if CanRead(ctx) == false {
|
|
Log.Debug("cat::permission 'permission denied'")
|
|
SendErrorResult(res, ErrPermissionDenied)
|
|
return
|
|
}
|
|
path, err := PathBuilder(ctx, query.Get("path"))
|
|
if err != nil {
|
|
Log.Debug("cat::path '%s'", err.Error())
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
|
|
for _, auth := range Hooks.Get.AuthorisationMiddleware() {
|
|
if req.Method == http.MethodHead {
|
|
if err = auth.Stat(ctx, path); err != nil {
|
|
SendErrorResult(res, ErrNotAuthorized)
|
|
return
|
|
}
|
|
} else if err = auth.Cat(ctx, path); err != nil {
|
|
SendErrorResult(res, ErrNotAuthorized)
|
|
return
|
|
}
|
|
}
|
|
|
|
// use our cache if necessary (range request) when possible
|
|
var mtime string
|
|
if req.Header.Get("Range") != "" {
|
|
if finfo, err := ctx.Backend.Stat(path); err == nil {
|
|
mtime = strconv.FormatInt(finfo.ModTime().Unix(), 10)
|
|
}
|
|
ctx.Session["fullpath"] = path
|
|
if p := fileCache.Get(ctx.Session); p != nil {
|
|
if tmpPath, cachedMtime, _ := strings.Cut(p.(string), "::"); cachedMtime != mtime {
|
|
fileCache.Del(ctx.Session)
|
|
} else if f, err := os.OpenFile(tmpPath, os.O_RDONLY, os.ModePerm); err == nil {
|
|
file = f
|
|
if fi, err := f.Stat(); err == nil {
|
|
contentLength = fi.Size()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// perform the actual `cat` if needed
|
|
mType := GetMimeType(query.Get("path"))
|
|
if file == nil {
|
|
if file, err = ctx.Backend.Cat(path); err != nil {
|
|
if req.Method == http.MethodHead {
|
|
var finfo os.FileInfo
|
|
if finfo, err = ctx.Backend.Stat(path); err == nil && finfo.IsDir() {
|
|
if finfo.ModTime().Unix() > 0 {
|
|
header.Set("Last-Modified", finfo.ModTime().UTC().Format(http.TimeFormat))
|
|
}
|
|
header.Set("Content-Type", "inode/directory")
|
|
res.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
}
|
|
Log.Debug("cat::backend '%s'", err.Error())
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
if mType == "application/javascript" {
|
|
mType = "text/plain"
|
|
}
|
|
header.Set("Content-Type", mType)
|
|
if req.Header.Get("Range") != "" {
|
|
needToCreateCache = true
|
|
}
|
|
}
|
|
|
|
// plugin hooks
|
|
thumb := query.Get("thumbnail")
|
|
if thumb == "true" {
|
|
fileMutation = true
|
|
if finfo, err := ctx.Backend.Stat(path); err == nil && finfo.ModTime().Unix() > 0 {
|
|
lm := finfo.ModTime().UTC().Format(http.TimeFormat)
|
|
if lm == req.Header.Get("If-Modified-Since") {
|
|
res.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
header.Set("Last-Modified", lm)
|
|
}
|
|
for plgMType, plgHandler := range Hooks.Get.Thumbnailer() {
|
|
if plgMType != mType {
|
|
continue
|
|
}
|
|
file, err = plgHandler.Generate(file, ctx, &res, req)
|
|
if err != nil {
|
|
if req.Context().Err() == nil {
|
|
Log.Debug("cat::thumbnailer '%s'", err.Error())
|
|
}
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
break
|
|
}
|
|
}
|
|
for _, obj := range Hooks.Get.ProcessFileContentBeforeSend() {
|
|
f, changed, err := obj(file, ctx, &res, req)
|
|
if err != nil {
|
|
Log.Debug("cat::hooks '%s'", err.Error())
|
|
SendErrorResult(res, err)
|
|
return
|
|
} else if changed {
|
|
file = f
|
|
fileMutation = true
|
|
}
|
|
}
|
|
|
|
// The extra complexity is to support: https://en.wikipedia.org/wiki/Progressive_download
|
|
// => range request requires a seeker to work, some backend support it, some don't. 2 strategies:
|
|
// 1. backend support Seek: use what the current backend gives us
|
|
// 2. backend doesn't support Seek: build up a cache so that subsequent call don't trigger multiple downloads
|
|
if req.Header.Get("Range") != "" && needToCreateCache == true {
|
|
if obj, ok := file.(io.Seeker); ok == true {
|
|
if size, err := obj.Seek(0, io.SeekEnd); err == nil {
|
|
if _, err = obj.Seek(0, io.SeekStart); err == nil {
|
|
contentLength = size
|
|
}
|
|
}
|
|
} else {
|
|
tmpPath := GetAbsolutePath(TMP_PATH, "file_"+QuickString(20)+".dat")
|
|
f, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE, os.ModePerm)
|
|
if err != nil {
|
|
Log.Debug("cat::range0 '%s'", err.Error())
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
fileCache.Set(ctx.Session, tmpPath+"::"+mtime)
|
|
if _, err = io.Copy(f, file); err != nil {
|
|
f.Close()
|
|
file.Close()
|
|
Log.Debug("cat::range1 '%s'", err.Error())
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
if err = f.Sync(); err != nil {
|
|
f.Close()
|
|
file.Close()
|
|
Log.Debug("cat::range2 '%s'", err.Error())
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
f.Close()
|
|
file.Close()
|
|
if f, err = os.OpenFile(tmpPath, os.O_RDONLY, os.ModePerm); err != nil {
|
|
Log.Debug("cat::range3 '%s'", err.Error())
|
|
SendErrorResult(res, err)
|
|
return
|
|
}
|
|
if fi, err := f.Stat(); err == nil {
|
|
contentLength = fi.Size()
|
|
}
|
|
file = f
|
|
}
|
|
}
|
|
|
|
// Range request: find how much data we need to send
|
|
var ranges [][]int64
|
|
if req.Header.Get("Range") != "" {
|
|
ranges = make([][]int64, 0)
|
|
for _, r := range strings.Split(strings.TrimPrefix(req.Header.Get("Range"), "bytes="), ",") {
|
|
r = strings.TrimSpace(r)
|
|
if r == "" {
|
|
continue
|
|
}
|
|
var start int64 = -1
|
|
var end int64 = -1
|
|
sides := strings.Split(r, "-")
|
|
if len(sides) == 2 {
|
|
if start, err = strconv.ParseInt(sides[0], 10, 64); err != nil || start < 0 {
|
|
start = 0
|
|
}
|
|
if end, err = strconv.ParseInt(sides[1], 10, 64); err != nil || end < start {
|
|
end = contentLength - 1
|
|
}
|
|
}
|
|
if start != -1 && end != -1 && end-start >= 0 {
|
|
ranges = append(ranges, []int64{start, end})
|
|
}
|
|
}
|
|
} else if fileMutation == false && contentLength < 0 {
|
|
if finfo, err := ctx.Backend.Stat(path); err == nil {
|
|
if finfo.ModTime().Unix() > 0 {
|
|
header.Set("Last-Modified", finfo.ModTime().UTC().Format(http.TimeFormat))
|
|
}
|
|
if finfo.IsDir() {
|
|
header.Set("Content-Type", "inode/directory")
|
|
if req.Method == http.MethodHead {
|
|
res.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
SendErrorResult(res, ErrNotFound)
|
|
return
|
|
}
|
|
contentLength = finfo.Size()
|
|
}
|
|
}
|
|
|
|
// publish headers
|
|
if contentLength >= 0 {
|
|
header.Set("Content-Length", strconv.FormatInt(contentLength, 10))
|
|
}
|
|
if disable_csp() == false {
|
|
header.Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; font-src data:; script-src-elem 'self'")
|
|
}
|
|
if fname := query.Get("name"); fname != "" {
|
|
header.Set("Content-Disposition", "attachment; filename=\""+fname+"\"")
|
|
}
|
|
header.Set("Accept-Ranges", "bytes")
|
|
header.Set("Vary", "Accept")
|
|
|
|
if req.Method == http.MethodHead {
|
|
file.Close()
|
|
return
|
|
}
|
|
size := 32
|
|
if thumb != "true" {
|
|
switch Config.Get("general.buffer_size").String() {
|
|
case "small":
|
|
size = 32
|
|
case "medium":
|
|
size = 128
|
|
case "large":
|
|
size = 2 * 1024
|
|
}
|
|
}
|
|
buf := bufferPool.Get().(*[]byte)
|
|
if len(*buf) < size*1024 {
|
|
*buf = make([]byte, size*1024)
|
|
}
|
|
defer bufferPool.Put(buf)
|
|
if f, ok := file.(io.ReadSeeker); ok && len(ranges) > 0 {
|
|
if _, err = f.Seek(ranges[0][0], io.SeekStart); err == nil {
|
|
header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", ranges[0][0], ranges[0][1], contentLength))
|
|
header.Set("Content-Length", strconv.FormatInt(ranges[0][1]-ranges[0][0]+1, 10))
|
|
res.WriteHeader(http.StatusPartialContent)
|
|
io.CopyBuffer(res, io.LimitReader(f, ranges[0][1]-ranges[0][0]+1), *buf)
|
|
} else {
|
|
res.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
}
|
|
} else if strings.Contains(req.Header.Get("Accept"), "application/vnd.filestash.delta.rdiff") {
|
|
header.Set("Cache-Control", "no-store")
|
|
header.Set("Content-Type", "application/vnd.filestash.delta.rdiff")
|
|
header.Del("Content-Length")
|
|
hasher := sha256.New()
|
|
if err = rdiffSignature(io.TeeReader(file, hasher), res); err == nil {
|
|
_, err = res.Write(hasher.Sum(nil))
|
|
}
|
|
} else {
|
|
io.CopyBuffer(res, file, *buf)
|
|
}
|
|
file.Close()
|
|
}
|