micro-filebrowser/service/files.go
2025-06-04 10:49:01 +08:00

608 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path"
"path/filepath"
"strings"
"dubbo.apache.org/dubbo-go/v3/common/logger"
"github.com/dubbogo/grpc-go/metadata"
filesApi "github.com/filebrowser/filebrowser/v2/api/files"
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/fileutils"
"github.com/filebrowser/filebrowser/v2/http"
"github.com/filebrowser/filebrowser/v2/img"
"github.com/filebrowser/filebrowser/v2/rules"
"github.com/filebrowser/filebrowser/v2/search"
"github.com/mholt/archiver/v3"
"github.com/samber/lo"
"github.com/spf13/afero"
)
type FilesProvider struct {
filesApi.UnimplementedFileServer
}
const BASE_PATH = "../"
var imgSvc = img.New(4) // 图片预览可用协程数
func (f *FilesProvider) List(ctx context.Context, req *filesApi.FileListReq) (*filesApi.FileListResp, error) {
file, err := files.NewFileInfo(&files.FileOptions{
Fs: getFs(req.UserSpacePath),
Path: req.Path,
Modify: true,
Expand: true,
ReadHeader: true,
Content: true,
Checker: rules.EmptyChecker,
})
if err != nil {
return nil, err
}
if !file.IsDir {
return nil, errors.New("路径不是文件夹")
}
file.Listing.Sorting.Asc = req.Sorting.Asc
file.Listing.Sorting.By = req.Sorting.By
file.Listing.ApplySort()
result := &filesApi.FileListResp{
NumDirs: int32(file.NumDirs),
NumFiles: int32(file.NumFiles),
Sorting: &filesApi.Sorting{
By: file.Sorting.By,
Asc: file.Sorting.Asc,
},
Path: file.Path,
Name: file.Name,
Size: file.Size,
Extension: file.Extension,
Mode: file.Mode.String(),
IsDir: file.IsDir,
IsSymlink: file.IsSymlink,
Type: file.Type,
Items: make([]*filesApi.Items, 0, len(file.Listing.Items)),
}
for _, v := range file.Listing.Items {
result.Items = append(result.Items, &filesApi.Items{
Path: v.Path,
Name: v.Name,
Size: v.Size,
Extension: v.Extension,
Mode: v.Mode.String(),
IsDir: v.IsDir,
IsSymlink: v.IsSymlink,
Type: v.Type,
})
}
return result, err
}
func (f *FilesProvider) Create(ctx context.Context, req *filesApi.CreateReq) (*filesApi.CreateResp, error) {
fs := getFs(req.UserSpacePath)
if strings.HasSuffix(req.Path, "/") {
return new(filesApi.CreateResp), fs.MkdirAll(req.Path, files.PermDir)
}
_, err := fs.Create(req.Path)
return new(filesApi.CreateResp), err
}
func (f *FilesProvider) Delete(ctx context.Context, req *filesApi.DeleteReq) (*filesApi.DeleteResp, error) {
fs := getFs(req.UserSpacePath)
return new(filesApi.DeleteResp), fs.RemoveAll(req.Path)
}
func (f *FilesProvider) Upload(ctx context.Context, req *filesApi.UploadReq) (*filesApi.UploadResp, error) {
fs := getFs(req.UserSpacePath)
fi, err := fs.Create(req.Path)
if err != nil {
return nil, err
}
defer fi.Close()
_, err = fi.Write(req.Content)
if err != nil {
return nil, err
}
return new(filesApi.UploadResp), nil
}
func (f *FilesProvider) Search(ctx context.Context, req *filesApi.SearchReq) (*filesApi.SearchResp, error) {
fs := getFs(req.UserSpacePath)
result := new(filesApi.SearchResp)
err := search.Search(fs, req.Path, req.Query, rules.EmptyChecker, func(path string, f os.FileInfo) error {
result.Items = append(result.Items, &filesApi.SearchResp_Nested{
Dir: f.IsDir(),
Path: path,
})
return nil
})
if err != nil {
return nil, err
}
return result, err
}
func (f *FilesProvider) TusCreate(ctx context.Context, req *filesApi.TusCreateReq) (*filesApi.TusCreateResp, error) {
fs := getFs(req.UserSpacePath)
file, err := files.NewFileInfo(&files.FileOptions{
Fs: fs,
Path: req.Path,
Modify: true,
Expand: false,
ReadHeader: true,
Checker: rules.EmptyChecker,
})
switch {
case errors.Is(err, afero.ErrFileNotFound):
dirPath := filepath.Dir(req.Path)
if _, statErr := fs.Stat(dirPath); os.IsNotExist(statErr) {
if mkdirErr := fs.MkdirAll(dirPath, files.PermDir); mkdirErr != nil {
return nil, err
}
}
case err != nil:
return nil, err
}
fileFlags := os.O_CREATE | os.O_WRONLY
if req.Override {
fileFlags |= os.O_TRUNC
}
// if file exists
if file != nil {
if file.IsDir {
return nil, fmt.Errorf("上传路径不能是文件夹 %s", file.RealPath())
}
}
openFile, err := fs.OpenFile(req.Path, fileFlags, files.PermFile)
if err != nil {
return nil, err
}
if err := openFile.Close(); err != nil {
return nil, err
}
logger.Infof("创建空文件,%v", openFile.Name())
return &filesApi.TusCreateResp{
UploadLength: -1,
UploadOffset: 0,
}, nil
}
func (f *FilesProvider) TusUpload(ctx context.Context, req *filesApi.TusUploadReq) (*filesApi.TusUploadResp, error) {
fs := getFs(req.UserSpacePath)
file, err := files.NewFileInfo(&files.FileOptions{
Fs: fs,
Path: req.Path,
Modify: true,
Expand: false,
ReadHeader: true,
Checker: rules.EmptyChecker,
})
switch {
case errors.Is(err, afero.ErrFileNotFound):
return nil, errors.New("未找到文件")
case err != nil:
return nil, err
}
switch {
case file.IsDir:
return nil, fmt.Errorf("上传路径不能是文件夹 %s", file.RealPath())
case file.Size != req.UploadOffset:
return nil, fmt.Errorf(
"%s 文件大小符合: %d",
file.RealPath(),
req.UploadOffset,
)
}
openFile, err := fs.OpenFile(req.Path, os.O_WRONLY|os.O_APPEND, files.PermFile)
if err != nil {
return nil, fmt.Errorf("could not open file: %w", err)
}
defer openFile.Close()
_, err = openFile.Seek(req.UploadOffset, 0)
if err != nil {
return nil, fmt.Errorf("could not seek file: %w", err)
}
logger.Infof("写入文件块offset:%v块大小%vbyte", req.UploadOffset, len(req.Content))
bytesWritten, err := io.Copy(openFile, bytes.NewBuffer(req.Content))
if err != nil {
return nil, fmt.Errorf("could not write to file: %w", err)
}
return &filesApi.TusUploadResp{
UploadOffset: req.UploadOffset + bytesWritten,
}, nil
}
func (f *FilesProvider) Info(ctx context.Context, req *filesApi.FileInfoReq) (*filesApi.FileInfoResp, error) {
fs := getFs(req.UserSpacePath)
file, err := files.NewFileInfo(&files.FileOptions{
Fs: fs,
Path: req.Path,
Modify: true,
Expand: false,
ReadHeader: true,
Checker: rules.EmptyChecker,
})
switch {
case errors.Is(err, afero.ErrFileNotFound):
return nil, errors.New("未找到文件")
case err != nil:
return nil, err
}
result := &filesApi.FileInfoResp{
Path: file.Path,
Name: file.Name,
Size: file.Size,
Extension: file.Extension,
Mode: file.Mode.String(),
IsDir: file.IsDir,
IsSymlink: file.IsSymlink,
Type: file.Type,
}
return result, nil
}
func (f *FilesProvider) ResumableTransfer(ctx context.Context, req *filesApi.ResumableTransferReq) (*filesApi.ResumableTransferResp, error) {
fs := getFs(req.UserSpacePath)
_, err := files.NewFileInfo(&files.FileOptions{
Fs: fs,
Path: req.Path,
Modify: true,
Expand: false,
ReadHeader: true,
Checker: rules.EmptyChecker,
})
switch {
case errors.Is(err, afero.ErrFileNotFound):
return nil, errors.New("未找到文件")
case err != nil:
return nil, err
}
openFile, err := fs.Open(req.Path)
if err != nil {
return nil, fmt.Errorf("could not open file: %w", err)
}
defer openFile.Close()
logger.Debugf("设置文件读取offset:%vlength%v", req.Offset, req.Length)
_, err = openFile.Seek(req.Offset, 0)
if err != nil {
return nil, err
}
b := make([]byte, req.Length)
n, err := openFile.Read(b)
if err != nil {
return nil, err
}
return &filesApi.ResumableTransferResp{
Content: b[:n],
}, nil
}
func (f *FilesProvider) Preview(ctx context.Context, req *filesApi.PreviewReq) (*filesApi.PreviewResp, error) {
fs := getFs(req.UserSpacePath)
file, err := files.NewFileInfo(&files.FileOptions{
Fs: fs,
Path: req.Path,
Modify: true,
Expand: true,
ReadHeader: true,
Checker: rules.EmptyChecker,
})
if err != nil {
return nil, err
}
switch file.Type {
case "image":
{
b, err := f.createPreview(imgSvc, file, req.Size)
if err != nil {
return nil, err
}
return &filesApi.PreviewResp{
Content: b,
FileName: file.Name,
ModTime: file.ModTime.UnixMilli(),
}, nil
}
default:
return nil, errors.New("目前只支持图片类型的预览")
}
}
func (f *FilesProvider) createPreview(imgSvc http.ImgService,
file *files.FileInfo, previewSize uint32) ([]byte, error) {
fd, err := file.Fs.Open(file.Path)
if err != nil {
return nil, err
}
defer fd.Close()
var (
width int
height int
options []img.Option
)
switch {
case previewSize == 1:
width = 1080
height = 1080
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
case previewSize == 0:
width = 256
height = 256
options = append(options, img.WithMode(img.ResizeModeFill), img.WithQuality(img.QualityLow), img.WithFormat(img.FormatJpeg))
default:
return nil, img.ErrUnsupportedFormat
}
buf := &bytes.Buffer{}
if err := imgSvc.Resize(context.Background(), fd, width, height, buf, options...); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (f *FilesProvider) Action(_ context.Context, req *filesApi.ActionReq) (*filesApi.ActionResp, error) {
fs := getFs(req.UserSpacePath)
if req.Destination == "/" || req.Path == "/" {
return nil, errors.New("禁止操作用户空间根目录")
}
err := checkParent(req.Path, req.Destination)
if err != nil {
return nil, err
}
if !req.Override && !req.Rename {
if _, err = fs.Stat(req.Destination); err == nil {
return nil, err
}
}
if req.Rename {
req.Destination = addVersionSuffix(req.Destination, fs)
}
return &filesApi.ActionResp{}, patchAction(req.Action, req.Path, req.Destination, fs)
}
func (f *FilesProvider) DirDownload(req *filesApi.DirDownloadReq, stream filesApi.File_DirDownloadServer) error {
var filenames []string
writer := newFileDownloadWriter(stream)
fs := getFs(req.UserSpacePath)
file, err := files.NewFileInfo(&files.FileOptions{
Fs: fs,
Path: req.Path,
Modify: true,
Expand: false,
ReadHeader: true,
Checker: rules.EmptyChecker,
})
if err != nil {
return err
}
if len(req.Files) == 0 {
filenames = append(filenames, file.Path)
} else {
filenames = lo.Map(req.Files, func(name string, _ int) string {
return filepath.Join(req.Path, slashClean(name))
})
}
extension, ar, err := parseQueryAlgorithm(req.Algo)
if err != nil {
return err
}
err = ar.Create(writer)
if err != nil {
return err
}
defer ar.Close()
commonDir := fileutils.CommonPrefix(filepath.Separator, filenames...)
name := filepath.Base(commonDir)
if name == "." || name == "" || name == string(filepath.Separator) {
name = file.Name
}
// Prefix used to distinguish a filelist generated
// archive from the full directory archive
if len(filenames) > 1 {
name = "_" + name
}
name += extension
stream.SendHeader(metadata.MD{
"filename": []string{name},
})
for _, fname := range filenames {
err = addFile(ar, fs, fname, commonDir)
if err != nil {
log.Printf("Failed to archive %s: %v", fname, err)
}
}
return nil
}
type fileDownloadWriter struct { // 用于实现archiver.Writer.Create(out io.Writer) error
stream filesApi.File_DirDownloadServer
}
func (f *fileDownloadWriter) Write(p []byte) (n int, err error) {
err = f.stream.Send(&filesApi.DirDownloadResp{
Content: p,
})
if err == nil {
n = len(p)
}
return
}
func newFileDownloadWriter(stream filesApi.File_DirDownloadServer) *fileDownloadWriter {
return &fileDownloadWriter{
stream: stream,
}
}
func getFs(userSpacePath string) afero.Fs {
bashAbs, _ := filepath.Abs(BASE_PATH)
if !strings.HasPrefix(userSpacePath, "/") {
userSpacePath = "/" + userSpacePath
}
_, err := os.Stat(bashAbs + userSpacePath)
if err != nil {
os.MkdirAll(bashAbs+userSpacePath, os.ModeDir)
}
return afero.NewBasePathFs(afero.NewOsFs(), bashAbs+userSpacePath)
}
func checkParent(src, dst string) error {
rel, err := filepath.Rel(src, dst)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, "../") && rel != ".." && rel != "." {
return fbErrors.ErrSourceIsParent
}
return nil
}
func addVersionSuffix(source string, fs afero.Fs) string {
counter := 1
dir, name := path.Split(source)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for {
if _, err := fs.Stat(source); err != nil {
break
}
renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext)
source = path.Join(dir, renamed)
counter++
}
return source
}
func patchAction(action, src, dst string, fs afero.Fs) error {
switch action {
case "copy":
return fileutils.Copy(fs, src, dst)
case "rename":
src = path.Clean("/" + src)
dst = path.Clean("/" + dst)
_, err := files.NewFileInfo(&files.FileOptions{
Fs: fs,
Path: src,
Modify: true,
Expand: false,
ReadHeader: false,
Checker: rules.EmptyChecker,
})
if err != nil {
return err
}
return fileutils.MoveFile(fs, src, dst)
default:
return fmt.Errorf("unsupported action %s: %w", action, fbErrors.ErrInvalidRequestParams)
}
}
func slashClean(name string) string {
if name == "" || name[0] != '/' {
name = "/" + name
}
return path.Clean(name)
}
func parseQueryAlgorithm(algo string) (string, archiver.Writer, error) {
switch algo {
case "zip", "true", "":
return ".zip", archiver.NewZip(), nil
case "tar":
return ".tar", archiver.NewTar(), nil
case "targz":
return ".tar.gz", archiver.NewTarGz(), nil
case "tarbz2":
return ".tar.bz2", archiver.NewTarBz2(), nil
case "tarxz":
return ".tar.xz", archiver.NewTarXz(), nil
case "tarlz4":
return ".tar.lz4", archiver.NewTarLz4(), nil
case "tarsz":
return ".tar.sz", archiver.NewTarSz(), nil
default:
return "", nil, errors.New("format not implemented")
}
}
func addFile(ar archiver.Writer, fs afero.Fs, path, commonPath string) error {
info, err := fs.Stat(path)
if err != nil {
return err
}
if !info.IsDir() && !info.Mode().IsRegular() {
return nil
}
file, err := fs.Open(path)
if err != nil {
return err
}
defer file.Close()
if path != commonPath {
filename := strings.TrimPrefix(path, commonPath)
filename = strings.TrimPrefix(filename, string(filepath.Separator))
err = ar.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: filename,
},
ReadCloser: file,
})
if err != nil {
return err
}
}
if info.IsDir() {
names, err := file.Readdirnames(0)
if err != nil {
return err
}
for _, name := range names {
fPath := filepath.Join(path, name)
err = addFile(ar, fs, fPath, commonPath)
if err != nil {
log.Printf("Failed to archive %s: %v", fPath, err)
}
}
}
return nil
}