sftpgo-mirror/internal/httpdtest/httpfsimpl.go
Nicola Murino 784b7585c1
remove end year from Copyright notice in files
so we don't have to update all the files every year

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
2024-01-01 11:31:45 +01:00

545 lines
14 KiB
Go

// Copyright (C) 2019 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package httpdtest
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/shirou/gopsutil/v3/disk"
"github.com/drakkan/sftpgo/v2/internal/util"
)
const (
statPath = "/api/v1/stat"
openPath = "/api/v1/open"
createPath = "/api/v1/create"
renamePath = "/api/v1/rename"
removePath = "/api/v1/remove"
mkdirPath = "/api/v1/mkdir"
chmodPath = "/api/v1/chmod"
chtimesPath = "/api/v1/chtimes"
truncatePath = "/api/v1/truncate"
readdirPath = "/api/v1/readdir"
dirsizePath = "/api/v1/dirsize"
mimetypePath = "/api/v1/mimetype"
statvfsPath = "/api/v1/statvfs"
)
// HTTPFsCallbacks defines additional callbacks to customize the HTTPfs responses
type HTTPFsCallbacks struct {
Readdir func(string) []os.FileInfo
}
// StartTestHTTPFs starts a test HTTP service that implements httpfs
// and listens on the specified port
func StartTestHTTPFs(port int, callbacks *HTTPFsCallbacks) error {
fs := httpFsImpl{
port: port,
callbacks: callbacks,
}
return fs.Run()
}
// StartTestHTTPFsOverUnixSocket starts a test HTTP service that implements httpfs
// and listens on the specified UNIX domain socket path
func StartTestHTTPFsOverUnixSocket(socketPath string) error {
fs := httpFsImpl{
unixSocketPath: socketPath,
}
return fs.Run()
}
type httpFsImpl struct {
router *chi.Mux
basePath string
port int
unixSocketPath string
callbacks *HTTPFsCallbacks
}
type apiResponse struct {
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
func (fs *httpFsImpl) sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
var errorString string
if err != nil {
errorString = err.Error()
}
resp := apiResponse{
Error: errorString,
Message: message,
}
ctx := context.WithValue(r.Context(), render.StatusCtxKey, code)
render.JSON(w, r.WithContext(ctx), resp)
}
func (fs *httpFsImpl) getUsername(r *http.Request) (string, error) {
username, _, ok := r.BasicAuth()
if !ok || username == "" {
return "", os.ErrPermission
}
rootPath := filepath.Join(fs.basePath, username)
_, err := os.Stat(rootPath)
if errors.Is(err, os.ErrNotExist) {
err = os.MkdirAll(rootPath, os.ModePerm)
if err != nil {
return username, err
}
}
return username, nil
}
func (fs *httpFsImpl) getRespStatus(err error) int {
if errors.Is(err, os.ErrPermission) {
return http.StatusForbidden
}
if errors.Is(err, os.ErrNotExist) {
return http.StatusNotFound
}
return http.StatusInternalServerError
}
func (fs *httpFsImpl) stat(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
info, err := os.Stat(fsPath)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
render.JSON(w, r, getStatFromInfo(info))
}
func (fs *httpFsImpl) open(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
var offset int64
if r.URL.Query().Has("offset") {
offset, err = strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
f, err := os.Open(fsPath)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
defer f.Close()
if offset > 0 {
_, err = f.Seek(offset, io.SeekStart)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
}
ctype := mime.TypeByExtension(filepath.Ext(name))
if ctype != "" {
ctype = "application/octet-stream"
}
w.Header().Set("Content-Type", ctype)
_, err = io.Copy(w, f)
if err != nil {
panic(http.ErrAbortHandler)
}
}
func (fs *httpFsImpl) create(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC
if r.URL.Query().Has("flags") {
openFlags, err := strconv.ParseInt(r.URL.Query().Get("flags"), 10, 32)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
if openFlags > 0 {
flags = int(openFlags)
}
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
f, err := os.OpenFile(fsPath, flags, 0666)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
defer f.Close()
_, err = io.Copy(f, r.Body)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
fs.sendAPIResponse(w, r, nil, "upload OK", http.StatusOK)
}
func (fs *httpFsImpl) rename(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
target := r.URL.Query().Get("target")
if target == "" {
fs.sendAPIResponse(w, r, nil, "target path cannot be empty", http.StatusBadRequest)
return
}
name := getNameURLParam(r)
sourcePath := filepath.Join(fs.basePath, username, name)
targetPath := filepath.Join(fs.basePath, username, target)
err = os.Rename(sourcePath, targetPath)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
fs.sendAPIResponse(w, r, nil, "rename OK", http.StatusOK)
}
func (fs *httpFsImpl) remove(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
err = os.Remove(fsPath)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
fs.sendAPIResponse(w, r, nil, "remove OK", http.StatusOK)
}
func (fs *httpFsImpl) mkdir(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
err = os.Mkdir(fsPath, os.ModePerm)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
fs.sendAPIResponse(w, r, nil, "mkdir OK", http.StatusOK)
}
func (fs *httpFsImpl) chmod(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
mode, err := strconv.ParseUint(r.URL.Query().Get("mode"), 10, 32)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
err = os.Chmod(fsPath, os.FileMode(mode))
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
fs.sendAPIResponse(w, r, nil, "chmod OK", http.StatusOK)
}
func (fs *httpFsImpl) chtimes(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
atime, err := time.Parse(time.RFC3339, r.URL.Query().Get("access_time"))
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
mtime, err := time.Parse(time.RFC3339, r.URL.Query().Get("modification_time"))
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
err = os.Chtimes(fsPath, atime, mtime)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
fs.sendAPIResponse(w, r, nil, "chtimes OK", http.StatusOK)
}
func (fs *httpFsImpl) truncate(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
size, err := strconv.ParseInt(r.URL.Query().Get("size"), 10, 64)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
err = os.Truncate(fsPath, size)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
fs.sendAPIResponse(w, r, nil, "chmod OK", http.StatusOK)
}
func (fs *httpFsImpl) readdir(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
f, err := os.Open(fsPath)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
list, err := f.Readdir(-1)
f.Close()
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
result := make([]map[string]any, 0, len(list))
for _, fi := range list {
result = append(result, getStatFromInfo(fi))
}
if fs.callbacks != nil && fs.callbacks.Readdir != nil {
for _, fi := range fs.callbacks.Readdir(name) {
result = append(result, getStatFromInfo(fi))
}
}
render.JSON(w, r, result)
}
func (fs *httpFsImpl) dirsize(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
info, err := os.Stat(fsPath)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
numFiles := 0
size := int64(0)
if info.IsDir() {
err = filepath.Walk(fsPath, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info != nil && info.Mode().IsRegular() {
size += info.Size()
numFiles++
}
return err
})
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
}
render.JSON(w, r, map[string]any{
"files": numFiles,
"size": size,
})
}
func (fs *httpFsImpl) mimetype(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
f, err := os.OpenFile(fsPath, os.O_RDONLY, 0)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
defer f.Close()
var buf [512]byte
n, err := io.ReadFull(f, buf[:])
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
ctype := http.DetectContentType(buf[:n])
render.JSON(w, r, map[string]any{
"mime": ctype,
})
}
func (fs *httpFsImpl) statvfs(w http.ResponseWriter, r *http.Request) {
username, err := fs.getUsername(r)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
name := getNameURLParam(r)
fsPath := filepath.Join(fs.basePath, username, name)
usage, err := disk.Usage(fsPath)
if err != nil {
fs.sendAPIResponse(w, r, err, "", fs.getRespStatus(err))
return
}
// we assume block size = 4096
bsize := uint64(4096)
blocks := usage.Total / bsize
bfree := usage.Free / bsize
files := usage.InodesTotal
ffree := usage.InodesFree
if files == 0 {
// these assumptions are wrong but still better than returning 0
files = blocks / 4
ffree = bfree / 4
}
render.JSON(w, r, map[string]any{
"bsize": bsize,
"frsize": bsize,
"blocks": blocks,
"bfree": bfree,
"bavail": bfree,
"files": files,
"ffree": ffree,
"favail": ffree,
"namemax": 255,
})
}
func (fs *httpFsImpl) configureRouter() {
fs.router = chi.NewRouter()
fs.router.Use(middleware.Recoverer)
fs.router.Get(statPath+"/{name}", fs.stat) //nolint:goconst
fs.router.Get(openPath+"/{name}", fs.open)
fs.router.Post(createPath+"/{name}", fs.create)
fs.router.Patch(renamePath+"/{name}", fs.rename)
fs.router.Delete(removePath+"/{name}", fs.remove)
fs.router.Post(mkdirPath+"/{name}", fs.mkdir)
fs.router.Patch(chmodPath+"/{name}", fs.chmod)
fs.router.Patch(chtimesPath+"/{name}", fs.chtimes)
fs.router.Patch(truncatePath+"/{name}", fs.truncate)
fs.router.Get(readdirPath+"/{name}", fs.readdir)
fs.router.Get(dirsizePath+"/{name}", fs.dirsize)
fs.router.Get(mimetypePath+"/{name}", fs.mimetype)
fs.router.Get(statvfsPath+"/{name}", fs.statvfs)
}
func (fs *httpFsImpl) Run() error {
fs.basePath = filepath.Join(os.TempDir(), "httpfs")
if err := os.RemoveAll(fs.basePath); err != nil {
return err
}
if err := os.MkdirAll(fs.basePath, os.ModePerm); err != nil {
return err
}
fs.configureRouter()
httpServer := http.Server{
Addr: fmt.Sprintf(":%d", fs.port),
Handler: fs.router,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 16, // 64KB
}
if fs.unixSocketPath == "" {
return httpServer.ListenAndServe()
}
err := os.Remove(fs.unixSocketPath)
if err != nil && !os.IsNotExist(err) {
return err
}
listener, err := net.Listen("unix", fs.unixSocketPath)
if err != nil {
return err
}
return httpServer.Serve(listener)
}
func getStatFromInfo(info os.FileInfo) map[string]any {
return map[string]any{
"name": info.Name(),
"size": info.Size(),
"mode": info.Mode(),
"last_modified": info.ModTime(),
}
}
func getNameURLParam(r *http.Request) string {
v := chi.URLParam(r, "name")
unescaped, err := url.PathUnescape(v)
if err != nil {
return util.CleanPath(v)
}
return util.CleanPath(unescaped)
}