feat: filebrowser with encfs
This commit is contained in:
217
http/auth.go
Normal file
217
http/auth.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v4/request"
|
||||
|
||||
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTokenExpirationTime = time.Hour * 2
|
||||
)
|
||||
|
||||
type userInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Locale string `json:"locale"`
|
||||
ViewMode users.ViewMode `json:"viewMode"`
|
||||
SingleClick bool `json:"singleClick"`
|
||||
Perm users.Permissions `json:"perm"`
|
||||
Commands []string `json:"commands"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
HideDotfiles bool `json:"hideDotfiles"`
|
||||
DateFormat bool `json:"dateFormat"`
|
||||
}
|
||||
|
||||
type authToken struct {
|
||||
User userInfo `json:"user"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type extractor []string
|
||||
|
||||
func (e extractor) ExtractToken(r *http.Request) (string, error) {
|
||||
token, _ := request.HeaderExtractor{"X-Auth"}.ExtractToken(r)
|
||||
|
||||
// Checks if the token isn't empty and if it contains two dots.
|
||||
// The former prevents incompatibility with URLs that previously
|
||||
// used basic auth.
|
||||
if token != "" && strings.Count(token, ".") == 2 {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
auth := r.URL.Query().Get("auth")
|
||||
if auth != "" && strings.Count(auth, ".") == 2 {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
cookie, _ := r.Cookie("auth")
|
||||
if cookie != nil && strings.Count(cookie.Value, ".") == 2 {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", request.ErrNoTokenInRequest
|
||||
}
|
||||
|
||||
func withUser(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
keyFunc := func(_ *jwt.Token) (interface{}, error) {
|
||||
return d.settings.Key, nil
|
||||
}
|
||||
|
||||
var tk authToken
|
||||
token, err := request.ParseFromRequest(r, &extractor{}, keyFunc, request.WithClaims(&tk))
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
|
||||
expired := !tk.VerifyExpiresAt(time.Now().Add(time.Hour), true)
|
||||
updated := tk.IssuedAt != nil && tk.IssuedAt.Unix() < d.store.Users.LastUpdate(tk.User.ID)
|
||||
|
||||
if expired || updated {
|
||||
w.Header().Add("X-Renew-Token", "true")
|
||||
}
|
||||
|
||||
d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return fn(w, r, d)
|
||||
}
|
||||
}
|
||||
|
||||
func withAdmin(fn handleFunc) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Admin {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
return fn(w, r, d)
|
||||
})
|
||||
}
|
||||
|
||||
func loginHandler(tokenExpireTime time.Duration) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
user, err := auther.Auth(r, d.store.Users, d.settings, d.server)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return http.StatusForbidden, nil
|
||||
case err != nil:
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return printToken(w, r, d, user, tokenExpireTime)
|
||||
}
|
||||
}
|
||||
|
||||
type signupBody struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.settings.Signup {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
|
||||
if r.Body == nil {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
info := &signupBody{}
|
||||
err := json.NewDecoder(r.Body).Decode(info)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
if info.Password == "" || info.Username == "" {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
user := &users.User{
|
||||
Username: info.Username,
|
||||
}
|
||||
|
||||
d.settings.Defaults.Apply(user)
|
||||
|
||||
pwd, err := users.HashPwd(info.Password)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
user.Password = pwd
|
||||
|
||||
userHome, err := d.settings.MakeUserDir(user.Username, user.Scope, d.server.Root)
|
||||
if err != nil {
|
||||
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
user.Scope = userHome
|
||||
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
|
||||
|
||||
err = d.store.Users.Save(user)
|
||||
if errors.Is(err, fbErrors.ErrExist) {
|
||||
return http.StatusConflict, err
|
||||
} else if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func renewHandler(tokenExpireTime time.Duration) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
w.Header().Set("X-Renew-Token", "false")
|
||||
return printToken(w, r, d, d.user, tokenExpireTime)
|
||||
})
|
||||
}
|
||||
|
||||
func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.User, tokenExpirationTime time.Duration) (int, error) {
|
||||
claims := &authToken{
|
||||
User: userInfo{
|
||||
ID: user.ID,
|
||||
Locale: user.Locale,
|
||||
ViewMode: user.ViewMode,
|
||||
SingleClick: user.SingleClick,
|
||||
Perm: user.Perm,
|
||||
LockPassword: user.LockPassword,
|
||||
Commands: user.Commands,
|
||||
HideDotfiles: user.HideDotfiles,
|
||||
DateFormat: user.DateFormat,
|
||||
},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenExpirationTime)),
|
||||
Issuer: "File Browser",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(d.settings.Key)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if _, err := w.Write([]byte(signed)); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
111
http/commands.go
Normal file
111
http/commands.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/runner"
|
||||
)
|
||||
|
||||
const (
|
||||
WSWriteDeadline = 10 * time.Second
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
|
||||
var (
|
||||
cmdNotAllowed = []byte("Command not allowed.")
|
||||
)
|
||||
|
||||
//nolint:unparam
|
||||
func wsErr(ws *websocket.Conn, r *http.Request, status int, err error) {
|
||||
txt := http.StatusText(status)
|
||||
if err != nil || status >= 400 {
|
||||
log.Printf("%s: %v %s %v", r.URL.Path, status, r.RemoteAddr, err)
|
||||
}
|
||||
if err := ws.WriteControl(websocket.CloseInternalServerErr, []byte(txt), time.Now().Add(WSWriteDeadline)); err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
var commandsHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var raw string
|
||||
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage() //nolint:govet
|
||||
if err != nil {
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
raw = strings.TrimSpace(string(msg))
|
||||
if raw != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
command, err := runner.ParseCommand(d.settings, raw)
|
||||
if err != nil {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(err.Error())); err != nil { //nolint:govet
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if !d.server.EnableExec || !d.user.CanExecute(command[0]) {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, cmdNotAllowed); err != nil { //nolint:govet
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(command[0], command[1:]...) //nolint:gosec
|
||||
cmd.Dir = d.user.FullPath(r.URL.Path)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
s := bufio.NewScanner(io.MultiReader(stdout, stderr))
|
||||
for s.Scan() {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, s.Bytes()); err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
wsErr(conn, r, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
})
|
||||
82
http/data.go
Normal file
82
http/data.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/tomasen/realip"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/rules"
|
||||
"github.com/filebrowser/filebrowser/v2/runner"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/storage"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
type handleFunc func(w http.ResponseWriter, r *http.Request, d *data) (int, error)
|
||||
|
||||
type data struct {
|
||||
*runner.Runner
|
||||
settings *settings.Settings
|
||||
server *settings.Server
|
||||
store *storage.Storage
|
||||
user *users.User
|
||||
raw interface{}
|
||||
}
|
||||
|
||||
// Check implements rules.Checker.
|
||||
func (d *data) Check(path string) bool {
|
||||
if d.user.HideDotfiles && rules.MatchHidden(path) {
|
||||
return false
|
||||
}
|
||||
|
||||
allow := true
|
||||
for _, rule := range d.settings.Rules {
|
||||
if rule.Matches(path) {
|
||||
allow = rule.Allow
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range d.user.Rules {
|
||||
if rule.Matches(path) {
|
||||
allow = rule.Allow
|
||||
}
|
||||
}
|
||||
|
||||
return allow
|
||||
}
|
||||
|
||||
func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range globalHeaders {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
|
||||
settings, err := store.Settings.Get()
|
||||
if err != nil {
|
||||
log.Fatalf("ERROR: couldn't get settings: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := fn(w, r, &data{
|
||||
Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings},
|
||||
store: store,
|
||||
settings: settings,
|
||||
server: server,
|
||||
})
|
||||
|
||||
if status >= 400 || err != nil {
|
||||
clientIP := realip.FromRequest(r)
|
||||
log.Printf("%s: %v %s %v", r.URL.Path, status, clientIP, err)
|
||||
}
|
||||
|
||||
if status != 0 {
|
||||
txt := http.StatusText(status)
|
||||
http.Error(w, strconv.Itoa(status)+" "+txt, status)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return stripPrefix(prefix, handler)
|
||||
}
|
||||
9
http/headers.go
Normal file
9
http/headers.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !dev
|
||||
// +build !dev
|
||||
|
||||
package http
|
||||
|
||||
// global headers to append to every response
|
||||
var globalHeaders = map[string]string{
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
}
|
||||
15
http/headers_dev.go
Normal file
15
http/headers_dev.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build dev
|
||||
// +build dev
|
||||
|
||||
package http
|
||||
|
||||
// global headers to append to every response
|
||||
// cross-origin headers are necessary to be able to
|
||||
// access them from a different URL during development
|
||||
var globalHeaders = map[string]string{
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
"Access-Control-Allow-Methods": "*",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
}
|
||||
96
http/http.go
Normal file
96
http/http.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/storage"
|
||||
)
|
||||
|
||||
type modifyRequest struct {
|
||||
What string `json:"what"` // Answer to: what data type?
|
||||
Which []string `json:"which"` // Answer to: which fields?
|
||||
}
|
||||
|
||||
func NewHandler(
|
||||
imgSvc ImgService,
|
||||
fileCache FileCache,
|
||||
store *storage.Storage,
|
||||
server *settings.Server,
|
||||
assetsFs fs.FS,
|
||||
) (http.Handler, error) {
|
||||
server.Clean()
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
index, static := getStaticHandlers(store, server, assetsFs)
|
||||
|
||||
// NOTE: This fixes the issue where it would redirect if people did not put a
|
||||
// trailing slash in the end. I hate this decision since this allows some awful
|
||||
// URLs https://www.gorillatoolkit.org/pkg/mux#Router.SkipClean
|
||||
r = r.SkipClean(true)
|
||||
|
||||
monkey := func(fn handleFunc, prefix string) http.Handler {
|
||||
return handle(fn, prefix, store, server)
|
||||
}
|
||||
|
||||
r.HandleFunc("/health", healthHandler)
|
||||
r.PathPrefix("/static").Handler(static)
|
||||
r.NotFoundHandler = index
|
||||
|
||||
api := r.PathPrefix("/api").Subrouter()
|
||||
|
||||
tokenExpirationTime := server.GetTokenExpirationTime(DefaultTokenExpirationTime)
|
||||
api.Handle("/login", monkey(loginHandler(tokenExpirationTime), ""))
|
||||
api.Handle("/signup", monkey(signupHandler, ""))
|
||||
api.Handle("/renew", monkey(renewHandler(tokenExpirationTime), ""))
|
||||
|
||||
users := api.PathPrefix("/users").Subrouter()
|
||||
users.Handle("", monkey(usersGetHandler, "")).Methods("GET")
|
||||
users.Handle("", monkey(userPostHandler, "")).Methods("POST")
|
||||
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
|
||||
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
||||
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
||||
|
||||
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourcePostHandler(fileCache), "/api/resources")).Methods("POST")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourcePutHandler, "/api/resources")).Methods("PUT")
|
||||
api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler(fileCache), "/api/resources")).Methods("PATCH")
|
||||
|
||||
api.PathPrefix("/tus").Handler(monkey(tusPostHandler(), "/api/tus")).Methods("POST")
|
||||
api.PathPrefix("/tus").Handler(monkey(tusHeadHandler(), "/api/tus")).Methods("HEAD", "GET")
|
||||
api.PathPrefix("/tus").Handler(monkey(tusPatchHandler(), "/api/tus")).Methods("PATCH")
|
||||
api.PathPrefix("/tus").Handler(monkey(resourceDeleteHandler(fileCache), "/api/tus")).Methods("DELETE")
|
||||
|
||||
api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET")
|
||||
|
||||
api.Path("/shares").Handler(monkey(shareListHandler, "/api/shares")).Methods("GET")
|
||||
api.PathPrefix("/share").Handler(monkey(shareGetsHandler, "/api/share")).Methods("GET")
|
||||
api.PathPrefix("/share").Handler(monkey(sharePostHandler, "/api/share")).Methods("POST")
|
||||
api.PathPrefix("/share").Handler(monkey(shareDeleteHandler, "/api/share")).Methods("DELETE")
|
||||
|
||||
api.Handle("/settings", monkey(settingsGetHandler, "")).Methods("GET")
|
||||
api.Handle("/settings", monkey(settingsPutHandler, "")).Methods("PUT")
|
||||
|
||||
api.PathPrefix("/raw").Handler(monkey(rawHandler, "/api/raw")).Methods("GET")
|
||||
api.PathPrefix("/preview/{size}/{path:.*}").
|
||||
Handler(monkey(previewHandler(imgSvc, fileCache, server.EnableThumbnails, server.ResizePreview), "/api/preview")).Methods("GET")
|
||||
api.PathPrefix("/command").Handler(monkey(commandsHandler, "/api/command")).Methods("GET")
|
||||
api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET")
|
||||
api.PathPrefix("/subtitle").Handler(monkey(subtitleHandler, "/api/subtitle")).Methods("GET")
|
||||
|
||||
public := api.PathPrefix("/public").Subrouter()
|
||||
public.PathPrefix("/dl").Handler(monkey(publicDlHandler, "/api/public/dl/")).Methods("GET")
|
||||
public.PathPrefix("/share").Handler(monkey(publicShareHandler, "/api/public/share/")).Methods("GET")
|
||||
|
||||
return stripPrefix(server.BaseURL, r), nil
|
||||
}
|
||||
157
http/preview.go
Normal file
157
http/preview.go
Normal file
@@ -0,0 +1,157 @@
|
||||
//go:generate go-enum --sql --marshal --names --file $GOFILE
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/img"
|
||||
)
|
||||
|
||||
/*
|
||||
ENUM(
|
||||
thumb
|
||||
big
|
||||
)
|
||||
*/
|
||||
type PreviewSize int
|
||||
|
||||
type ImgService interface {
|
||||
FormatFromExtension(ext string) (img.Format, error)
|
||||
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
|
||||
}
|
||||
|
||||
type FileCache interface {
|
||||
Store(ctx context.Context, key string, value []byte) error
|
||||
Load(ctx context.Context, key string) ([]byte, bool, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
vars := mux.Vars(r)
|
||||
|
||||
previewSize, err := ParsePreviewSize(vars["size"])
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: "/" + vars["path"],
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: true,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
setContentDisposition(w, r, file)
|
||||
|
||||
switch file.Type {
|
||||
case "image":
|
||||
return handleImagePreview(w, r, imgSvc, fileCache, file, previewSize, enableThumbnails, resizePreview)
|
||||
default:
|
||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleImagePreview(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
imgSvc ImgService,
|
||||
fileCache FileCache,
|
||||
file *files.FileInfo,
|
||||
previewSize PreviewSize,
|
||||
enableThumbnails, resizePreview bool,
|
||||
) (int, error) {
|
||||
if (previewSize == PreviewSizeBig && !resizePreview) ||
|
||||
(previewSize == PreviewSizeThumb && !enableThumbnails) {
|
||||
return rawFileHandler(w, r, file)
|
||||
}
|
||||
|
||||
format, err := imgSvc.FormatFromExtension(file.Extension)
|
||||
// Unsupported extensions directly return the raw data
|
||||
if errors.Is(err, img.ErrUnsupportedFormat) || format == img.FormatGif {
|
||||
return rawFileHandler(w, r, file)
|
||||
}
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
cacheKey := previewCacheKey(file, previewSize)
|
||||
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if !ok {
|
||||
resizedImage, err = createPreview(imgSvc, fileCache, file, previewSize)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(resizedImage))
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func createPreview(imgSvc ImgService, fileCache FileCache,
|
||||
file *files.FileInfo, previewSize PreviewSize) ([]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 == PreviewSizeBig:
|
||||
width = 1080
|
||||
height = 1080
|
||||
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
|
||||
case previewSize == PreviewSizeThumb:
|
||||
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
|
||||
}
|
||||
|
||||
go func() {
|
||||
cacheKey := previewCacheKey(file, previewSize)
|
||||
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
|
||||
fmt.Printf("failed to cache resized image: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func previewCacheKey(f *files.FileInfo, previewSize PreviewSize) string {
|
||||
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
|
||||
}
|
||||
100
http/preview_enum.go
Normal file
100
http/preview_enum.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Code generated by go-enum
|
||||
// DO NOT EDIT!
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// PreviewSizeThumb is a PreviewSize of type Thumb
|
||||
PreviewSizeThumb PreviewSize = iota
|
||||
// PreviewSizeBig is a PreviewSize of type Big
|
||||
PreviewSizeBig
|
||||
)
|
||||
|
||||
const _PreviewSizeName = "thumbbig"
|
||||
|
||||
var _PreviewSizeNames = []string{
|
||||
_PreviewSizeName[0:5],
|
||||
_PreviewSizeName[5:8],
|
||||
}
|
||||
|
||||
// PreviewSizeNames returns a list of possible string values of PreviewSize.
|
||||
func PreviewSizeNames() []string {
|
||||
tmp := make([]string, len(_PreviewSizeNames))
|
||||
copy(tmp, _PreviewSizeNames)
|
||||
return tmp
|
||||
}
|
||||
|
||||
var _PreviewSizeMap = map[PreviewSize]string{
|
||||
0: _PreviewSizeName[0:5],
|
||||
1: _PreviewSizeName[5:8],
|
||||
}
|
||||
|
||||
// String implements the Stringer interface.
|
||||
func (x PreviewSize) String() string {
|
||||
if str, ok := _PreviewSizeMap[x]; ok {
|
||||
return str
|
||||
}
|
||||
return fmt.Sprintf("PreviewSize(%d)", x)
|
||||
}
|
||||
|
||||
var _PreviewSizeValue = map[string]PreviewSize{
|
||||
_PreviewSizeName[0:5]: 0,
|
||||
_PreviewSizeName[5:8]: 1,
|
||||
}
|
||||
|
||||
// ParsePreviewSize attempts to convert a string to a PreviewSize
|
||||
func ParsePreviewSize(name string) (PreviewSize, error) {
|
||||
if x, ok := _PreviewSizeValue[name]; ok {
|
||||
return x, nil
|
||||
}
|
||||
return PreviewSize(0), fmt.Errorf("%s is not a valid PreviewSize, try [%s]", name, strings.Join(_PreviewSizeNames, ", "))
|
||||
}
|
||||
|
||||
// MarshalText implements the text marshaller method
|
||||
func (x PreviewSize) MarshalText() ([]byte, error) {
|
||||
return []byte(x.String()), nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements the text unmarshaller method
|
||||
func (x *PreviewSize) UnmarshalText(text []byte) error {
|
||||
name := string(text)
|
||||
tmp, err := ParsePreviewSize(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*x = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (x *PreviewSize) Scan(value interface{}) error {
|
||||
var name string
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
name = v
|
||||
case []byte:
|
||||
name = string(v)
|
||||
case nil:
|
||||
*x = PreviewSize(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
tmp, err := ParsePreviewSize(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*x = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (x PreviewSize) Value() (driver.Value, error) {
|
||||
return x.String(), nil
|
||||
}
|
||||
148
http/public.go
Normal file
148
http/public.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
)
|
||||
|
||||
var withHashFile = func(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
id, ifPath := ifPathWithName(r)
|
||||
link, err := d.store.Share.GetByHash(id)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
status, err := authenticateShareRequest(r, link)
|
||||
if status != 0 || err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
user, err := d.store.Users.Get(d.server.Root, link.UserID)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
d.user = user
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: link.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
Token: link.Token,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
// share base path
|
||||
basePath := link.Path
|
||||
|
||||
// file relative path
|
||||
filePath := ""
|
||||
|
||||
if file.IsDir {
|
||||
basePath = filepath.Dir(basePath)
|
||||
filePath = ifPath
|
||||
}
|
||||
|
||||
// set fs root to the shared file/folder
|
||||
d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
|
||||
|
||||
file, err = files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: filePath,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: true,
|
||||
Checker: d,
|
||||
Token: link.Token,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
d.raw = file
|
||||
return fn(w, r, d)
|
||||
}
|
||||
}
|
||||
|
||||
// ref to https://github.com/filebrowser/filebrowser/pull/727
|
||||
// `/api/public/dl/MEEuZK-v/file-name.txt` for old browsers to save file with correct name
|
||||
func ifPathWithName(r *http.Request) (id, filePath string) {
|
||||
pathElements := strings.Split(r.URL.Path, "/")
|
||||
// prevent maliciously constructed parameters like `/api/public/dl/XZzCDnK2_not_exists_hash_name`
|
||||
// len(pathElements) will be 1, and golang will panic `runtime error: index out of range`
|
||||
|
||||
switch len(pathElements) {
|
||||
case 1:
|
||||
return r.URL.Path, "/"
|
||||
default:
|
||||
return pathElements[0], path.Join("/", path.Join(pathElements[1:]...))
|
||||
}
|
||||
}
|
||||
|
||||
var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
file := d.raw.(*files.FileInfo)
|
||||
|
||||
if file.IsDir {
|
||||
file.Listing.Sorting = files.Sorting{By: "name", Asc: false}
|
||||
file.Listing.ApplySort()
|
||||
return renderJSON(w, r, file)
|
||||
}
|
||||
|
||||
return renderJSON(w, r, file)
|
||||
})
|
||||
|
||||
var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
file := d.raw.(*files.FileInfo)
|
||||
if !file.IsDir {
|
||||
return rawFileHandler(w, r, file)
|
||||
}
|
||||
|
||||
return rawDirHandler(w, r, d, file)
|
||||
})
|
||||
|
||||
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("token") == l.Token {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if password == "" {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"OK"}`))
|
||||
}
|
||||
136
http/public_test.go
Normal file
136
http/public_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
"github.com/filebrowser/filebrowser/v2/storage/bolt"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" //nolint:gosec
|
||||
testCases := map[string]struct {
|
||||
share *share.Link
|
||||
req *http.Request
|
||||
expectedStatusCode int
|
||||
}{
|
||||
"Public share, no auth required": {
|
||||
share: &share.Link{Hash: "h", UserID: 1},
|
||||
req: newHTTPRequest(t),
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
"Private share, no auth provided, 401": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t),
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
"Private share, authentication via token": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=123" }),
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
"Private share, authentication via invalid token, 401": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=1234" }),
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
"Private share, authentication via password": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "password") }),
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
"Private share, authentication via invalid password, 401": {
|
||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
||||
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "wrong-password") }),
|
||||
expectedStatusCode: 401,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
for handlerName, handler := range map[string]handleFunc{"public share handler": publicShareHandler, "public dl handler": publicDlHandler} {
|
||||
name, tc, handlerName, handler := name, tc, handlerName, handler
|
||||
t.Run(fmt.Sprintf("%s: %s", handlerName, name), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "db")
|
||||
db, err := storm.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil { //nolint:govet
|
||||
t.Errorf("failed to close db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
storage, err := bolt.NewStorage(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get storage: %v", err)
|
||||
}
|
||||
if err := storage.Share.Save(tc.share); err != nil {
|
||||
t.Fatalf("failed to save share: %v", err)
|
||||
}
|
||||
if err := storage.Users.Save(&users.User{Username: "username", Password: "pw"}); err != nil {
|
||||
t.Fatalf("failed to save user: %v", err)
|
||||
}
|
||||
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
|
||||
t.Fatalf("failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
storage.Users = &customFSUser{
|
||||
Store: storage.Users,
|
||||
fs: &afero.MemMapFs{},
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler := handle(handler, "", storage, &settings.Server{})
|
||||
|
||||
handler.ServeHTTP(recorder, tc.req)
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
if result.StatusCode != tc.expectedStatusCode {
|
||||
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http.Request {
|
||||
t.Helper()
|
||||
r, err := http.NewRequest(http.MethodGet, "h", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to construct request: %v", err)
|
||||
}
|
||||
for _, modify := range requestModifiers {
|
||||
modify(r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type customFSUser struct {
|
||||
users.Store
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, error) {
|
||||
user, err := cu.Store.Get(baseScope, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Fs = cu.fs
|
||||
|
||||
return user, nil
|
||||
}
|
||||
213
http/raw.go
Normal file
213
http/raw.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
gopath "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mholt/archiver/v3"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/fileutils"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
func slashClean(name string) string {
|
||||
if name == "" || name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
return gopath.Clean(name)
|
||||
}
|
||||
|
||||
func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]string, error) {
|
||||
var fileSlice []string
|
||||
names := strings.Split(r.URL.Query().Get("files"), ",")
|
||||
|
||||
if len(names) == 0 {
|
||||
fileSlice = append(fileSlice, f.Path)
|
||||
} else {
|
||||
for _, name := range names {
|
||||
name, err := url.QueryUnescape(strings.Replace(name, "+", "%2B", -1)) //nolint:govet
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name = slashClean(name)
|
||||
fileSlice = append(fileSlice, filepath.Join(f.Path, name))
|
||||
}
|
||||
}
|
||||
|
||||
return fileSlice, nil
|
||||
}
|
||||
|
||||
//nolint:goconst
|
||||
func parseQueryAlgorithm(r *http.Request) (string, archiver.Writer, error) {
|
||||
switch r.URL.Query().Get("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 setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
|
||||
if r.URL.Query().Get("inline") == "true" {
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
} else {
|
||||
// As per RFC6266 section 4.3
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
|
||||
}
|
||||
}
|
||||
|
||||
var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if files.IsNamedPipe(file.Mode) {
|
||||
setContentDisposition(w, r, file)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if !file.IsDir {
|
||||
return rawFileHandler(w, r, file)
|
||||
}
|
||||
|
||||
return rawDirHandler(w, r, d, file)
|
||||
})
|
||||
|
||||
func addFile(ar archiver.Writer, d *data, path, commonPath string) error {
|
||||
if !d.Check(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := d.user.Fs.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := d.user.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, d, fPath, commonPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to archive %s: %v", fPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawDirHandler(w http.ResponseWriter, r *http.Request, d *data, file *files.FileInfo) (int, error) {
|
||||
filenames, err := parseQueryFiles(r, file, d.user)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
extension, ar, err := parseQueryAlgorithm(r)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
err = ar.Create(w)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, 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
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(name))
|
||||
|
||||
for _, fname := range filenames {
|
||||
err = addFile(ar, d, fname, commonDir)
|
||||
if err != nil {
|
||||
log.Printf("Failed to archive %s: %v", fname, err)
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
|
||||
fd, err := file.Fs.Open(file.Path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
setContentDisposition(w, r, file)
|
||||
w.Header().Add("Content-Security-Policy", `script-src 'none';`)
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, file.Name, file.ModTime, fd)
|
||||
return 0, nil
|
||||
}
|
||||
369
http/resource.go
Normal file
369
http/resource.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/fileutils"
|
||||
)
|
||||
|
||||
var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: true,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
Content: true,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if file.IsDir {
|
||||
file.Listing.Sorting = d.user.Sorting
|
||||
file.Listing.ApplySort()
|
||||
return renderJSON(w, r, file)
|
||||
}
|
||||
|
||||
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
|
||||
err := file.Checksum(checksum)
|
||||
if errors.Is(err, fbErrors.ErrInvalidOption) {
|
||||
return http.StatusBadRequest, nil
|
||||
} else if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// do not waste bandwidth if we just want the checksum
|
||||
file.Content = ""
|
||||
}
|
||||
|
||||
return renderJSON(w, r, file)
|
||||
})
|
||||
|
||||
func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
||||
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if r.URL.Path == "/" || !d.user.Perm.Delete {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
err = delThumbs(r.Context(), fileCache, file)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
err = d.RunHook(func() error {
|
||||
return d.user.Fs.RemoveAll(r.URL.Path)
|
||||
}, "delete", r.URL.Path, "", d.user)
|
||||
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
return http.StatusNoContent, nil
|
||||
})
|
||||
}
|
||||
|
||||
func resourcePostHandler(fileCache FileCache) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
// Directories creation on POST.
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
err := d.user.Fs.MkdirAll(r.URL.Path, files.PermDir)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
if err == nil {
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
|
||||
// Permission for overwriting the file
|
||||
if !d.user.Perm.Modify {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
err = delThumbs(r.Context(), fileCache, file)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
}
|
||||
|
||||
err = d.RunHook(func() error {
|
||||
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size())
|
||||
w.Header().Set("ETag", etag)
|
||||
return nil
|
||||
}, "upload", r.URL.Path, "", d.user)
|
||||
|
||||
if err != nil {
|
||||
_ = d.user.Fs.RemoveAll(r.URL.Path)
|
||||
}
|
||||
|
||||
return errToStatus(err), err
|
||||
})
|
||||
}
|
||||
|
||||
var resourcePutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
// Only allow PUT for files.
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
|
||||
exists, err := afero.Exists(d.user.Fs, r.URL.Path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
if !exists {
|
||||
return http.StatusNotFound, nil
|
||||
}
|
||||
|
||||
err = d.RunHook(func() error {
|
||||
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size())
|
||||
w.Header().Set("ETag", etag)
|
||||
return nil
|
||||
}, "save", r.URL.Path, "", d.user)
|
||||
|
||||
return errToStatus(err), err
|
||||
})
|
||||
|
||||
func resourcePatchHandler(fileCache FileCache) handleFunc {
|
||||
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
src := r.URL.Path
|
||||
dst := r.URL.Query().Get("destination")
|
||||
action := r.URL.Query().Get("action")
|
||||
dst, err := url.QueryUnescape(dst)
|
||||
if !d.Check(src) || !d.Check(dst) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if dst == "/" || src == "/" {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
err = checkParent(src, dst)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
override := r.URL.Query().Get("override") == "true"
|
||||
rename := r.URL.Query().Get("rename") == "true"
|
||||
if !override && !rename {
|
||||
if _, err = d.user.Fs.Stat(dst); err == nil {
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
dst = addVersionSuffix(dst, d.user.Fs)
|
||||
}
|
||||
|
||||
// Permission for overwriting the file
|
||||
if override && !d.user.Perm.Modify {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
err = d.RunHook(func() error {
|
||||
return patchAction(r.Context(), action, src, dst, d, fileCache)
|
||||
}, action, src, dst, d.user)
|
||||
|
||||
return errToStatus(err), err
|
||||
})
|
||||
}
|
||||
|
||||
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 writeFile(fs afero.Fs, dst string, in io.Reader) (os.FileInfo, error) {
|
||||
dir, _ := path.Split(dst)
|
||||
err := fs.MkdirAll(dir, files.PermDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := fs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, files.PermFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Gets the info about the file.
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
|
||||
for _, previewSizeName := range PreviewSizeNames() {
|
||||
size, _ := ParsePreviewSize(previewSizeName)
|
||||
if err := fileCache.Delete(ctx, previewCacheKey(file, size)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func patchAction(ctx context.Context, action, src, dst string, d *data, fileCache FileCache) error {
|
||||
switch action {
|
||||
case "copy":
|
||||
if !d.user.Perm.Create {
|
||||
return fbErrors.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return fileutils.Copy(d.user.Fs, src, dst)
|
||||
case "rename":
|
||||
if !d.user.Perm.Rename {
|
||||
return fbErrors.ErrPermissionDenied
|
||||
}
|
||||
src = path.Clean("/" + src)
|
||||
dst = path.Clean("/" + dst)
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: src,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: false,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
err = delThumbs(ctx, fileCache, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fileutils.MoveFile(d.user.Fs, src, dst)
|
||||
default:
|
||||
return fmt.Errorf("unsupported action %s: %w", action, fbErrors.ErrInvalidRequestParams)
|
||||
}
|
||||
}
|
||||
|
||||
type DiskUsageResponse struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
}
|
||||
|
||||
var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: false,
|
||||
Checker: d,
|
||||
Content: false,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
fPath := file.RealPath()
|
||||
if !file.IsDir {
|
||||
return renderJSON(w, r, &DiskUsageResponse{
|
||||
Total: 0,
|
||||
Used: 0,
|
||||
})
|
||||
}
|
||||
|
||||
usage, err := disk.UsageWithContext(r.Context(), fPath)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
return renderJSON(w, r, &DiskUsageResponse{
|
||||
Total: usage.Total,
|
||||
Used: usage.Used,
|
||||
})
|
||||
})
|
||||
28
http/search.go
Normal file
28
http/search.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/search"
|
||||
)
|
||||
|
||||
var searchHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
response := []map[string]interface{}{}
|
||||
query := r.URL.Query().Get("query")
|
||||
|
||||
err := search.Search(d.user.Fs, r.URL.Path, query, d, func(path string, f os.FileInfo) error {
|
||||
response = append(response, map[string]interface{}{
|
||||
"dir": f.IsDir(),
|
||||
"path": path,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, response)
|
||||
})
|
||||
58
http/settings.go
Normal file
58
http/settings.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/rules"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
)
|
||||
|
||||
type settingsData struct {
|
||||
Signup bool `json:"signup"`
|
||||
CreateUserDir bool `json:"createUserDir"`
|
||||
UserHomeBasePath string `json:"userHomeBasePath"`
|
||||
Defaults settings.UserDefaults `json:"defaults"`
|
||||
Rules []rules.Rule `json:"rules"`
|
||||
Branding settings.Branding `json:"branding"`
|
||||
Tus settings.Tus `json:"tus"`
|
||||
Shell []string `json:"shell"`
|
||||
Commands map[string][]string `json:"commands"`
|
||||
}
|
||||
|
||||
var settingsGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
data := &settingsData{
|
||||
Signup: d.settings.Signup,
|
||||
CreateUserDir: d.settings.CreateUserDir,
|
||||
UserHomeBasePath: d.settings.UserHomeBasePath,
|
||||
Defaults: d.settings.Defaults,
|
||||
Rules: d.settings.Rules,
|
||||
Branding: d.settings.Branding,
|
||||
Tus: d.settings.Tus,
|
||||
Shell: d.settings.Shell,
|
||||
Commands: d.settings.Commands,
|
||||
}
|
||||
|
||||
return renderJSON(w, r, data)
|
||||
})
|
||||
|
||||
var settingsPutHandler = withAdmin(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
req := &settingsData{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
d.settings.Signup = req.Signup
|
||||
d.settings.CreateUserDir = req.CreateUserDir
|
||||
d.settings.UserHomeBasePath = req.UserHomeBasePath
|
||||
d.settings.Defaults = req.Defaults
|
||||
d.settings.Rules = req.Rules
|
||||
d.settings.Branding = req.Branding
|
||||
d.settings.Tus = req.Tus
|
||||
d.settings.Shell = req.Shell
|
||||
d.settings.Commands = req.Commands
|
||||
|
||||
err = d.store.Settings.Save(d.settings)
|
||||
return errToStatus(err), err
|
||||
})
|
||||
167
http/share.go
Normal file
167
http/share.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
)
|
||||
|
||||
func withPermShare(fn handleFunc) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Share {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
return fn(w, r, d)
|
||||
})
|
||||
}
|
||||
|
||||
var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
var (
|
||||
s []*share.Link
|
||||
err error
|
||||
)
|
||||
if d.user.Perm.Admin {
|
||||
s, err = d.store.Share.All()
|
||||
} else {
|
||||
s, err = d.store.Share.FindByUserID(d.user.ID)
|
||||
}
|
||||
if errors.Is(err, fbErrors.ErrNotExist) {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
sort.Slice(s, func(i, j int) bool {
|
||||
if s[i].UserID != s[j].UserID {
|
||||
return s[i].UserID < s[j].UserID
|
||||
}
|
||||
return s[i].Expire < s[j].Expire
|
||||
})
|
||||
|
||||
return renderJSON(w, r, s)
|
||||
})
|
||||
|
||||
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
s, err := d.store.Share.Gets(r.URL.Path, d.user.ID)
|
||||
if errors.Is(err, fbErrors.ErrNotExist) {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, s)
|
||||
})
|
||||
|
||||
var shareDeleteHandler = withPermShare(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
hash := strings.TrimSuffix(r.URL.Path, "/")
|
||||
hash = strings.TrimPrefix(hash, "/")
|
||||
|
||||
if hash == "" {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
err := d.store.Share.Delete(hash)
|
||||
return errToStatus(err), err
|
||||
})
|
||||
|
||||
var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
var s *share.Link
|
||||
var body share.CreateBody
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
}
|
||||
|
||||
bytes := make([]byte, 6) //nolint:gomnd
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
str := base64.URLEncoding.EncodeToString(bytes)
|
||||
|
||||
var expire int64 = 0
|
||||
|
||||
if body.Expires != "" {
|
||||
//nolint:govet
|
||||
num, err := strconv.Atoi(body.Expires)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
var add time.Duration
|
||||
switch body.Unit {
|
||||
case "seconds":
|
||||
add = time.Second * time.Duration(num)
|
||||
case "minutes":
|
||||
add = time.Minute * time.Duration(num)
|
||||
case "days":
|
||||
add = time.Hour * 24 * time.Duration(num)
|
||||
default:
|
||||
add = time.Hour * time.Duration(num)
|
||||
}
|
||||
|
||||
expire = time.Now().Add(add).Unix()
|
||||
}
|
||||
|
||||
hash, status, err := getSharePasswordHash(body)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
var token string
|
||||
if len(hash) > 0 {
|
||||
tokenBuffer := make([]byte, 96) //nolint:gomnd
|
||||
if _, err := rand.Read(tokenBuffer); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
token = base64.URLEncoding.EncodeToString(tokenBuffer)
|
||||
}
|
||||
|
||||
s = &share.Link{
|
||||
Path: r.URL.Path,
|
||||
Hash: str,
|
||||
Expire: expire,
|
||||
UserID: d.user.ID,
|
||||
PasswordHash: string(hash),
|
||||
Token: token,
|
||||
}
|
||||
|
||||
if err := d.store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, s)
|
||||
})
|
||||
|
||||
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
||||
if body.Password == "" {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
return hash, 0, nil
|
||||
}
|
||||
158
http/static.go
Normal file
158
http/static.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/auth"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/storage"
|
||||
"github.com/filebrowser/filebrowser/v2/version"
|
||||
)
|
||||
|
||||
func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys fs.FS, file, contentType string) (int, error) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Name": d.settings.Branding.Name,
|
||||
"DisableExternal": d.settings.Branding.DisableExternal,
|
||||
"DisableUsedPercentage": d.settings.Branding.DisableUsedPercentage,
|
||||
"Color": d.settings.Branding.Color,
|
||||
"BaseURL": d.server.BaseURL,
|
||||
"Version": version.Version,
|
||||
"StaticURL": path.Join(d.server.BaseURL, "/static"),
|
||||
"Signup": d.settings.Signup,
|
||||
"NoAuth": d.settings.AuthMethod == auth.MethodNoAuth,
|
||||
"AuthMethod": d.settings.AuthMethod,
|
||||
"LoginPage": auther.LoginPage(),
|
||||
"CSS": false,
|
||||
"ReCaptcha": false,
|
||||
"Theme": d.settings.Branding.Theme,
|
||||
"EnableThumbs": d.server.EnableThumbnails,
|
||||
"ResizePreview": d.server.ResizePreview,
|
||||
"EnableExec": d.server.EnableExec,
|
||||
"TusSettings": d.settings.Tus,
|
||||
}
|
||||
|
||||
if d.settings.Branding.Files != "" {
|
||||
fPath := filepath.Join(d.settings.Branding.Files, "custom.css")
|
||||
_, err := os.Stat(fPath) //nolint:govet
|
||||
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("couldn't load custom styles: %v", err)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
data["CSS"] = true
|
||||
}
|
||||
}
|
||||
|
||||
if d.settings.AuthMethod == auth.MethodJSONAuth {
|
||||
raw, err := d.store.Auth.Get(d.settings.AuthMethod) //nolint:govet
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
auther := raw.(*auth.JSONAuth)
|
||||
|
||||
if auther.ReCaptcha != nil {
|
||||
data["ReCaptcha"] = auther.ReCaptcha.Key != "" && auther.ReCaptcha.Secret != ""
|
||||
data["ReCaptchaHost"] = auther.ReCaptcha.Host
|
||||
data["ReCaptchaKey"] = auther.ReCaptcha.Key
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
data["Json"] = strings.ReplaceAll(string(b), `'`, `\'`)
|
||||
|
||||
fileContents, err := fs.ReadFile(fSys, file)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
index := template.Must(template.New("index").Delims("[{[", "]}]").Parse(string(fileContents)))
|
||||
err = index.Execute(w, data)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func getStaticHandlers(store *storage.Storage, server *settings.Server, assetsFs fs.FS) (index, static http.Handler) {
|
||||
index = handle(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if r.Method != http.MethodGet {
|
||||
return http.StatusNotFound, nil
|
||||
}
|
||||
|
||||
w.Header().Set("x-xss-protection", "1; mode=block")
|
||||
return handleWithStaticData(w, r, d, assetsFs, "public/index.html", "text/html; charset=utf-8")
|
||||
}, "", store, server)
|
||||
|
||||
static = handle(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if r.Method != http.MethodGet {
|
||||
return http.StatusNotFound, nil
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
return http.StatusNotFound, nil
|
||||
}
|
||||
|
||||
const maxAge = 86400 // 1 day
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
||||
|
||||
if d.settings.Branding.Files != "" {
|
||||
if strings.HasPrefix(r.URL.Path, "img/") {
|
||||
fPath := filepath.Join(d.settings.Branding.Files, r.URL.Path)
|
||||
if _, err := os.Stat(fPath); err == nil {
|
||||
http.ServeFile(w, r, fPath)
|
||||
return 0, nil
|
||||
}
|
||||
} else if r.URL.Path == "custom.css" && d.settings.Branding.Files != "" {
|
||||
http.ServeFile(w, r, filepath.Join(d.settings.Branding.Files, "custom.css"))
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(r.URL.Path, ".js") {
|
||||
http.FileServer(http.FS(assetsFs)).ServeHTTP(w, r)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
fileContents, err := fs.ReadFile(assetsFs, r.URL.Path+".gz")
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
|
||||
if _, err := w.Write(fileContents); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}, "/static/", store, server)
|
||||
|
||||
return index, static
|
||||
}
|
||||
80
http/subtitle.go
Normal file
80
http/subtitle.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/asticode/go-astisub"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
)
|
||||
|
||||
var subtitleHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if file.IsDir {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
return subtitleFileHandler(w, r, file)
|
||||
})
|
||||
|
||||
func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
|
||||
// if its not a subtitle file, reject
|
||||
if !files.IsSupportedSubtitle(file.Name) {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
fd, err := file.Fs.Open(file.Path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
// load subtitle for conversion to vtt
|
||||
var sub *astisub.Subtitles
|
||||
if strings.HasSuffix(file.Name, ".srt") {
|
||||
sub, err = astisub.ReadFromSRT(fd)
|
||||
} else if strings.HasSuffix(file.Name, ".ass") || strings.HasSuffix(file.Name, ".ssa") {
|
||||
sub, err = astisub.ReadFromSSA(fd)
|
||||
}
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
setContentDisposition(w, r, file)
|
||||
w.Header().Add("Content-Security-Policy", `script-src 'none';`)
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
// force type to text/vtt
|
||||
w.Header().Set("Content-Type", "text/vtt")
|
||||
|
||||
// serve vtt file directly
|
||||
if sub == nil {
|
||||
http.ServeContent(w, r, file.Name, file.ModTime, fd)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// convert others to vtt and serve from buffer
|
||||
var buf = &bytes.Buffer{}
|
||||
err = sub.WriteToWebVTT(buf)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(buf.Bytes()))
|
||||
return 0, nil
|
||||
}
|
||||
163
http/tus_handlers.go
Normal file
163
http/tus_handlers.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
)
|
||||
|
||||
func tusPostHandler() handleFunc {
|
||||
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, afero.ErrFileNotFound):
|
||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
dirPath := filepath.Dir(r.URL.Path)
|
||||
if _, statErr := d.user.Fs.Stat(dirPath); os.IsNotExist(statErr) {
|
||||
if mkdirErr := d.user.Fs.MkdirAll(dirPath, files.PermDir); mkdirErr != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
case err != nil:
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
fileFlags := os.O_CREATE | os.O_WRONLY
|
||||
if r.URL.Query().Get("override") == "true" {
|
||||
fileFlags |= os.O_TRUNC
|
||||
}
|
||||
|
||||
// if file exists
|
||||
if file != nil {
|
||||
if file.IsDir {
|
||||
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
|
||||
}
|
||||
}
|
||||
|
||||
openFile, err := d.user.Fs.OpenFile(r.URL.Path, fileFlags, files.PermFile)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if err := openFile.Close(); err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
return http.StatusCreated, nil
|
||||
})
|
||||
}
|
||||
|
||||
func tusHeadHandler() handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if !d.Check(r.URL.Path) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(file.Size, 10))
|
||||
w.Header().Set("Upload-Length", "-1")
|
||||
|
||||
return http.StatusOK, nil
|
||||
})
|
||||
}
|
||||
|
||||
func tusPatchHandler() handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/offset+octet-stream" {
|
||||
return http.StatusUnsupportedMediaType, nil
|
||||
}
|
||||
|
||||
uploadOffset, err := getUploadOffset(r)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid upload offset: %w", err)
|
||||
}
|
||||
|
||||
file, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: r.URL.Path,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: d.server.TypeDetectionByHeader,
|
||||
Checker: d,
|
||||
})
|
||||
|
||||
switch {
|
||||
case errors.Is(err, afero.ErrFileNotFound):
|
||||
return http.StatusNotFound, nil
|
||||
case err != nil:
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
switch {
|
||||
case file.IsDir:
|
||||
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
|
||||
case file.Size != uploadOffset:
|
||||
return http.StatusConflict, fmt.Errorf(
|
||||
"%s file size doesn't match the provided offset: %d",
|
||||
file.RealPath(),
|
||||
uploadOffset,
|
||||
)
|
||||
}
|
||||
|
||||
openFile, err := d.user.Fs.OpenFile(r.URL.Path, os.O_WRONLY|os.O_APPEND, files.PermFile)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not open file: %w", err)
|
||||
}
|
||||
defer openFile.Close()
|
||||
|
||||
_, err = openFile.Seek(uploadOffset, 0)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not seek file: %w", err)
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
bytesWritten, err := io.Copy(openFile, r.Body)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not write to file: %w", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(uploadOffset+bytesWritten, 10))
|
||||
|
||||
return http.StatusNoContent, nil
|
||||
})
|
||||
}
|
||||
|
||||
func getUploadOffset(r *http.Request) (int64, error) {
|
||||
uploadOffset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid upload offset: %w", err)
|
||||
}
|
||||
return uploadOffset, nil
|
||||
}
|
||||
208
http/users.go
Normal file
208
http/users.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
var (
|
||||
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
|
||||
)
|
||||
|
||||
type modifyUserRequest struct {
|
||||
modifyRequest
|
||||
Data *users.User `json:"data"`
|
||||
}
|
||||
|
||||
func getUserID(r *http.Request) (uint, error) {
|
||||
vars := mux.Vars(r)
|
||||
i, err := strconv.ParseUint(vars["id"], 10, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(i), err
|
||||
}
|
||||
|
||||
func getUser(_ http.ResponseWriter, r *http.Request) (*modifyUserRequest, error) {
|
||||
if r.Body == nil {
|
||||
return nil, fbErrors.ErrEmptyRequest
|
||||
}
|
||||
|
||||
req := &modifyUserRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.What != "user" {
|
||||
return nil, fbErrors.ErrInvalidDataType
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func withSelfOrAdmin(fn handleFunc) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
id, err := getUserID(r)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if d.user.ID != id && !d.user.Perm.Admin {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
d.raw = id
|
||||
return fn(w, r, d)
|
||||
})
|
||||
}
|
||||
|
||||
var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
users, err := d.store.Users.Gets(d.server.Root)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
u.Password = ""
|
||||
}
|
||||
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
return users[i].ID < users[j].ID
|
||||
})
|
||||
|
||||
return renderJSON(w, r, users)
|
||||
})
|
||||
|
||||
var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
u, err := d.store.Users.Get(d.server.Root, d.raw.(uint))
|
||||
if errors.Is(err, fbErrors.ErrNotExist) {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
u.Password = ""
|
||||
if !d.user.Perm.Admin {
|
||||
u.Scope = ""
|
||||
}
|
||||
return renderJSON(w, r, u)
|
||||
})
|
||||
|
||||
var userDeleteHandler = withSelfOrAdmin(func(_ http.ResponseWriter, _ *http.Request, d *data) (int, error) {
|
||||
err := d.store.Users.Delete(d.raw.(uint))
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
})
|
||||
|
||||
var userPostHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
req, err := getUser(w, r)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
if len(req.Which) != 0 {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
if req.Data.Password == "" {
|
||||
return http.StatusBadRequest, fbErrors.ErrEmptyPassword
|
||||
}
|
||||
|
||||
req.Data.Password, err = users.HashPwd(req.Data.Password)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
userHome, err := d.settings.MakeUserDir(req.Data.Username, req.Data.Scope, d.server.Root)
|
||||
if err != nil {
|
||||
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
req.Data.Scope = userHome
|
||||
log.Printf("user: %s, home dir: [%s].", req.Data.Username, userHome)
|
||||
|
||||
err = d.store.Users.Save(req.Data)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
w.Header().Set("Location", "/settings/users/"+strconv.FormatUint(uint64(req.Data.ID), 10))
|
||||
return http.StatusCreated, nil
|
||||
})
|
||||
|
||||
var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
req, err := getUser(w, r)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
if req.Data.ID != d.raw.(uint) {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
if len(req.Which) == 0 || (len(req.Which) == 1 && req.Which[0] == "all") {
|
||||
if !d.user.Perm.Admin {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
if req.Data.Password != "" {
|
||||
req.Data.Password, err = users.HashPwd(req.Data.Password)
|
||||
} else {
|
||||
var suser *users.User
|
||||
suser, err = d.store.Users.Get(d.server.Root, d.raw.(uint))
|
||||
req.Data.Password = suser.Password
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
req.Which = []string{}
|
||||
}
|
||||
|
||||
for k, v := range req.Which {
|
||||
v = cases.Title(language.English, cases.NoLower).String(v)
|
||||
req.Which[k] = v
|
||||
|
||||
if v == "Password" {
|
||||
if !d.user.Perm.Admin && d.user.LockPassword {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
req.Data.Password, err = users.HashPwd(req.Data.Password)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range NonModifiableFieldsForNonAdmin {
|
||||
if !d.user.Perm.Admin && v == f {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = d.store.Users.Update(req.Data, req.Which...)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
})
|
||||
68
http/utils.go
Normal file
68
http/utils.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
libErrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
)
|
||||
|
||||
func renderJSON(w http.ResponseWriter, _ *http.Request, data interface{}) (int, error) {
|
||||
marsh, err := json.Marshal(data)
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if _, err := w.Write(marsh); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func errToStatus(err error) int {
|
||||
switch {
|
||||
case err == nil:
|
||||
return http.StatusOK
|
||||
case os.IsPermission(err):
|
||||
return http.StatusForbidden
|
||||
case os.IsNotExist(err), errors.Is(err, libErrors.ErrNotExist):
|
||||
return http.StatusNotFound
|
||||
case os.IsExist(err), errors.Is(err, libErrors.ErrExist):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, libErrors.ErrPermissionDenied):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, libErrors.ErrInvalidRequestParams):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, libErrors.ErrRootUserDeletion):
|
||||
return http.StatusForbidden
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// This is an addaptation if http.StripPrefix in which we don't
|
||||
// return 404 if the page doesn't have the needed prefix.
|
||||
func stripPrefix(prefix string, h http.Handler) http.Handler {
|
||||
if prefix == "" || prefix == "/" {
|
||||
return h
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
rp := strings.TrimPrefix(r.URL.RawPath, prefix)
|
||||
r2 := new(http.Request)
|
||||
*r2 = *r
|
||||
r2.URL = new(url.URL)
|
||||
*r2.URL = *r.URL
|
||||
r2.URL.Path = p
|
||||
r2.URL.RawPath = rp
|
||||
h.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user