diff --git a/go.mod b/go.mod index 5c3beed..66c0464 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ module github.com/sirrobot01/decypharr -go 1.24 +go 1.24.0 toolchain go1.24.3 require ( github.com/anacrolix/torrent v1.55.0 - github.com/beevik/etree v1.5.1 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/go-chi/chi/v5 v5.1.0 github.com/go-co-op/gocron/v2 v2.16.1 @@ -16,6 +15,7 @@ require ( github.com/puzpuzpuz/xsync/v4 v4.1.0 github.com/robfig/cron/v3 v3.0.1 github.com/rs/zerolog v1.33.0 + github.com/stanNthe5/stringbuf v0.0.3 golang.org/x/crypto v0.33.0 golang.org/x/net v0.35.0 golang.org/x/sync v0.12.0 diff --git a/go.sum b/go.sum index fa37085..b1a5e6a 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,6 @@ github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CM github.com/anacrolix/torrent v1.55.0 h1:s9yh/YGdPmbN9dTa+0Inh2dLdrLQRvEAj1jdFW/Hdd8= github.com/anacrolix/torrent v1.55.0/go.mod h1:sBdZHBSZNj4de0m+EbYg7vvs/G/STubxu/GzzNbojsE= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/beevik/etree v1.5.1 h1:TC3zyxYp+81wAmbsi8SWUpZCurbxa6S8RITYRSkNRwo= -github.com/beevik/etree v1.5.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -204,6 +202,8 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1 github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= +github.com/stanNthe5/stringbuf v0.0.3 h1:3ChRipDckEY6FykaQ1Dowy3B+ZQa72EDBCasvT5+D1w= +github.com/stanNthe5/stringbuf v0.0.3/go.mod h1:hii5Vr+mucoWkNJlIYQVp8YvuPtq45fFnJEAhcPf2cQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/internal/utils/file.go b/internal/utils/file.go index 862f571..f46423a 100644 --- a/internal/utils/file.go +++ b/internal/utils/file.go @@ -1,17 +1,17 @@ package utils -import "strings" +import ( + "net/url" + "strings" +) -func EscapePath(path string) string { - // escape % - escapedPath := strings.ReplaceAll(path, "%", "%25") +func PathUnescape(path string) string { - // add others + // try to use url.PathUnescape + if unescaped, err := url.PathUnescape(path); err == nil { + return unescaped + } - return escapedPath -} - -func UnescapePath(path string) string { // unescape % unescapedPath := strings.ReplaceAll(path, "%25", "%") diff --git a/main.go b/main.go index 5e62493..3d22d61 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,10 @@ import ( "flag" "github.com/sirrobot01/decypharr/cmd/decypharr" "github.com/sirrobot01/decypharr/internal/config" + "github.com/sirrobot01/decypharr/pkg/version" "log" + "net/http" + _ "net/http/pprof" // registers pprof handlers "os" "os/signal" "runtime/debug" @@ -19,6 +22,15 @@ func main() { debug.PrintStack() } }() + + if version.GetInfo().Channel == "dev" { + log.Println("Running in dev mode") + go func() { + if err := http.ListenAndServe(":6060", nil); err != nil { + log.Fatalf("pprof server failed: %v", err) + } + }() + } var configPath string flag.StringVar(&configPath, "config", "/data", "path to the data folder") flag.Parse() diff --git a/pkg/debrid/debrid/refresh.go b/pkg/debrid/debrid/refresh.go index 3c9b511..35dbe96 100644 --- a/pkg/debrid/debrid/refresh.go +++ b/pkg/debrid/debrid/refresh.go @@ -2,7 +2,6 @@ package debrid import ( "fmt" - "github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/pkg/debrid/types" "io" "net/http" @@ -20,7 +19,7 @@ type fileInfo struct { isDir bool } -func (fi *fileInfo) Name() string { return utils.EscapePath(fi.name) } +func (fi *fileInfo) Name() string { return fi.name } func (fi *fileInfo) Size() int64 { return fi.size } func (fi *fileInfo) Mode() os.FileMode { return fi.mode } func (fi *fileInfo) ModTime() time.Time { return fi.modTime } diff --git a/pkg/debrid/debrid/torrent.go b/pkg/debrid/debrid/torrent.go index 95e6543..022df0b 100644 --- a/pkg/debrid/debrid/torrent.go +++ b/pkg/debrid/debrid/torrent.go @@ -27,7 +27,7 @@ const ( filterBySizeGT string = "size_gt" filterBySizeLT string = "size_lt" - + filterBLastAdded string = "last_added" ) @@ -47,7 +47,7 @@ type torrentCache struct { folderListing map[string][]os.FileInfo folderListingMu sync.RWMutex directoriesFilters map[string][]directoryFilter - sortNeeded bool + sortNeeded atomic.Bool } type sortableFile struct { @@ -62,10 +62,10 @@ func newTorrentCache(dirFilters map[string][]directoryFilter) *torrentCache { byID: make(map[string]string), byName: make(map[string]*CachedTorrent), folderListing: make(map[string][]os.FileInfo), - sortNeeded: false, directoriesFilters: dirFilters, } + tc.sortNeeded.Store(false) tc.listing.Store(make([]os.FileInfo, 0)) return tc } @@ -100,12 +100,12 @@ func (tc *torrentCache) set(id, name string, torrent *CachedTorrent) { defer tc.mu.Unlock() tc.byID[id] = name tc.byName[name] = torrent - tc.sortNeeded = true + tc.sortNeeded.Store(true) } func (tc *torrentCache) getListing() []os.FileInfo { // Fast path: if we have a sorted list and no changes since last sort - if !tc.sortNeeded { + if !tc.sortNeeded.Load() { return tc.listing.Load().([]os.FileInfo) } @@ -134,7 +134,7 @@ func (tc *torrentCache) refreshListing() { for name, t := range tc.byName { all = append(all, sortableFile{name, t.AddedOn, t.Size}) } - tc.sortNeeded = false + tc.sortNeeded.Store(false) tc.mu.Unlock() sort.Slice(all, func(i, j int) bool { @@ -261,12 +261,12 @@ func (tc *torrentCache) removeId(id string) { tc.mu.Lock() defer tc.mu.Unlock() delete(tc.byID, id) - tc.sortNeeded = true + tc.sortNeeded.Store(true) } func (tc *torrentCache) remove(name string) { tc.mu.Lock() defer tc.mu.Unlock() delete(tc.byName, name) - tc.sortNeeded = true + tc.sortNeeded.Store(true) } diff --git a/pkg/qbit/downloader.go b/pkg/qbit/downloader.go index eb1e330..467386d 100644 --- a/pkg/qbit/downloader.go +++ b/pkg/qbit/downloader.go @@ -159,7 +159,7 @@ func (q *QBit) createSymlinksWebdav(debridTorrent *debridTypes.Torrent, rclonePa remainingFiles := make(map[string]debridTypes.File) for _, file := range files { - remainingFiles[utils.EscapePath(file.Name)] = file + remainingFiles[file.Name] = file } ticker := time.NewTicker(100 * time.Millisecond) diff --git a/pkg/webdav/file_info.go b/pkg/webdav/file_info.go index 5a29c71..8a34172 100644 --- a/pkg/webdav/file_info.go +++ b/pkg/webdav/file_info.go @@ -1,7 +1,6 @@ package webdav import ( - "github.com/sirrobot01/decypharr/internal/utils" "os" "time" ) @@ -15,7 +14,7 @@ type FileInfo struct { isDir bool } -func (fi *FileInfo) Name() string { return utils.EscapePath(fi.name) } // uses minimal escaping +func (fi *FileInfo) Name() string { return fi.name } // uses minimal escaping func (fi *FileInfo) Size() int64 { return fi.size } func (fi *FileInfo) Mode() os.FileMode { return fi.mode } func (fi *FileInfo) ModTime() time.Time { return fi.modTime } diff --git a/pkg/webdav/handler.go b/pkg/webdav/handler.go index 0dc9d2c..7f78887 100644 --- a/pkg/webdav/handler.go +++ b/pkg/webdav/handler.go @@ -119,7 +119,7 @@ func (h *Handler) getChildren(name string) []os.FileInfo { if name[0] != '/' { name = "/" + name } - name = utils.UnescapePath(path.Clean(name)) + name = utils.PathUnescape(path.Clean(name)) root := path.Clean(h.getRootPath()) // top‐level “parents” (e.g. __all__, torrents) @@ -133,9 +133,8 @@ func (h *Handler) getChildren(name string) []os.FileInfo { // torrent-folder level (e.g. /root/parentFolder/torrentName) rel := strings.TrimPrefix(name, root+string(os.PathSeparator)) parts := strings.Split(rel, string(os.PathSeparator)) - parent, _ := url.PathUnescape(parts[0]) - if len(parts) == 2 && utils.Contains(h.getParentItems(), parent) { - torrentName := utils.UnescapePath(parts[1]) + if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) { + torrentName := parts[1] if t := h.cache.GetTorrentByName(torrentName); t != nil { return h.getFileInfos(t.Torrent) } @@ -147,7 +146,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F if !strings.HasPrefix(name, "/") { name = "/" + name } - name = utils.UnescapePath(path.Clean(name)) + name = utils.PathUnescape(path.Clean(name)) rootDir := path.Clean(h.getRootPath()) metadataOnly := ctx.Value("metadataOnly") != nil now := time.Now() @@ -188,9 +187,8 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F rel := strings.TrimPrefix(name, rootDir+string(os.PathSeparator)) parts := strings.Split(rel, string(os.PathSeparator)) if len(parts) >= 2 { - parent, _ := url.PathUnescape(parts[0]) - if utils.Contains(h.getParentItems(), parent) { - torrentName := utils.UnescapePath(parts[1]) + if utils.Contains(h.getParentItems(), parts[0]) { + torrentName := parts[1] cached := h.cache.GetTorrentByName(torrentName) if cached != nil && len(parts) >= 3 { filename := filepath.Join(parts[2:]...) diff --git a/pkg/webdav/misc.go b/pkg/webdav/misc.go index 3651045..8697f85 100644 --- a/pkg/webdav/misc.go +++ b/pkg/webdav/misc.go @@ -38,3 +38,31 @@ func (h *Handler) getCacheTTL(urlPath string) time.Duration { } return 2 * time.Minute // Longer TTL for other paths } + +var pctHex = "0123456789ABCDEF" + +// fastEscapePath returns a percent-encoded path, preserving '/' +// and only encoding bytes outside the unreserved set: +// +// ALPHA / DIGIT / '-' / '_' / '.' / '~' / '/' +func fastEscapePath(p string) string { + var b strings.Builder + + for i := 0; i < len(p); i++ { + c := p[i] + // unreserved (plus '/') + if (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || + c == '.' || c == '~' || + c == '/' { + b.WriteByte(c) + } else { + b.WriteByte('%') + b.WriteByte(pctHex[c>>4]) + b.WriteByte(pctHex[c&0xF]) + } + } + return b.String() +} diff --git a/pkg/webdav/propfind.go b/pkg/webdav/propfind.go index 2586bd8..6586a65 100644 --- a/pkg/webdav/propfind.go +++ b/pkg/webdav/propfind.go @@ -2,14 +2,20 @@ package webdav import ( "context" - "fmt" + "github.com/stanNthe5/stringbuf" "net/http" - "net/url" "os" "path" + "strconv" "strings" + "sync" + "time" ) +var builderPool = sync.Pool{ + New: func() interface{} { return stringbuf.New("") }, +} + func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { // Setup context for metadata only ctx := context.WithValue(r.Context(), "metadataOnly", true) @@ -25,8 +31,12 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { // Build the list of entries type entry struct { - href string - fi os.FileInfo + href string + escHref string // already XML-safe + percent-escaped + escName string + size int64 + isDir bool + modTime string } // Always include the resource itself @@ -45,65 +55,81 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { return } - // Collect children if a directory and depth allows - children := make([]os.FileInfo, 0) - if fi.IsDir() && depth != "0" { - children = h.getChildren(cleanPath) + var rawEntries []os.FileInfo + if fi.IsDir() { + rawEntries = append(rawEntries, h.getChildren(cleanPath)...) } - entries := make([]entry, 0, 1+len(children)) - entries = append(entries, entry{href: cleanPath, fi: fi}) + now := time.Now().UTC().Format("2006-01-02T15:04:05.000-07:00") + entries := make([]entry, 0, len(rawEntries)+1) + // Add the current file itself + entries = append(entries, entry{ + escHref: xmlEscape(fastEscapePath(cleanPath)), + escName: xmlEscape(fi.Name()), + isDir: fi.IsDir(), + size: fi.Size(), + modTime: fi.ModTime().Format("2006-01-02T15:04:05.000-07:00"), + }) + for _, info := range rawEntries { - for _, child := range children { - childHref := path.Join("/", cleanPath, child.Name()) - if child.IsDir() { - childHref += "/" + nm := info.Name() + // build raw href + href := path.Join("/", cleanPath, nm) + if info.IsDir() { + href += "/" } - entries = append(entries, entry{href: childHref, fi: child}) + + entries = append(entries, entry{ + escHref: xmlEscape(fastEscapePath(href)), + escName: xmlEscape(nm), + isDir: info.IsDir(), + size: info.Size(), + modTime: info.ModTime().Format("2006-01-02T15:04:05.000-07:00"), + }) } - // Use a string builder for creating XML - var sb strings.Builder + sb := builderPool.Get().(stringbuf.StringBuf) + sb.Reset() + defer builderPool.Put(sb) // XML header and main element - sb.WriteString(``) - sb.WriteString(``) - - // Format time once - timeFormat := "2006-01-02T15:04:05.000-07:00" + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(``) // Add responses for each entry for _, e := range entries { - // Format href path properly - u := &url.URL{Path: e.href} - escaped := u.EscapedPath() + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(e.escHref) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(fmt.Sprintf(`%s`, xmlEscape(escaped))) - sb.WriteString(``) - sb.WriteString(``) - - // Resource type differs based on directory vs file - if e.fi.IsDir() { - sb.WriteString(``) + if e.isDir { + _, _ = sb.WriteString(``) } else { - sb.WriteString(``) - sb.WriteString(fmt.Sprintf(`%d`, e.fi.Size())) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(strconv.FormatInt(e.size, 10)) + _, _ = sb.WriteString(``) } - // Always add lastmodified and displayname - lastModified := e.fi.ModTime().Format(timeFormat) - sb.WriteString(fmt.Sprintf(`%s`, xmlEscape(lastModified))) - sb.WriteString(fmt.Sprintf(`%s`, xmlEscape(e.fi.Name()))) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(now) + _, _ = sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(`HTTP/1.1 200 OK`) - sb.WriteString(``) - sb.WriteString(``) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(e.escName) + _, _ = sb.WriteString(``) + + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(`HTTP/1.1 200 OK`) + _, _ = sb.WriteString(``) + _, _ = sb.WriteString(``) } // Close root element - sb.WriteString(``) + _, _ = sb.WriteString(``) // Set headers w.Header().Set("Content-Type", "application/xml; charset=utf-8") @@ -111,18 +137,28 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { // Set status code and write response w.WriteHeader(http.StatusMultiStatus) // 207 MultiStatus - _, _ = w.Write([]byte(sb.String())) + _, _ = w.Write(sb.Bytes()) } // Basic XML escaping function func xmlEscape(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - s = strings.ReplaceAll(s, "'", "'") - s = strings.ReplaceAll(s, "\"", """) - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "\r", " ") - s = strings.ReplaceAll(s, "\t", " ") - return s + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + case '"': + b.WriteString(""") + case '\'': + b.WriteString("'") + default: + b.WriteRune(r) + } + } + return b.String() }