Implementing a streaming setup with Usenet
This commit is contained in:
529
pkg/webdav/usenet_handler.go
Normal file
529
pkg/webdav/usenet_handler.go
Normal file
@@ -0,0 +1,529 @@
|
||||
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)
|
||||
|
||||
// top‐level “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) file‐within‐torrent 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
|
||||
}
|
||||
Reference in New Issue
Block a user