package server import ( "fmt" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/net/webdav" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/workers/auto" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/service/http/header" "github.com/photoprism/photoprism/pkg/txt" ) // WebDAVHandler wraps the http request handler so that it can be customized. var WebDAVHandler = func(c *gin.Context, router *gin.RouterGroup, srv *webdav.Handler) { srv.ServeHTTP(c.Writer, c.Request) } // WebDAV handles requests to the "/originals" and "/import" endpoints. func WebDAV(dir string, router *gin.RouterGroup, conf *config.Config) { if router == nil { log.Error("webdav: router is nil") return } if conf == nil { log.Error("webdav: conf is nil") return } // Native file system restricted to a specific directory. fileSystem := webdav.Dir(dir) lockSystem := mutex.WebDAV(dir) // Request logger function. loggerFunc := func(request *http.Request, err error) { if err != nil { switch request.Method { case MethodPut, MethodPost, MethodPatch, MethodDelete, MethodCopy, MethodMove: log.Errorf("webdav: %s in %s %s", clean.Error(err), clean.Log(request.Method), clean.Log(request.URL.String())) case MethodPropfind: log.Tracef("webdav: %s in %s %s", clean.Error(err), clean.Log(request.Method), clean.Log(request.URL.String())) default: log.Debugf("webdav: %s in %s %s", clean.Error(err), clean.Log(request.Method), clean.Log(request.URL.String())) } } else { // Determine the filename if it is an uploaded file and process custom request headers, if any. if fileName := WebDAVFileName(request, router, conf); fileName != "" { // Flag the uploaded file as favorite if the "X-Favorite" header is set to "1". if request.Header.Get(header.XFavorite) == "1" { WebDAVSetFavoriteFlag(fileName) } // Set the file modification time based on the Unix timestamp found in the "X-OC-MTime" header. if fileMtime := txt.Int64(request.Header.Get(header.XModTime)); fileMtime > 0 { WebDAVSetFileMtime(fileName, fileMtime) } } switch request.Method { case MethodPut, MethodPost, MethodPatch, MethodDelete, MethodCopy, MethodMove: log.Infof("webdav: %s %s", clean.Log(request.Method), clean.Log(request.URL.String())) if router.BasePath() == conf.BaseUri(WebDAVOriginals) { auto.ShouldIndex() } else if router.BasePath() == conf.BaseUri(WebDAVImport) { auto.ShouldImport() } default: log.Tracef("webdav: %s %s", clean.Log(request.Method), clean.Log(request.URL.String())) } } } // Create WebDAV request handler. srv := &webdav.Handler{ Prefix: router.BasePath(), FileSystem: fileSystem, LockSystem: lockSystem, Logger: loggerFunc, } // Wrap handler to check quota and permissions. handlerFunc := func(c *gin.Context) { // Abort PUT, POST, PATCH, and COPY requests if there // is not enough free storage to upload new files. switch c.Request.Method { case MethodPut, MethodPost, MethodPatch, MethodCopy: if conf.FilesQuotaReached() { c.AbortWithStatus(http.StatusInsufficientStorage) return } } // Invoke handler callback. WebDAVHandler(c, router, srv) } // handleRead registers WebDAV methods used for browsing and downloading. handleRead := func(h func(*gin.Context)) { router.Handle(MethodHead, "/*path", h) router.Handle(MethodGet, "/*path", h) router.Handle(MethodOptions, "/*path", h) router.Handle(MethodLock, "/*path", h) router.Handle(MethodUnlock, "/*path", h) router.Handle(MethodPropfind, "/*path", h) } // handleWrite registers WebDAV methods to may modify the file system. handleWrite := func(h func(*gin.Context)) { router.Handle(MethodPut, "/*path", h) router.Handle(MethodPost, "/*path", h) router.Handle(MethodPatch, "/*path", h) router.Handle(MethodDelete, "/*path", h) router.Handle(MethodMkcol, "/*path", h) router.Handle(MethodCopy, "/*path", h) router.Handle(MethodMove, "/*path", h) router.Handle(MethodProppatch, "/*path", h) } // Handle supported WebDAV request methods. handleRead(handlerFunc) // Only supported with read-only mode disabled. if conf.ReadOnly() { handleWrite(func(c *gin.Context) { _ = c.AbortWithError(http.StatusForbidden, fmt.Errorf("forbidden in read-only mode")) }) } else { handleWrite(handlerFunc) } } // WebDAVFileName determines the name and path of an uploaded file and returns its name if it exists. func WebDAVFileName(request *http.Request, router *gin.RouterGroup, conf *config.Config) (fileName string) { // Check if this is a PUT request, as used for file uploads. if request.Method != MethodPut { return "" } basePath := router.BasePath() // Determine the absolute file path based on the request URL and the configuration. switch basePath { case conf.BaseUri(WebDAVOriginals): // Resolve the requested path safely under OriginalsPath. rel := strings.TrimPrefix(request.URL.Path, basePath) // Make relative if a leading slash remains after trimming the base. rel = strings.TrimLeft(rel, "/\\") if name, err := joinUnderBase(conf.OriginalsPath(), rel); err == nil { fileName = name } else { return "" } case conf.BaseUri(WebDAVImport): // Resolve the requested path safely under ImportPath. rel := strings.TrimPrefix(request.URL.Path, basePath) rel = strings.TrimLeft(rel, "/\\") if name, err := joinUnderBase(conf.ImportPath(), rel); err == nil { fileName = name } else { return "" } default: return "" } // Check if the file actually exists and return an empty string otherwise. if !fs.FileExists(fileName) { return "" } return fileName } // joinUnderBase joins a base directory with a relative name and ensures // that the resulting path stays within the base directory. Absolute // paths and Windows-style volume names are rejected. func joinUnderBase(baseDir, rel string) (string, error) { if rel == "" { return "", fmt.Errorf("invalid path") } // Reject absolute or volume paths. if filepath.IsAbs(rel) || filepath.VolumeName(rel) != "" { return "", fmt.Errorf("invalid path: absolute or volume path not allowed") } cleaned := filepath.Clean(rel) // Compose destination and verify it stays inside base. dest := filepath.Join(baseDir, cleaned) base := filepath.Clean(baseDir) if dest != base && !strings.HasPrefix(dest, base+string(os.PathSeparator)) { return "", fmt.Errorf("invalid path: outside base directory") } return dest, nil } // WebDAVSetFavoriteFlag adds the favorite flag to files uploaded via WebDAV. func WebDAVSetFavoriteFlag(fileName string) { yamlName := fs.AbsPrefix(fileName, false) + fs.ExtYml // Abort if YAML file already exists to avoid overwriting metadata. if fs.FileExists(yamlName) { log.Warnf("webdav: %s already exists", clean.Log(filepath.Base(yamlName))) return } // Make sure directory exists. if err := fs.MkdirAll(filepath.Dir(yamlName)); err != nil { log.Errorf("webdav: %s", err.Error()) return } // Write YAML data to file. if err := fs.WriteFile(yamlName, []byte("Favorite: true\n"), fs.ModeConfigFile); err != nil { log.Errorf("webdav: %s", err.Error()) return } // Log success. log.Infof("webdav: flagged %s as favorite", clean.Log(filepath.Base(fileName))) } // WebDAVSetFileMtime updaters the file modification time based on a Unix timestamp string. func WebDAVSetFileMtime(fileName string, mtimeUnix int64) { if mtime := time.Unix(mtimeUnix, 0); mtimeUnix <= 0 || mtime.IsZero() || time.Now().Before(mtime) { log.Warnf("webdav: invalid mtime provided for %s", clean.Log(filepath.Base(fileName))) } else if mtimeErr := os.Chtimes(fileName, time.Time{}, mtime); mtimeErr != nil { log.Warnf("webdav: failed to set mtime for %s", clean.Log(filepath.Base(fileName))) } else { log.Infof("webdav: set mtime for %s", clean.Log(filepath.Base(fileName))) } }