Files
decypharr/pkg/webdav/usenet_handler.go
2025-08-01 15:27:24 +01:00

530 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webdav
import (
"context"
"errors"
"fmt"
"github.com/sirrobot01/decypharr/pkg/usenet"
"golang.org/x/net/webdav"
"io"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/version"
)
type UsenetHandler struct {
name string
logger zerolog.Logger
usenet usenet.Usenet
URLBase string
RootPath string
}
func NewUsenetHandler(name, urlBase string, usenet usenet.Usenet, logger zerolog.Logger) Handler {
h := &UsenetHandler{
name: name,
usenet: usenet,
logger: logger,
URLBase: urlBase,
RootPath: path.Join(urlBase, "webdav", name),
}
return h
}
func (hu *UsenetHandler) Type() string {
return "usenet"
}
func (hu *UsenetHandler) Name() string {
return hu.name
}
func (hu *UsenetHandler) Start(ctx context.Context) error {
return hu.usenet.Start(ctx)
}
func (hu *UsenetHandler) Readiness(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-hu.usenet.IsReady():
// WebDAV is ready, proceed
next.ServeHTTP(w, r)
default:
// WebDAV is still initializing
w.Header().Set("Retry-After", "5")
http.Error(w, "WebDAV service is initializing, please try again shortly", http.StatusServiceUnavailable)
}
})
}
// Mkdir implements webdav.FileSystem
func (hu *UsenetHandler) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
return os.ErrPermission // Read-only filesystem
}
// RemoveAll implements webdav.FileSystem
func (hu *UsenetHandler) RemoveAll(ctx context.Context, name string) error {
if !strings.HasPrefix(name, "/") {
name = "/" + name
}
name = utils.PathUnescape(path.Clean(name))
rootDir := path.Clean(hu.RootPath)
if name == rootDir {
return os.ErrPermission
}
// Skip if it's version.txt
if name == path.Join(rootDir, "version.txt") {
return os.ErrPermission
}
// Check if the name is a parent path
if _, ok := hu.isParentPath(name); ok {
return os.ErrPermission
}
// Check if the name is a torrent folder
rel := strings.TrimPrefix(name, rootDir+"/")
parts := strings.Split(rel, "/")
if len(parts) == 2 && utils.Contains(hu.getParentItems(), parts[0]) {
nzb := hu.usenet.Store().GetByName(parts[1])
if nzb == nil {
return os.ErrNotExist
}
// Remove the nzb from the store
if err := hu.usenet.Store().Delete(nzb.ID); err != nil {
hu.logger.Error().Err(err).Msgf("Failed to remove torrent %s", parts[1])
return err
}
return nil
}
// If we reach here, it means the path is a file
if len(parts) >= 2 {
if utils.Contains(hu.getParentItems(), parts[0]) {
cached := hu.usenet.Store().GetByName(parts[1])
if cached != nil && len(parts) >= 3 {
filename := filepath.Clean(path.Join(parts[2:]...))
if file := cached.GetFileByName(filename); file != nil {
if err := hu.usenet.Store().RemoveFile(cached.ID, file.Name); err != nil {
hu.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, parts[1])
return err
}
// If the file was successfully removed, we can return nil
return nil
}
}
}
}
return nil
}
// Rename implements webdav.FileSystem
func (hu *UsenetHandler) Rename(ctx context.Context, oldName, newName string) error {
return os.ErrPermission // Read-only filesystem
}
func (hu *UsenetHandler) getTorrentsFolders(folder string) []os.FileInfo {
return hu.usenet.Store().GetListing(folder)
}
func (hu *UsenetHandler) getParentItems() []string {
parents := []string{"__all__", "__bad__"}
// version.txt
parents = append(parents, "version.txt")
return parents
}
func (hu *UsenetHandler) getParentFiles() []os.FileInfo {
now := time.Now()
rootFiles := make([]os.FileInfo, 0, len(hu.getParentItems()))
for _, item := range hu.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
}
// GetChildren returns the os.FileInfo slice for “depth-1” children of cleanPath
func (hu *UsenetHandler) GetChildren(name string) []os.FileInfo {
if name[0] != '/' {
name = "/" + name
}
name = utils.PathUnescape(path.Clean(name))
root := path.Clean(hu.RootPath)
// toplevel “parents” (e.g. __all__, torrents etc)
if name == root {
return hu.getParentFiles()
}
if parent, ok := hu.isParentPath(name); ok {
return hu.getTorrentsFolders(parent)
}
// torrent-folder level (e.g. /root/parentFolder/torrentName)
rel := strings.TrimPrefix(name, root+"/")
parts := strings.Split(rel, "/")
if len(parts) == 2 && utils.Contains(hu.getParentItems(), parts[0]) {
if u := hu.usenet.Store().GetByName(parts[1]); u != nil {
return hu.getFileInfos(u)
}
}
return nil
}
func (hu *UsenetHandler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if !strings.HasPrefix(name, "/") {
name = "/" + name
}
name = utils.PathUnescape(path.Clean(name))
rootDir := path.Clean(hu.RootPath)
metadataOnly := ctx.Value(metadataOnlyKey) != nil
now := time.Now()
// 1) special case version.txt
if name == path.Join(rootDir, "version.txt") {
versionInfo := version.GetInfo().String()
return &UsenetFile{
usenet: hu.usenet,
isDir: false,
content: []byte(versionInfo),
name: "version.txt",
size: int64(len(versionInfo)),
metadataOnly: metadataOnly,
modTime: now,
}, nil
}
// 2) directory case: ask GetChildren
if children := hu.GetChildren(name); children != nil {
displayName := filepath.Clean(path.Base(name))
if name == rootDir {
displayName = "/"
}
return &UsenetFile{
usenet: hu.usenet,
isDir: true,
children: children,
name: displayName,
size: 0,
metadataOnly: metadataOnly,
modTime: now,
}, nil
}
// 3) filewithintorrent case
// everything else must be a file under a torrent folder
rel := strings.TrimPrefix(name, rootDir+"/")
parts := strings.Split(rel, "/")
if len(parts) >= 2 {
if utils.Contains(hu.getParentItems(), parts[0]) {
cached := hu.usenet.Store().GetByName(parts[1])
if cached != nil && len(parts) >= 3 {
filename := filepath.Clean(path.Join(parts[2:]...))
if file := cached.GetFileByName(filename); file != nil {
return &UsenetFile{
usenet: hu.usenet,
nzbID: cached.ID,
fileId: file.Name,
isDir: false,
name: file.Name,
size: file.Size,
metadataOnly: metadataOnly,
modTime: cached.AddedOn,
}, nil
}
}
}
}
return nil, os.ErrNotExist
}
// Stat implements webdav.FileSystem
func (hu *UsenetHandler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
f, err := hu.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return nil, err
}
return f.Stat()
}
func (hu *UsenetHandler) getFileInfos(nzb *usenet.NZB) []os.FileInfo {
nzbFiles := nzb.GetFiles()
files := make([]os.FileInfo, 0, len(nzbFiles))
sort.Slice(nzbFiles, func(i, j int) bool {
return nzbFiles[i].Name < nzbFiles[j].Name
})
for _, file := range nzbFiles {
files = append(files, &FileInfo{
name: file.Name,
size: file.Size,
mode: 0644,
modTime: nzb.AddedOn,
isDir: false,
})
}
return files
}
func (hu *UsenetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
hu.handleGet(w, r)
return
case "HEAD":
hu.handleHead(w, r)
return
case "OPTIONS":
hu.handleOptions(w, r)
return
case "PROPFIND":
hu.handlePropfind(w, r)
return
case "DELETE":
if err := hu.handleDelete(w, r); err == nil {
return
}
// fallthrough to default
}
handler := &webdav.Handler{
FileSystem: hu,
LockSystem: webdav.NewMemLS(),
Logger: func(r *http.Request, err error) {
if err != nil {
hu.logger.Trace().
Err(err).
Str("method", r.Method).
Str("path", r.URL.Path).
Msg("WebDAV error")
}
},
}
handler.ServeHTTP(w, r)
}
func (hu *UsenetHandler) isParentPath(urlPath string) (string, bool) {
parents := hu.getParentItems()
lastComponent := path.Base(urlPath)
for _, p := range parents {
if p == lastComponent {
return p, true
}
}
return "", false
}
func (hu *UsenetHandler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
var children []os.FileInfo
if f, ok := file.(*UsenetFile); 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
isBadPath := strings.HasSuffix(cleanPath, "__bad__")
_, canDelete := hu.isParentPath(cleanPath)
// Prepare template data
data := struct {
Path string
ParentPath string
ShowParent bool
Children []os.FileInfo
URLBase string
IsBadPath bool
CanDelete bool
DeleteAllBadTorrentKey string
}{
Path: cleanPath,
ParentPath: parentPath,
ShowParent: showParent,
Children: children,
URLBase: hu.URLBase,
IsBadPath: isBadPath,
CanDelete: canDelete,
DeleteAllBadTorrentKey: DeleteAllBadTorrentKey,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tplDirectory.ExecuteTemplate(w, "directory.html", data); err != nil {
return
}
}
// Handlers
func (hu *UsenetHandler) handlePropfind(w http.ResponseWriter, r *http.Request) {
handlePropfind(hu, hu.logger, w, r)
}
func (hu *UsenetHandler) handleGet(w http.ResponseWriter, r *http.Request) {
fRaw, err := hu.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
if err != nil {
http.NotFound(w, r)
return
}
defer fRaw.Close()
fi, err := fRaw.Stat()
if err != nil {
http.Error(w, "Server Error", http.StatusInternalServerError)
return
}
if fi.IsDir() {
hu.serveDirectory(w, r, fRaw)
return
}
// Set common headers
etag := fmt.Sprintf("\"%x-%x\"", fi.ModTime().Unix(), fi.Size())
ext := path.Ext(fi.Name())
w.Header().Set("ETag", etag)
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
w.Header().Set("Content-Type", getContentType(ext))
w.Header().Set("Connection", "keep-alive")
// Handle File struct with direct streaming
if file, ok := fRaw.(*UsenetFile); ok {
if err := file.StreamResponse(w, r); err != nil {
var streamErr *streamError
if errors.As(err, &streamErr) {
// Handle client disconnections silently (just debug log)
if errors.Is(streamErr.Err, context.Canceled) || errors.Is(streamErr.Err, context.DeadlineExceeded) || streamErr.IsClientDisconnection {
return // Don't log as error or try to write response
}
if streamErr.StatusCode > 0 && !hasHeadersWritten(w) {
return
} else {
hu.logger.Error().
Err(streamErr.Err).
Str("path", r.URL.Path).
Msg("Stream error")
}
} else {
// Generic error
if !hasHeadersWritten(w) {
http.Error(w, "Stream error", http.StatusInternalServerError)
return
} else {
hu.logger.Error().
Err(err).
Str("path", r.URL.Path).
Msg("Stream error after headers written")
}
}
return
}
return
}
// Fallback to ServeContent for other webdav.File implementations
if rs, ok := fRaw.(io.ReadSeeker); ok {
http.ServeContent(w, r, fi.Name(), fi.ModTime(), rs)
} else {
w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
_, _ = io.Copy(w, fRaw)
}
}
func (hu *UsenetHandler) handleHead(w http.ResponseWriter, r *http.Request) {
f, err := hu.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
if err != nil {
hu.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file")
http.NotFound(w, r)
return
}
defer func(f webdav.File) {
err := f.Close()
if err != nil {
return
}
}(f)
fi, err := f.Stat()
if err != nil {
hu.logger.Error().Err(err).Msg("Failed to stat file")
http.Error(w, "Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusOK)
}
func (hu *UsenetHandler) handleOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND")
w.WriteHeader(http.StatusOK)
}
// handleDelete deletes a torrent by id, or all bad torrents if the id is DeleteAllBadTorrentKey
func (hu *UsenetHandler) handleDelete(w http.ResponseWriter, r *http.Request) error {
cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes
_, torrentId := path.Split(cleanPath)
if torrentId == "" {
return os.ErrNotExist
}
if torrentId == DeleteAllBadTorrentKey {
return hu.handleDeleteAll(w)
}
return hu.handleDeleteById(w, torrentId)
}
func (hu *UsenetHandler) handleDeleteById(w http.ResponseWriter, nzID string) error {
cached := hu.usenet.Store().Get(nzID)
if cached == nil {
return os.ErrNotExist
}
err := hu.usenet.Store().Delete(nzID)
if err != nil {
hu.logger.Error().Err(err).Str("nzbID", nzID).Msg("Failed to delete NZB")
http.Error(w, "Failed to delete NZB", http.StatusInternalServerError)
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func (hu *UsenetHandler) handleDeleteAll(w http.ResponseWriter) error {
w.WriteHeader(http.StatusNoContent)
return nil
}