package webdav import ( "bytes" "context" "fmt" "github.com/rs/zerolog" "github.com/sirrobot01/decypharr/internal/request" "github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/pkg/debrid/debrid" "github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/version" "golang.org/x/net/webdav" "html/template" "io" "net/http" "net/http/httptest" "net/url" "os" path "path/filepath" "slices" "strings" "time" ) type Handler struct { Name string logger zerolog.Logger cache *debrid.Cache RootPath string } func NewHandler(name string, cache *debrid.Cache, logger zerolog.Logger) *Handler { h := &Handler{ Name: name, cache: cache, logger: logger, RootPath: fmt.Sprintf("/%s", name), } return h } // Mkdir implements webdav.FileSystem func (h *Handler) Mkdir(ctx context.Context, name string, perm os.FileMode) error { return os.ErrPermission // Read-only filesystem } // RemoveAll implements webdav.FileSystem func (h *Handler) RemoveAll(ctx context.Context, name string) error { name = path.Clean("/" + name) rootDir := h.getRootPath() if name == rootDir { return os.ErrPermission } torrentName, _ := getName(rootDir, name) cachedTorrent := h.cache.GetTorrentByName(torrentName) if cachedTorrent == nil { h.logger.Debug().Msgf("Torrent not found: %s", torrentName) return nil // It's possible that the torrent was removed } h.cache.OnRemove(cachedTorrent.Id) return nil } // Rename implements webdav.FileSystem func (h *Handler) Rename(ctx context.Context, oldName, newName string) error { return os.ErrPermission // Read-only filesystem } func (h *Handler) getRootPath() string { return fmt.Sprintf("/webdav/%s", h.Name) } func (h *Handler) getTorrentsFolders() []os.FileInfo { return h.cache.GetListing() } func (h *Handler) getParentItems() []string { return []string{"__all__", "torrents", "version.txt"} } func (h *Handler) getParentFiles() []os.FileInfo { now := time.Now() rootFiles := make([]os.FileInfo, 0, len(h.getParentItems())) for _, item := range h.getParentItems() { f := &FileInfo{ name: item, size: 0, mode: 0755 | os.ModeDir, modTime: now, isDir: true, } if item == "version.txt" { f.isDir = false f.size = int64(len(version.GetInfo().String())) } rootFiles = append(rootFiles, f) } return rootFiles } func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { name = utils.UnescapePath(path.Clean("/" + name)) rootDir := h.getRootPath() metadataOnly := ctx.Value("metadataOnly") != nil now := time.Now() // Fast path optimization with a map lookup instead of string comparisons switch name { case rootDir: return &File{ cache: h.cache, isDir: true, children: h.getParentFiles(), name: "/", metadataOnly: true, modTime: now, }, nil case path.Join(rootDir, "version.txt"): versionInfo := version.GetInfo().String() return &File{ cache: h.cache, isDir: false, content: []byte(versionInfo), name: "version.txt", size: int64(len(versionInfo)), metadataOnly: metadataOnly, modTime: now, }, nil } // Single check for top-level folders if h.isParentPath(name) { folderName := strings.TrimPrefix(name, rootDir) folderName = strings.TrimPrefix(folderName, "/") // Only fetcher the torrent folders once children := h.getTorrentsFolders() return &File{ cache: h.cache, isDir: true, children: children, name: folderName, size: 0, metadataOnly: metadataOnly, modTime: now, }, nil } _path := strings.TrimPrefix(name, rootDir) parts := strings.Split(strings.TrimPrefix(_path, "/"), "/") if len(parts) >= 2 && (slices.Contains(h.getParentItems(), parts[0])) { torrentName := parts[1] cachedTorrent := h.cache.GetTorrentByName(torrentName) if cachedTorrent == nil { h.logger.Debug().Msgf("Torrent not found: %s", torrentName) return nil, os.ErrNotExist } if len(parts) == 2 { // Torrent folder level return &File{ cache: h.cache, torrentName: torrentName, isDir: true, children: h.getFileInfos(cachedTorrent.Torrent), name: cachedTorrent.Name, size: cachedTorrent.Size, metadataOnly: metadataOnly, modTime: cachedTorrent.AddedOn, }, nil } // Torrent file level filename := strings.Join(parts[2:], "/") if file, ok := cachedTorrent.Files[filename]; ok { return &File{ cache: h.cache, torrentName: torrentName, fileId: file.Id, isDir: false, name: file.Name, size: file.Size, link: file.Link, metadataOnly: metadataOnly, modTime: cachedTorrent.AddedOn, }, nil } } h.logger.Info().Msgf("File not found: %s", name) return nil, os.ErrNotExist } // Stat implements webdav.FileSystem func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) { f, err := h.OpenFile(ctx, name, os.O_RDONLY, 0) if err != nil { return nil, err } return f.Stat() } func (h *Handler) getFileInfos(torrent *types.Torrent) []os.FileInfo { files := make([]os.FileInfo, 0, len(torrent.Files)) now := time.Now() for _, file := range torrent.Files { files = append(files, &FileInfo{ name: file.Name, size: file.Size, mode: 0644, modTime: now, isDir: false, }) } return files } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Handle OPTIONS if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } // Cache PROPFIND responses for a short time to reduce load. if r.Method == "PROPFIND" { // Determine the Depth; default to "1" if not provided. // Set metadata only ctx := context.WithValue(r.Context(), "metadataOnly", true) r = r.WithContext(ctx) cleanPath := path.Clean(r.URL.Path) if r.Header.Get("Depth") == "" { r.Header.Set("Depth", "1") } // Reject "infinity" depth if r.Header.Get("Depth") == "infinity" { r.Header.Set("Depth", "1") } depth := r.Header.Get("Depth") // Use both path and Depth header to form the cache key. cacheKey := fmt.Sprintf("propfind:%s:%s", cleanPath, depth) // Determine TTL based on the requested folder: // - If the path is exactly the parent folder (which changes frequently), // use a short TTL. // - Otherwise, for deeper (torrent folder) paths, use a longer TTL. ttl := 1 * time.Minute if h.isParentPath(r.URL.Path) { // __all__ or torrents folder // Manually build the xml ttl = 30 * time.Second } if served := h.serveFromCacheIfValid(w, r, cacheKey, ttl); served { return } // No valid cache entry; process the PROPFIND request. responseRecorder := httptest.NewRecorder() handler := &webdav.Handler{ FileSystem: h, LockSystem: webdav.NewMemLS(), Logger: func(r *http.Request, err error) { if err != nil { h.logger.Error().Err(err).Msg("WebDAV error") } }, } handler.ServeHTTP(responseRecorder, r) responseData := responseRecorder.Body.Bytes() gzippedData := request.Gzip(responseData) // Create compressed version h.cache.PropfindResp.Store(cacheKey, debrid.PropfindResponse{ Data: responseData, GzippedData: gzippedData, Ts: time.Now(), }) // Forward the captured response to the client. for k, v := range responseRecorder.Header() { w.Header()[k] = v } if acceptsGzip(r) { w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Vary", "Accept-Encoding") w.Header().Set("Content-Length", fmt.Sprintf("%d", len(gzippedData))) w.WriteHeader(responseRecorder.Code) _, _ = w.Write(gzippedData) } else { w.Header().Set("Content-Length", fmt.Sprintf("%d", len(responseData))) w.WriteHeader(responseRecorder.Code) _, _ = w.Write(responseData) } return } // Handle GET requests for file/directory content if r.Method == "GET" { f, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0) if err != nil { h.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file") http.NotFound(w, r) return } defer f.Close() fi, err := f.Stat() if err != nil { h.logger.Error().Err(err).Msg("Failed to stat file") http.Error(w, "Server Error", http.StatusInternalServerError) return } // If the target is a directory, use your directory listing logic. if fi.IsDir() { h.serveDirectory(w, r, f) return } rs, ok := f.(io.ReadSeeker) if !ok { // If not, read the entire file into memory as a fallback. buf, err := io.ReadAll(f) if err != nil { h.logger.Error().Err(err).Msg("Failed to read file content") http.Error(w, "Server Error", http.StatusInternalServerError) return } rs = bytes.NewReader(buf) } fileName := fi.Name() contentType := getContentType(fileName) w.Header().Set("Content-Type", contentType) // Serve the file with the correct modification time. // http.ServeContent automatically handles Range requests. http.ServeContent(w, r, fileName, fi.ModTime(), rs) return } // Fallback: for other methods, use the standard WebDAV handler. handler := &webdav.Handler{ FileSystem: h, LockSystem: webdav.NewMemLS(), Logger: func(r *http.Request, err error) { if err != nil { h.logger.Error(). Err(err). Str("method", r.Method). Str("path", r.URL.Path). Msg("WebDAV error") } }, } handler.ServeHTTP(w, r) } func getContentType(fileName string) string { contentType := "application/octet-stream" // Determine content type based on file extension switch { case strings.HasSuffix(fileName, ".mp4"): contentType = "video/mp4" case strings.HasSuffix(fileName, ".mkv"): contentType = "video/x-matroska" case strings.HasSuffix(fileName, ".avi"): contentType = "video/x-msvideo" case strings.HasSuffix(fileName, ".mov"): contentType = "video/quicktime" case strings.HasSuffix(fileName, ".m4v"): contentType = "video/x-m4v" case strings.HasSuffix(fileName, ".ts"): contentType = "video/mp2t" case strings.HasSuffix(fileName, ".srt"): contentType = "application/x-subrip" case strings.HasSuffix(fileName, ".vtt"): contentType = "text/vtt" } return contentType } func (h *Handler) isParentPath(_path string) bool { rootPath := h.getRootPath() parents := h.getParentItems() for _, p := range parents { if path.Clean(_path) == path.Clean(path.Join(rootPath, p)) { return true } } return false } func (h *Handler) serveFromCacheIfValid(w http.ResponseWriter, r *http.Request, cacheKey string, ttl time.Duration) bool { respCache, ok := h.cache.PropfindResp.Load(cacheKey) if !ok { return false } if time.Since(respCache.Ts) >= ttl { // Remove expired cache entry return false } w.Header().Set("Content-Type", "application/xml; charset=utf-8") if acceptsGzip(r) && len(respCache.GzippedData) > 0 { w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Vary", "Accept-Encoding") w.Header().Set("Content-Length", fmt.Sprintf("%d", len(respCache.GzippedData))) w.WriteHeader(http.StatusOK) _, _ = w.Write(respCache.GzippedData) } else { w.Header().Set("Content-Length", fmt.Sprintf("%d", len(respCache.Data))) w.WriteHeader(http.StatusOK) _, _ = w.Write(respCache.Data) } return true } func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) { var children []os.FileInfo if f, ok := file.(*File); ok { children = f.children } else { var err error children, err = file.Readdir(-1) if err != nil { http.Error(w, "Failed to list directory", http.StatusInternalServerError) return } } // Clean and prepare the path cleanPath := path.Clean(r.URL.Path) parentPath := path.Dir(cleanPath) showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath // Prepare template data data := struct { Path string ParentPath string ShowParent bool Children []os.FileInfo }{ Path: cleanPath, ParentPath: parentPath, ShowParent: showParent, Children: children, } // Parse and execute template funcMap := template.FuncMap{ "add": func(a, b int) int { return a + b }, "urlpath": func(p string) string { segments := strings.Split(p, "/") for i, segment := range segments { segments[i] = url.PathEscape(segment) } return strings.Join(segments, "/") }, "formatSize": func(bytes int64) string { const ( KB = 1024 MB = 1024 * KB GB = 1024 * MB TB = 1024 * GB ) var size float64 var unit string switch { case bytes >= TB: size = float64(bytes) / TB unit = "TB" case bytes >= GB: size = float64(bytes) / GB unit = "GB" case bytes >= MB: size = float64(bytes) / MB unit = "MB" case bytes >= KB: size = float64(bytes) / KB unit = "KB" default: size = float64(bytes) unit = "bytes" } // Format to 2 decimal places for larger units, no decimals for bytes if unit == "bytes" { return fmt.Sprintf("%.0f %s", size, unit) } return fmt.Sprintf("%.2f %s", size, unit) }, } tmpl, err := template.New("directory").Funcs(funcMap).Parse(directoryTemplate) if err != nil { h.logger.Error().Err(err).Msg("Failed to parse directory template") http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { h.logger.Error().Err(err).Msg("Failed to execute directory template") http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } }