mirror of
https://github.com/hacdias/webdav.git
synced 2026-10-19 16:15:39 +00:00
346 lines
10 KiB
Go
346 lines
10 KiB
Go
package lib
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/rs/cors"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/net/webdav"
|
|
)
|
|
|
|
type handlerUser struct {
|
|
User
|
|
handler webdav.Handler
|
|
fs permissionsFS
|
|
}
|
|
|
|
type Handler struct {
|
|
noPassword bool
|
|
behindProxy bool
|
|
listing Listing
|
|
user *handlerUser
|
|
users map[string]*handlerUser
|
|
}
|
|
|
|
func NewHandler(c *Config) (http.Handler, error) {
|
|
ls := webdav.NewMemLS()
|
|
|
|
logFunc := func(r *http.Request, err error) {
|
|
lZap := getRequestLogger(r, c.BehindProxy)
|
|
lZap.Debug("handle webdav request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Error(err))
|
|
}
|
|
|
|
h := &Handler{
|
|
noPassword: c.NoPassword,
|
|
behindProxy: c.BehindProxy,
|
|
listing: c.Listing,
|
|
user: newHandlerUser(User{UserPermissions: c.UserPermissions}, c, ls, logFunc),
|
|
users: map[string]*handlerUser{},
|
|
}
|
|
|
|
for _, u := range c.Users {
|
|
h.users[u.Username] = newHandlerUser(u, c, ls, logFunc)
|
|
}
|
|
|
|
if c.CORS.Enabled {
|
|
return cors.New(cors.Options{
|
|
AllowCredentials: c.CORS.Credentials,
|
|
AllowPrivateNetwork: c.CORS.AllowPrivateNetwork,
|
|
AllowedOrigins: c.CORS.AllowedHosts,
|
|
AllowedMethods: c.CORS.AllowedMethods,
|
|
AllowedHeaders: c.CORS.AllowedHeaders,
|
|
ExposedHeaders: c.CORS.ExposedHeaders,
|
|
OptionsPassthrough: false,
|
|
}).Handler(h), nil
|
|
}
|
|
|
|
if len(c.Users) == 0 {
|
|
zap.L().Warn("unprotected config: no users have been set, so no authentication will be used")
|
|
}
|
|
|
|
if c.NoPassword {
|
|
zap.L().Warn("unprotected config: password check is disabled, only intended when delegating authentication to another service")
|
|
}
|
|
|
|
return h, nil
|
|
}
|
|
|
|
// newHandlerUser prepares a user for serving, keeping the unwrapped file system
|
|
// alongside the handler.
|
|
func newHandlerUser(u User, c *Config, ls webdav.LockSystem, logFunc func(*http.Request, error)) *handlerUser {
|
|
fs := permissionsFS{fs: buildFileSystem(u.UserPermissions, c.NoSniff), perms: u.UserPermissions}
|
|
|
|
return &handlerUser{
|
|
User: u,
|
|
handler: buildWebdavHandler(u.UserPermissions, fs, c.Prefix, ls, logFunc),
|
|
fs: fs,
|
|
}
|
|
}
|
|
|
|
// buildFileSystem creates the unfiltered [webdav.FileSystem] for a set of user
|
|
// permissions, selecting between single-directory and multi-directory backing
|
|
// depending on whether directories are configured.
|
|
func buildFileSystem(p UserPermissions, noSniff bool) webdav.FileSystem {
|
|
if p.useDirectories {
|
|
return multiDir{
|
|
mounts: p.Directories,
|
|
noSniff: noSniff,
|
|
}
|
|
}
|
|
|
|
return Dir{
|
|
Dir: webdav.Dir(p.Directory),
|
|
noSniff: noSniff,
|
|
}
|
|
}
|
|
|
|
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions.
|
|
func buildWebdavHandler(p UserPermissions, fs permissionsFS, prefix string, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
|
|
h := webdav.Handler{
|
|
Prefix: prefix,
|
|
Logger: logFunc,
|
|
FileSystem: fs,
|
|
}
|
|
|
|
if p.useDirectories {
|
|
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
|
|
} else {
|
|
h.LockSystem = newLockSystem(ls, p.Directory)
|
|
}
|
|
|
|
return h
|
|
}
|
|
|
|
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := h.user
|
|
|
|
lZap := getRequestLogger(r, h.behindProxy)
|
|
|
|
// Authentication
|
|
if len(h.users) > 0 {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
|
|
|
// Gets the correct user for this request.
|
|
username, password, ok := r.BasicAuth()
|
|
if !ok {
|
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
user, ok = h.users[username]
|
|
if !ok {
|
|
// Log invalid username
|
|
lZap.Info("invalid username", zap.String("username", username))
|
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !h.noPassword && !user.checkPassword(password) {
|
|
// Log invalid password
|
|
lZap.Info("invalid password", zap.String("username", username))
|
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Log successful authorization
|
|
lZap.Info("user authorized", zap.String("username", username))
|
|
}
|
|
|
|
// Convert the HTTP request into an internal request type
|
|
req, err := newRequest(r, h.user.handler.Prefix)
|
|
if err != nil {
|
|
lZap.Info("invalid request path or destination", zap.Error(err))
|
|
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
fileExists := func(filename string) bool {
|
|
_, err := user.fs.Stat(r.Context(), filename)
|
|
return !os.IsNotExist(err)
|
|
}
|
|
|
|
// Checks for user permissions relatively to this PATH.
|
|
allowed := user.Allowed(req, fileExists)
|
|
|
|
lZap.Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
|
|
|
if !allowed {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// MOVE and DELETE act on a whole source subtree in one call, so every
|
|
// descendant needs authorizing here. Reading COPY and PROPFIND out of the
|
|
// source goes through permFS instead.
|
|
if r.Method == "MOVE" || r.Method == "DELETE" {
|
|
ok, err := user.fs.allowedThroughout(r.Context(), req.path, func(p Permissions) bool {
|
|
return p.Allowed(req, fileExists)
|
|
})
|
|
if err != nil {
|
|
lZap.Error("could not authorize subtree", zap.String("path", req.path), zap.Error(err))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if !ok {
|
|
lZap.Info("denied by a rule on a descendant", zap.String("method", r.Method), zap.String("path", req.path))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Excerpt from RFC4918, section 9.9.3, on MOVE:
|
|
//
|
|
// If a resource exists at the destination and the Overwrite header is
|
|
// "T", then prior to performing the move, the server MUST perform a
|
|
// DELETE with "Depth: infinity" on the destination resource.
|
|
//
|
|
// And from section 9.8.4, on COPY:
|
|
//
|
|
// When a collection is overwritten, the membership of the destination
|
|
// collection after the successful COPY request MUST be the same
|
|
// membership as the source collection immediately before the COPY.
|
|
//
|
|
// Either way whatever the destination collection held is gone, which
|
|
// golang.org/x/net/webdav carries out as a RemoveAll before the rename or
|
|
// the copy. Writing over a file is an update, already authorized by Allowed,
|
|
// but removing a collection takes everything under it. Nothing writes to
|
|
// those descendants, they are only destroyed, so authorize the destination
|
|
// the way DELETE on that collection would be.
|
|
if (r.Method == "MOVE" || r.Method == "COPY") && req.destination != "" {
|
|
info, err := user.fs.Stat(r.Context(), req.destination)
|
|
if err == nil && info.IsDir() {
|
|
deletable := func(p Permissions) bool { return p.Delete }
|
|
|
|
// allowedThroughout reaches descendants only, and the destination
|
|
// check in Allowed covers the collection itself as an update rather
|
|
// than a delete, so that is checked here.
|
|
ok := user.allowedAt(req.destination, deletable)
|
|
if ok {
|
|
ok, err = user.fs.allowedThroughout(r.Context(), req.destination, deletable)
|
|
if err != nil {
|
|
lZap.Error("could not authorize destination subtree", zap.String("destination", req.destination), zap.Error(err))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !ok {
|
|
lZap.Info("denied by a rule on the destination collection", zap.String("method", r.Method), zap.String("destination", req.destination))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.Method == "HEAD" {
|
|
w = responseWriterNoBody{w}
|
|
}
|
|
|
|
// Excerpt from RFC4918, section 9.4:
|
|
//
|
|
// GET, when applied to a collection, may return the contents of an
|
|
// "index.html" resource, a human-readable view of the contents of
|
|
// the collection, or something else altogether.
|
|
//
|
|
// Similarly, since the definition of HEAD is a GET without a response
|
|
// message body, the semantics of HEAD are unmodified when applied to
|
|
// collection resources.
|
|
//
|
|
// GET (or HEAD), when applied to collection, will serve directory listing
|
|
// if listing is enabled, otherwise return same as PROPFIND method.
|
|
if r.Method == "GET" || r.Method == "HEAD" {
|
|
info, err := user.fs.Stat(r.Context(), req.path)
|
|
if err == nil && info.IsDir() {
|
|
if h.listing.Enabled {
|
|
// The listing uses relative links, which only resolve correctly
|
|
// under a trailing slash. The target is relative so http.Redirect
|
|
// cleans it, keeping a path such as "//host" from leaving the site.
|
|
if !strings.HasSuffix(r.URL.Path, "/") {
|
|
target := "./" + path.Base(r.URL.EscapedPath()) + "/"
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
|
|
sorting := ParseListingSortQuery(r.URL.Query())
|
|
html, err := RenderListing(
|
|
r.Context(),
|
|
user.fs,
|
|
req.path,
|
|
sorting,
|
|
h.listing,
|
|
)
|
|
if err != nil {
|
|
lZap.Error("failed to render directory listing", zap.Error(err))
|
|
http.Error(w, "Failed to render directory listing", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Method == "GET" {
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := w.Write([]byte(html)); err != nil {
|
|
lZap.Error("failed to write directory listing", zap.Error(err))
|
|
}
|
|
} else {
|
|
// HEAD: write headers only
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
return
|
|
}
|
|
|
|
r.Method = "PROPFIND"
|
|
|
|
if r.Header.Get("Depth") == "" {
|
|
r.Header.Add("Depth", "1")
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.Method == "OPTIONS" {
|
|
user.handleOptions(w, r, req.path)
|
|
return
|
|
}
|
|
|
|
if r.Method == "PATCH" || (r.Method == "PUT" && r.Header.Get("Content-Range") != "") {
|
|
user.handlePartialUpdate(w, r, req.path)
|
|
return
|
|
}
|
|
|
|
// Runs the WebDAV.
|
|
user.handler.ServeHTTP(w, r)
|
|
}
|
|
|
|
// getRequestLogger creates a zap.Logger using the request remote ip.
|
|
func getRequestLogger(r *http.Request, behindProxy bool) *zap.Logger {
|
|
// Retrieve the real client IP address using the updated helper function
|
|
remoteAddr := getRealRemoteIP(r, behindProxy)
|
|
|
|
return zap.L().With(zap.String("remote_address", remoteAddr))
|
|
}
|
|
|
|
// getRealRemoteIP retrieves the client's actual IP address, considering reverse proxies.
|
|
func getRealRemoteIP(r *http.Request, behindProxy bool) string {
|
|
if behindProxy {
|
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
type responseWriterNoBody struct {
|
|
http.ResponseWriter
|
|
}
|
|
|
|
func (w responseWriterNoBody) Write(data []byte) (int, error) {
|
|
return len(data), nil
|
|
}
|