- Cleaup the code

- Add delete button to webdav ui
- Some other bug fixes here and there
This commit is contained in:
Mukhtar Akere
2025-05-15 02:42:38 +01:00
parent 690d7668c1
commit b984697fe3
16 changed files with 332 additions and 297 deletions

View File

@@ -22,7 +22,6 @@ type AllDebrid struct {
Host string `json:"host"`
APIKey string
accounts map[string]types.Account
accountsMu sync.RWMutex
DownloadUncached bool
client *request.Client

View File

@@ -96,16 +96,6 @@ func (tc *torrentCache) set(name string, torrent, newTorrent CachedTorrent) {
tc.sortNeeded.Store(true)
}
func (tc *torrentCache) setMany(torrents map[string]CachedTorrent) {
tc.mu.Lock()
defer tc.mu.Unlock()
for id, torrent := range torrents {
tc.byID[id] = torrent
tc.byName[torrent.Name] = torrent
}
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.Load() {
@@ -135,7 +125,7 @@ func (tc *torrentCache) refreshListing() {
tc.mu.Lock()
all := make([]sortableFile, 0, len(tc.byName))
for name, t := range tc.byName {
all = append(all, sortableFile{t.Id, name, t.AddedOn, t.Size, t.Bad})
all = append(all, sortableFile{t.Id, name, t.AddedOn, t.Bytes, t.Bad})
}
tc.sortNeeded.Store(false)
tc.mu.Unlock()
@@ -279,7 +269,7 @@ func (tc *torrentCache) getIdMaps() map[string]struct{} {
tc.mu.Lock()
defer tc.mu.Unlock()
res := make(map[string]struct{}, len(tc.byID))
for id, _ := range tc.byID {
for id := range tc.byID {
res[id] = struct{}{}
}
return res

View File

@@ -11,7 +11,6 @@ import (
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"strconv"
"sync"
"time"
"net/http"
@@ -23,7 +22,6 @@ type DebridLink struct {
Host string `json:"host"`
APIKey string
accounts map[string]types.Account
accountsMutex sync.RWMutex
DownloadUncached bool
client *request.Client

View File

@@ -28,7 +28,6 @@ type Torbox struct {
Host string `json:"host"`
APIKey string
accounts map[string]types.Account
accountsMutex sync.RWMutex
DownloadUncached bool
client *request.Client

View File

@@ -145,32 +145,32 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="/">
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="{{.URLBase}}">
<i class="bi bi-table me-1"></i>Torrents
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="/download">
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="{{.URLBase}}download">
<i class="bi bi-cloud-download me-1"></i>Download
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="/repair">
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="{{.URLBase}}repair">
<i class="bi bi-tools me-1"></i>Repair
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="{{.URLBase}}config">
<i class="bi bi-gear me-1"></i>Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/webdav" target="_blank">
<a class="nav-link" href="{{.URLBase}}webdav" target="_blank">
<i class="bi bi-cloud me-1"></i>WebDAV
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logs" target="_blank">
<a class="nav-link" href="{{.URLBase}}logs" target="_blank">
<i class="bi bi-journal me-1"></i>Logs
</a>
</li>
@@ -209,23 +209,15 @@
window.urlBase = "{{.URLBase}}";
function joinUrl (base, path) {
if (path.substring(0, 1) === "/") {
// path starts with `/`. Thus it is absolute.
return path;
}
if (base.substring(base.length-1) === "/") {
// base ends with `/`
return base + path;
}
return base + "/" + path;
function joinURL(...segments) {
return segments.join('/').replace(/[/]+/g, '/').replace(/^(.+):\//, '$1://').replace(/^file:/, 'file:/').replace(/\/(?=[?&#])/g, '').replace(/\?/g, '&');
}
function fetcher(endpoint, options = {}) {
// Use the global urlBase or default to empty string
let baseUrl = window.urlBase || '';
let url = joinUrl(baseUrl, endpoint);
let url = joinURL(baseUrl, endpoint);
// Return the regular fetcher with the complete URL
return fetch(url, options);

View File

@@ -64,7 +64,7 @@
createToast(errorText || 'Registration failed', 'error');
});
} else {
window.location.href = joinUrl(window.urlBase, '/');
window.location.href = joinURL(window.urlBase, '/');
}
})
@@ -78,7 +78,7 @@
fetcher('/skip-auth', { method: 'GET' })
.then(response => {
if (response.ok) {
window.location.href = joinUrl(window.urlBase, '/');
window.location.href = joinURL(window.urlBase, '/');
} else {
throw new Error('Failed to skip authentication');
}

View File

@@ -4,10 +4,8 @@ import (
"bytes"
"context"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -27,15 +25,17 @@ type Handler struct {
Name string
logger zerolog.Logger
cache *debrid.Cache
URLBase string
RootPath string
}
func NewHandler(name string, cache *debrid.Cache, logger zerolog.Logger) *Handler {
func NewHandler(name, urlBase string, cache *debrid.Cache, logger zerolog.Logger) *Handler {
h := &Handler{
Name: name,
cache: cache,
logger: logger,
RootPath: fmt.Sprintf("/%s", name),
URLBase: urlBase,
RootPath: path.Join(urlBase, "webdav", name),
}
return h
}
@@ -52,7 +52,7 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error {
}
name = path.Clean(name)
rootDir := path.Clean(h.getRootPath())
rootDir := path.Clean(h.RootPath)
if name == rootDir {
return os.ErrPermission
@@ -74,10 +74,6 @@ 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(path.Join("/", "webdav", "%s"), h.Name)
}
func (h *Handler) getTorrentsFolders(folder string) []os.FileInfo {
return h.cache.GetListing(folder)
}
@@ -120,7 +116,7 @@ func (h *Handler) getChildren(name string) []os.FileInfo {
name = "/" + name
}
name = utils.PathUnescape(path.Clean(name))
root := path.Clean(h.getRootPath())
root := path.Clean(h.RootPath)
// toplevel “parents” (e.g. __all__, torrents etc)
if name == root {
@@ -147,7 +143,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
name = "/" + name
}
name = utils.PathUnescape(path.Clean(name))
rootDir := path.Clean(h.getRootPath())
rootDir := path.Clean(h.RootPath)
metadataOnly := ctx.Value("metadataOnly") != nil
now := time.Now()
@@ -169,7 +165,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
if children := h.getChildren(name); children != nil {
displayName := filepath.Clean(path.Base(name))
if name == rootDir {
displayName = string(os.PathSeparator)
displayName = "/"
}
return &File{
cache: h.cache,
@@ -336,6 +332,8 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
cleanPath := path.Clean(r.URL.Path)
parentPath := path.Dir(cleanPath)
showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath
isBadPath := strings.HasSuffix(cleanPath, "__bad__")
_, canDelete := h.isParentPath(cleanPath)
// Prepare template data
data := struct {
@@ -343,73 +341,21 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
ParentPath string
ShowParent bool
Children []os.FileInfo
URLBase string
IsBadPath bool
CanDelete bool
}{
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)
},
"hasSuffix": strings.HasSuffix,
}
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
URLBase: h.URLBase,
IsBadPath: isBadPath,
CanDelete: canDelete,
}
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)
if err := tplDirectory.ExecuteTemplate(w, "directory.html", data); err != nil {
return
}
}
@@ -423,7 +369,12 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
defer fRaw.Close()
defer func(fRaw webdav.File) {
err := fRaw.Close()
if err != nil {
return
}
}(fRaw)
fi, err := fRaw.Stat()
if err != nil {
@@ -483,7 +434,12 @@ func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
defer f.Close()
defer func(f webdav.File) {
err := f.Close()
if err != nil {
return
}
}(f)
fi, err := f.Stat()
if err != nil {

View File

@@ -1,11 +1,9 @@
package webdav
import (
"net/http"
"net/url"
"os"
"strings"
"time"
)
// getName: Returns the torrent name and filename from the path
@@ -18,27 +16,12 @@ func getName(rootDir, path string) (string, string) {
return parts[1], strings.Join(parts[2:], string(os.PathSeparator)) // Note the change from [0] to [1]
}
func acceptsGzip(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
}
func isValidURL(str string) bool {
u, err := url.Parse(str)
// A valid URL should parse without error, and have a non-empty scheme and host.
return err == nil && u.Scheme != "" && u.Host != ""
}
// 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.
func (h *Handler) getCacheTTL(urlPath string) time.Duration {
if _, ok := h.isParentPath(urlPath); ok {
return 30 * time.Second // Short TTL for parent folders
}
return 2 * time.Minute // Longer TTL for other paths
}
var pctHex = "0123456789ABCDEF"
// fastEscapePath returns a percent-encoded path, preserving '/'

View File

@@ -13,7 +13,11 @@ import (
)
var builderPool = sync.Pool{
New: func() interface{} { return stringbuf.New("") },
New: func() interface{} {
buf := stringbuf.New("")
return buf
},
}
func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
@@ -21,17 +25,10 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), "metadataOnly", true)
r = r.WithContext(ctx)
// Determine depth (default "1")
depth := r.Header.Get("Depth")
if depth == "" {
depth = "1"
}
cleanPath := path.Clean(r.URL.Path)
// Build the list of entries
type entry struct {
href string
escHref string // already XML-safe + percent-escaped
escName string
size int64

View File

@@ -1,149 +0,0 @@
package webdav
const rootTemplate = `
<!DOCTYPE html>
<html>
<head>
<title>WebDAV Shares</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #2c3e50;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 10px 0;
}
a {
color: #3498db;
text-decoration: none;
padding: 10px;
display: block;
border: 1px solid #eee;
border-radius: 4px;
}
a:hover {
background-color: #f7f9fa;
}
</style>
</head>
<body>
<h1>Available WebDAV Shares</h1>
<ul>
{{range .Handlers}}
<li><a href="{{$.Prefix}}{{.RootPath}}">{{$.Prefix}}{{.RootPath}}</a></li>
{{end}}
</ul>
</body>
</html>
`
const directoryTemplate = `
<!DOCTYPE html>
<html>
<head>
<title>Index of {{.Path}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #2c3e50;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 10px 0;
}
a {
color: #3498db;
text-decoration: none;
padding: 10px;
display: block;
border: 1px solid #eee;
border-radius: 4px;
position: relative;
padding-left: 50px; /* Make room for the number */
}
a:hover {
background-color: #f7f9fa;
}
.file-info {
color: #666;
font-size: 0.9em;
float: right;
}
.parent-dir {
background-color: #f8f9fa;
}
.file-number {
position: absolute;
left: 10px;
top: 10px;
width: 30px;
color: #777;
font-weight: bold;
text-align: right;
}
.file-name {
display: inline-block;
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.disabled {
color: #999;
pointer-events: none;
border-color: #f0f0f0;
background-color: #f8f8f8;
cursor: not-allowed;
}
.disabled .file-number {
color: #bbb;
}
.disabled .file-info {
color: #aaa;
}
</style>
</head>
<body>
<h1>Index of {{.Path}}</h1>
<ul>
{{if .ShowParent}}
<li><a href="{{urlpath .ParentPath}}" class="parent-dir"><span class="file-number"></span>Parent Directory</a></li>
{{end}}
{{$isBadPath := hasSuffix .Path "__bad__"}}
{{range $index, $file := .Children}}
<li>
{{if $isBadPath}}
<a class="disabled">
{{else}}
<a href="{{urlpath (printf "%s/%s" $.Path $file.Name)}}">
{{end}}
<span class="file-number">{{add $index 1}}.</span>
<span class="file-name">{{$file.Name}}{{if $file.IsDir}}/{{end}}</span>
<span class="file-info">
{{if not $file.IsDir}}
{{formatSize $file.Size}}
{{end}}
{{$file.ModTime.Format "2006-01-02 15:04:05"}}
</span>
</a>
</li>
{{end}}
</ul>
</body>
</html>
`

View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="">
<head>
<title>Index of {{.Path}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #2c3e50;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 10px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
a {
color: #3498db;
text-decoration: none;
padding: 10px;
flex: 1;
border: 1px solid #eee;
border-radius: 4px;
position: relative;
padding-left: 50px; /* room for number */
}
a:hover {
background-color: #f7f9fa;
}
.file-info {
color: #666;
font-size: 0.9em;
float: right;
}
.parent-dir {
background-color: #f8f9fa;
}
.file-number {
position: absolute;
left: 10px;
top: 10px;
width: 30px;
color: #777;
font-weight: bold;
text-align: right;
}
.file-name {
display: inline-block;
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-btn {
background: transparent;
border: none;
color: #c00;
cursor: pointer;
font-size: 0.9em;
margin-left: 12px;
}
.btn {
display: inline-block;
padding: 4px 8px;
font-size: 0.9em;
text-decoration: none;
border: 1px solid #ccc;
border-radius: 4px;
color: #333;
background-color: #f8f8f8;
cursor: pointer;
}
.btn:hover {
background-color: #e8e8e8;
}
.delete-btn:disabled {
color: #ccc;
cursor: not-allowed;
}
.disabled a {
color: #999;
pointer-events: none;
border-color: #f0f0f0;
background-color: #f8f8f8;
}
</style>
</head>
<body>
<nav>
<a href="{{.URLBase}}" class="btn">&larr; Home</a>
</nav>
<h3>Index of {{.Path}}</h3>
<ul>
{{- if .ShowParent}}
<li>
<a href="{{urlpath .ParentPath}}" class="parent-dir">
<span class="file-number"></span> Parent Directory
</a>
</li>
{{- end}}
{{$isBadPath := hasSuffix .Path "__bad__"}}
{{- range $i, $file := .Children}}
<li class="{{if $isBadPath}}disabled{{end}}">
<a {{ if not $isBadPath}}href="{{urlpath (printf "%s/%s" $.Path $file.Name)}}"{{end}}>
<span class="file-number">{{add $i 1}}.</span>
<span class="file-name">{{$file.Name}}{{if $file.IsDir}}/{{end}}</span>
<span class="file-info">
{{formatSize $file.Size}} ||
{{$file.ModTime.Format "2006-01-02 15:04:05"}}
</span>
</a>
{{- if and $.CanDelete }}
<button
class="delete-btn"
data-path="{{printf "%s/%s" $.Path $file.Name}}">
Delete
</button>
{{- end}}
</li>
{{- end}}
</ul>
<script>
document.querySelectorAll('.delete-btn').forEach(btn=>{
btn.addEventListener('click', ()=>{
let p = btn.getAttribute('data-path');
let name = p.split('/').pop();
if(!confirm('Delete '+name+'?')) return;
fetch(p, { method: 'DELETE' })
.then(_=>location.reload());
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Available WebDAV Shares</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0 auto;
padding: 20px;
}
nav {
margin-bottom: 1rem;
}
.btn {
display: inline-block;
padding: 4px 8px;
font-size: 0.9em;
text-decoration: none;
border: 1px solid #ccc;
border-radius: 4px;
color: #333;
background-color: #f8f8f8;
cursor: pointer;
}
.btn:hover {
background-color: #e8e8e8;
}
.list-group {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
.list-group-item {
padding: 10px 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.list-group-item:last-child {
border-bottom: none;
}
.share-link {
color: #3498db;
text-decoration: none;
}
.share-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<nav>
<a href="{{.URLBase}}" class="btn">&larr; Home</a>
</nav>
<h1>Available WebDAV Shares</h1>
<ul class="list-group">
{{- range .Handlers }}
<li class="list-group-item">
<a href="{{.RootPath}}" class="share-link">{{.Name}}</a>
</li>
{{- end }}
</ul>
</body>
</html>

View File

@@ -2,27 +2,90 @@ package webdav
import (
"context"
"embed"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/pkg/service"
"html/template"
"net/http"
"net/url"
"strings"
"sync"
)
//go:embed templates/*
var templatesFS embed.FS
var (
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)
},
"hasSuffix": strings.HasSuffix,
}
tplRoot = template.Must(template.ParseFS(templatesFS, "templates/root.html"))
tplDirectory = template.Must(template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/directory.html"))
)
type WebDav struct {
Handlers []*Handler
ready chan struct{}
URLBase string
}
func New() *WebDav {
svc := service.GetService()
urlBase := config.Get().URLBase
w := &WebDav{
Handlers: make([]*Handler, 0),
ready: make(chan struct{}),
URLBase: urlBase,
}
for name, c := range svc.Debrid.Caches {
h := NewHandler(name, c, c.GetLogger())
h := NewHandler(name, urlBase, c, c.GetLogger())
w.Handlers = append(w.Handlers, h)
}
return w
@@ -103,7 +166,7 @@ func (wd *WebDav) Start(ctx context.Context) error {
func (wd *WebDav) mountHandlers(r chi.Router) {
for _, h := range wd.Handlers {
r.Mount(h.RootPath, h)
r.Mount("/"+h.Name, h) // Mount to /name since router is already prefixed with /webdav
}
}
@@ -127,20 +190,14 @@ func (wd *WebDav) handleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, err := template.New("root").Parse(rootTemplate)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := struct {
Handlers []*Handler
Prefix string
URLBase string
}{
Handlers: wd.Handlers,
Prefix: "/webdav",
URLBase: wd.URLBase,
}
if err := tmpl.Execute(w, data); err != nil {
if err := tplRoot.Execute(w, data); err != nil {
return
}
}