Implementing a streaming setup with Usenet

This commit is contained in:
Mukhtar Akere
2025-08-01 15:27:24 +01:00
parent afe577bf2f
commit f9861e3b54
65 changed files with 9437 additions and 924 deletions

View File

@@ -1,472 +1,8 @@
package webdav
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
)
var streamingTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 60 * time.Second, // give the upstream a minute to send headers
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: true, // close after each request
ForceAttemptHTTP2: false, // dont speak HTTP/2
// this line is what truly blocks HTTP/2:
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
}
var sharedClient = &http.Client{
Transport: streamingTransport,
Timeout: 0,
}
type streamError struct {
Err error
StatusCode int
IsClientDisconnection bool
}
func (e *streamError) Error() string {
return e.Err.Error()
}
func (e *streamError) Unwrap() error {
return e.Err
}
type File struct {
name string
torrentName string
link string
downloadLink string
size int64
isDir bool
fileId string
isRar bool
metadataOnly bool
content []byte
children []os.FileInfo // For directories
cache *store.Cache
modTime time.Time
// Minimal state for interface compliance only
readOffset int64 // Only used for Read() method compliance
}
// File interface implementations for File
func (f *File) Close() error {
if f.isDir {
return nil // No resources to close for directories
}
// For files, we don't have any resources to close either
// This is just to satisfy the os.File interface
f.content = nil
f.children = nil
f.downloadLink = ""
f.readOffset = 0
return nil
}
func (f *File) getDownloadLink() (string, error) {
// Check if we already have a final URL cached
if f.downloadLink != "" && isValidURL(f.downloadLink) {
return f.downloadLink, nil
}
downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link)
if err != nil {
return "", err
}
if downloadLink != "" && isValidURL(downloadLink) {
f.downloadLink = downloadLink
return downloadLink, nil
}
return "", os.ErrNotExist
}
func (f *File) getDownloadByteRange() (*[2]int64, error) {
byteRange, err := f.cache.GetDownloadByteRange(f.torrentName, f.name)
if err != nil {
return nil, err
}
return byteRange, nil
}
func (f *File) servePreloadedContent(w http.ResponseWriter, r *http.Request) error {
content := f.content
size := int64(len(content))
// Handle range requests for preloaded content
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
ranges, err := parseRange(rangeHeader, size)
if err != nil || len(ranges) != 1 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
}
start, end := ranges[0].start, ranges[0].end
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, err = w.Write(content[start : end+1])
return err
}
// Full content
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusOK)
_, err := w.Write(content)
return err
}
func (f *File) StreamResponse(w http.ResponseWriter, r *http.Request) error {
// Handle preloaded content files
if f.content != nil {
return f.servePreloadedContent(w, r)
}
// Try streaming with retry logic
return f.streamWithRetry(w, r, 0)
}
func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
const maxRetries = 3
_log := f.cache.Logger()
// Get download link (with caching optimization)
downloadLink, err := f.getDownloadLink()
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed}
}
if downloadLink == "" {
return &streamError{Err: fmt.Errorf("empty download link"), StatusCode: http.StatusNotFound}
}
// Create upstream request with streaming optimizations
upstreamReq, err := http.NewRequest("GET", downloadLink, nil)
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusInternalServerError}
}
setVideoStreamingHeaders(upstreamReq)
// Handle range requests (critical for video seeking)
isRangeRequest := f.handleRangeRequest(upstreamReq, r, w)
if isRangeRequest == -1 {
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
}
resp, err := sharedClient.Do(upstreamReq)
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable}
}
defer resp.Body.Close()
// Handle upstream errors with retry logic
shouldRetry, retryErr := f.handleUpstream(resp, retryCount, maxRetries)
if shouldRetry && retryCount < maxRetries {
// Retry with new download link
_log.Debug().
Int("retry_count", retryCount+1).
Str("file", f.name).
Msg("Retrying stream request")
return f.streamWithRetry(w, r, retryCount+1)
}
if retryErr != nil {
return retryErr
}
setVideoResponseHeaders(w, resp, isRangeRequest == 1)
return f.streamBuffer(w, resp.Body)
}
func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader) error {
flusher, ok := w.(http.Flusher)
if !ok {
return fmt.Errorf("response does not support flushing")
}
smallBuf := make([]byte, 64*1024) // 64 KB
if n, err := src.Read(smallBuf); n > 0 {
if _, werr := w.Write(smallBuf[:n]); werr != nil {
return werr
}
flusher.Flush()
} else if err != nil && err != io.EOF {
return err
}
buf := make([]byte, 256*1024) // 256 KB
for {
n, readErr := src.Read(buf)
if n > 0 {
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
if isClientDisconnection(writeErr) {
return &streamError{Err: writeErr, StatusCode: 0, IsClientDisconnection: true}
}
return writeErr
}
flusher.Flush()
}
if readErr != nil {
if readErr == io.EOF {
return nil
}
if isClientDisconnection(readErr) {
return &streamError{Err: readErr, StatusCode: 0, IsClientDisconnection: true}
}
return readErr
}
}
}
func (f *File) handleUpstream(resp *http.Response, retryCount, maxRetries int) (shouldRetry bool, err error) {
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
return false, nil
}
_log := f.cache.Logger()
// Clean up response body properly
cleanupResp := func(resp *http.Response) {
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
switch resp.StatusCode {
case http.StatusServiceUnavailable:
// Read the body to check for specific error messages
body, readErr := io.ReadAll(resp.Body)
cleanupResp(resp)
if readErr != nil {
_log.Error().Err(readErr).Msg("Failed to read response body")
return false, &streamError{
Err: fmt.Errorf("failed to read error response: %w", readErr),
StatusCode: http.StatusServiceUnavailable,
}
}
bodyStr := string(body)
if strings.Contains(bodyStr, "you have exceeded your traffic") {
_log.Debug().
Str("file", f.name).
Int("retry_count", retryCount).
Msg("Bandwidth exceeded. Marking link as invalid")
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "bandwidth_exceeded")
// Retry with a different API key if available and we haven't exceeded retries
if retryCount < maxRetries {
return true, nil
}
return false, &streamError{
Err: fmt.Errorf("bandwidth exceeded after %d retries", retryCount),
StatusCode: http.StatusServiceUnavailable,
}
}
return false, &streamError{
Err: fmt.Errorf("service unavailable: %s", bodyStr),
StatusCode: http.StatusServiceUnavailable,
}
case http.StatusNotFound:
cleanupResp(resp)
_log.Debug().
Str("file", f.name).
Int("retry_count", retryCount).
Msg("Link not found (404). Marking link as invalid and regenerating")
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "link_not_found")
// Try to regenerate download link if we haven't exceeded retries
if retryCount < maxRetries {
// Clear cached link to force regeneration
f.downloadLink = ""
return true, nil
}
return false, &streamError{
Err: fmt.Errorf("file not found after %d retries", retryCount),
StatusCode: http.StatusNotFound,
}
default:
body, _ := io.ReadAll(resp.Body)
cleanupResp(resp)
_log.Error().
Int("status_code", resp.StatusCode).
Str("file", f.name).
Str("response_body", string(body)).
Msg("Unexpected upstream error")
return false, &streamError{
Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)),
StatusCode: http.StatusBadGateway,
}
}
}
func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int {
rangeHeader := r.Header.Get("Range")
if rangeHeader == "" {
// For video files, apply byte range if exists
if byteRange, _ := f.getDownloadByteRange(); byteRange != nil {
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange[0], byteRange[1]))
}
return 0 // No range request
}
// Parse range request
ranges, err := parseRange(rangeHeader, f.size)
if err != nil || len(ranges) != 1 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size))
return -1 // Invalid range
}
// Apply byte range offset if exists
byteRange, _ := f.getDownloadByteRange()
start, end := ranges[0].start, ranges[0].end
if byteRange != nil {
start += byteRange[0]
end += byteRange[0]
}
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
return 1 // Valid range request
}
/*
These are the methods that implement the os.File interface for the File type.
Only Stat and ReadDir are used
*/
func (f *File) Stat() (os.FileInfo, error) {
if f.isDir {
return &FileInfo{
name: f.name,
size: 0,
mode: 0755 | os.ModeDir,
modTime: f.modTime,
isDir: true,
}, nil
}
return &FileInfo{
name: f.name,
size: f.size,
mode: 0644,
modTime: f.modTime,
isDir: false,
}, nil
}
func (f *File) Read(p []byte) (n int, err error) {
if f.isDir {
return 0, os.ErrInvalid
}
if f.metadataOnly {
return 0, io.EOF
}
// For preloaded content files (like version.txt)
if f.content != nil {
if f.readOffset >= int64(len(f.content)) {
return 0, io.EOF
}
n = copy(p, f.content[f.readOffset:])
f.readOffset += int64(n)
return n, nil
}
// For streaming files, return an error to force use of StreamResponse
return 0, fmt.Errorf("use StreamResponse method for streaming files")
}
func (f *File) Seek(offset int64, whence int) (int64, error) {
if f.isDir {
return 0, os.ErrInvalid
}
// Only handle seeking for preloaded content
if f.content != nil {
newOffset := f.readOffset
switch whence {
case io.SeekStart:
newOffset = offset
case io.SeekCurrent:
newOffset += offset
case io.SeekEnd:
newOffset = int64(len(f.content)) + offset
default:
return 0, os.ErrInvalid
}
if newOffset < 0 {
newOffset = 0
}
if newOffset > int64(len(f.content)) {
newOffset = int64(len(f.content))
}
f.readOffset = newOffset
return f.readOffset, nil
}
// For streaming files, return error to force use of StreamResponse
return 0, fmt.Errorf("use StreamResponse method for streaming files")
}
func (f *File) Write(p []byte) (n int, err error) {
return 0, os.ErrPermission
}
func (f *File) Readdir(count int) ([]os.FileInfo, error) {
if !f.isDir {
return nil, os.ErrInvalid
}
if count <= 0 {
return f.children, nil
}
if len(f.children) == 0 {
return nil, io.EOF
}
if count > len(f.children) {
count = len(f.children)
}
files := f.children[:count]
f.children = f.children[count:]
return files, nil
type File interface {
Name() string
Size() int64
IsDir() bool
ModTime() string
}

View File

@@ -240,3 +240,28 @@ func setVideoResponseHeaders(w http.ResponseWriter, resp *http.Response, isRange
w.WriteHeader(resp.StatusCode)
}
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
}

View File

@@ -2,6 +2,7 @@ package webdav
import (
"context"
"github.com/rs/zerolog"
"github.com/stanNthe5/stringbuf"
"net/http"
"os"
@@ -18,7 +19,7 @@ const (
metadataOnlyKey contextKey = "metadataOnly"
)
func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
func handlePropfind(h Handler, logger zerolog.Logger, w http.ResponseWriter, r *http.Request) {
// Setup context for metadata only
ctx := context.WithValue(r.Context(), metadataOnlyKey, true)
r = r.WithContext(ctx)
@@ -37,7 +38,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
// Always include the resource itself
f, err := h.OpenFile(r.Context(), cleanPath, os.O_RDONLY, 0)
if err != nil {
h.logger.Error().Err(err).Str("path", cleanPath).Msg("Failed to open file")
logger.Error().Err(err).Str("path", cleanPath).Msg("Failed to open file")
http.NotFound(w, r)
return
}
@@ -45,14 +46,14 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
fi, err := f.Stat()
if err != nil {
h.logger.Error().Err(err).Msg("Failed to stat file")
logger.Error().Err(err).Msg("Failed to stat file")
http.Error(w, "Server Error", http.StatusInternalServerError)
return
}
var rawEntries []os.FileInfo
if fi.IsDir() {
rawEntries = append(rawEntries, h.getChildren(cleanPath)...)
rawEntries = append(rawEntries, h.GetChildren(cleanPath)...)
}
entries := make([]entry, 0, len(rawEntries)+1)

472
pkg/webdav/torrent_file.go Normal file
View File

@@ -0,0 +1,472 @@
package webdav
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
)
var streamingTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 60 * time.Second, // give the upstream a minute to send headers
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: true, // close after each request
ForceAttemptHTTP2: false, // dont speak HTTP/2
// this line is what truly blocks HTTP/2:
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
}
var sharedClient = &http.Client{
Transport: streamingTransport,
Timeout: 0,
}
type streamError struct {
Err error
StatusCode int
IsClientDisconnection bool
}
func (e *streamError) Error() string {
return e.Err.Error()
}
func (e *streamError) Unwrap() error {
return e.Err
}
type TorrentFile struct {
name string
torrentName string
link string
downloadLink string
size int64
isDir bool
fileId string
isRar bool
metadataOnly bool
content []byte
children []os.FileInfo // For directories
cache *store.Cache
modTime time.Time
// Minimal state for interface compliance only
readOffset int64 // Only used for Read() method compliance
}
// TorrentFile interface implementations for TorrentFile
func (f *TorrentFile) Close() error {
if f.isDir {
return nil // No resources to close for directories
}
// For files, we don't have any resources to close either
// This is just to satisfy the os.TorrentFile interface
f.content = nil
f.children = nil
f.downloadLink = ""
f.readOffset = 0
return nil
}
func (f *TorrentFile) getDownloadLink() (string, error) {
// Check if we already have a final URL cached
if f.downloadLink != "" && isValidURL(f.downloadLink) {
return f.downloadLink, nil
}
downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link)
if err != nil {
return "", err
}
if downloadLink != "" && isValidURL(downloadLink) {
f.downloadLink = downloadLink
return downloadLink, nil
}
return "", os.ErrNotExist
}
func (f *TorrentFile) getDownloadByteRange() (*[2]int64, error) {
byteRange, err := f.cache.GetDownloadByteRange(f.torrentName, f.name)
if err != nil {
return nil, err
}
return byteRange, nil
}
func (f *TorrentFile) servePreloadedContent(w http.ResponseWriter, r *http.Request) error {
content := f.content
size := int64(len(content))
// Handle range requests for preloaded content
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
ranges, err := parseRange(rangeHeader, size)
if err != nil || len(ranges) != 1 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
}
start, end := ranges[0].start, ranges[0].end
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, err = w.Write(content[start : end+1])
return err
}
// Full content
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusOK)
_, err := w.Write(content)
return err
}
func (f *TorrentFile) StreamResponse(w http.ResponseWriter, r *http.Request) error {
// Handle preloaded content files
if f.content != nil {
return f.servePreloadedContent(w, r)
}
// Try streaming with retry logic
return f.streamWithRetry(w, r, 0)
}
func (f *TorrentFile) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
const maxRetries = 3
_log := f.cache.Logger()
// Get download link (with caching optimization)
downloadLink, err := f.getDownloadLink()
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed}
}
if downloadLink == "" {
return &streamError{Err: fmt.Errorf("empty download link"), StatusCode: http.StatusNotFound}
}
// Create upstream request with streaming optimizations
upstreamReq, err := http.NewRequest("GET", downloadLink, nil)
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusInternalServerError}
}
setVideoStreamingHeaders(upstreamReq)
// Handle range requests (critical for video seeking)
isRangeRequest := f.handleRangeRequest(upstreamReq, r, w)
if isRangeRequest == -1 {
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
}
resp, err := sharedClient.Do(upstreamReq)
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable}
}
defer resp.Body.Close()
// Handle upstream errors with retry logic
shouldRetry, retryErr := f.handleUpstream(resp, retryCount, maxRetries)
if shouldRetry && retryCount < maxRetries {
// Retry with new download link
_log.Debug().
Int("retry_count", retryCount+1).
Str("file", f.name).
Msg("Retrying stream request")
return f.streamWithRetry(w, r, retryCount+1)
}
if retryErr != nil {
return retryErr
}
setVideoResponseHeaders(w, resp, isRangeRequest == 1)
return f.streamBuffer(w, resp.Body)
}
func (f *TorrentFile) streamBuffer(w http.ResponseWriter, src io.Reader) error {
flusher, ok := w.(http.Flusher)
if !ok {
return fmt.Errorf("response does not support flushing")
}
smallBuf := make([]byte, 64*1024) // 64 KB
if n, err := src.Read(smallBuf); n > 0 {
if _, werr := w.Write(smallBuf[:n]); werr != nil {
return werr
}
flusher.Flush()
} else if err != nil && err != io.EOF {
return err
}
buf := make([]byte, 256*1024) // 256 KB
for {
n, readErr := src.Read(buf)
if n > 0 {
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
if isClientDisconnection(writeErr) {
return &streamError{Err: writeErr, StatusCode: 0, IsClientDisconnection: true}
}
return writeErr
}
flusher.Flush()
}
if readErr != nil {
if readErr == io.EOF {
return nil
}
if isClientDisconnection(readErr) {
return &streamError{Err: readErr, StatusCode: 0, IsClientDisconnection: true}
}
return readErr
}
}
}
func (f *TorrentFile) handleUpstream(resp *http.Response, retryCount, maxRetries int) (shouldRetry bool, err error) {
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
return false, nil
}
_log := f.cache.Logger()
// Clean up response body properly
cleanupResp := func(resp *http.Response) {
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
switch resp.StatusCode {
case http.StatusServiceUnavailable:
// Read the body to check for specific error messages
body, readErr := io.ReadAll(resp.Body)
cleanupResp(resp)
if readErr != nil {
_log.Error().Err(readErr).Msg("Failed to read response body")
return false, &streamError{
Err: fmt.Errorf("failed to read error response: %w", readErr),
StatusCode: http.StatusServiceUnavailable,
}
}
bodyStr := string(body)
if strings.Contains(bodyStr, "you have exceeded your traffic") {
_log.Debug().
Str("file", f.name).
Int("retry_count", retryCount).
Msg("Bandwidth exceeded. Marking link as invalid")
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "bandwidth_exceeded")
// Retry with a different API key if available and we haven't exceeded retries
if retryCount < maxRetries {
return true, nil
}
return false, &streamError{
Err: fmt.Errorf("bandwidth exceeded after %d retries", retryCount),
StatusCode: http.StatusServiceUnavailable,
}
}
return false, &streamError{
Err: fmt.Errorf("service unavailable: %s", bodyStr),
StatusCode: http.StatusServiceUnavailable,
}
case http.StatusNotFound:
cleanupResp(resp)
_log.Debug().
Str("file", f.name).
Int("retry_count", retryCount).
Msg("Link not found (404). Marking link as invalid and regenerating")
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "link_not_found")
// Try to regenerate download link if we haven't exceeded retries
if retryCount < maxRetries {
// Clear cached link to force regeneration
f.downloadLink = ""
return true, nil
}
return false, &streamError{
Err: fmt.Errorf("file not found after %d retries", retryCount),
StatusCode: http.StatusNotFound,
}
default:
body, _ := io.ReadAll(resp.Body)
cleanupResp(resp)
_log.Error().
Int("status_code", resp.StatusCode).
Str("file", f.name).
Str("response_body", string(body)).
Msg("Unexpected upstream error")
return false, &streamError{
Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)),
StatusCode: http.StatusBadGateway,
}
}
}
func (f *TorrentFile) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int {
rangeHeader := r.Header.Get("Range")
if rangeHeader == "" {
// For video files, apply byte range if exists
if byteRange, _ := f.getDownloadByteRange(); byteRange != nil {
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange[0], byteRange[1]))
}
return 0 // No range request
}
// Parse range request
ranges, err := parseRange(rangeHeader, f.size)
if err != nil || len(ranges) != 1 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size))
return -1 // Invalid range
}
// Apply byte range offset if exists
byteRange, _ := f.getDownloadByteRange()
start, end := ranges[0].start, ranges[0].end
if byteRange != nil {
start += byteRange[0]
end += byteRange[0]
}
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
return 1 // Valid range request
}
/*
These are the methods that implement the os.TorrentFile interface for the TorrentFile type.
Only Stat and ReadDir are used
*/
func (f *TorrentFile) Stat() (os.FileInfo, error) {
if f.isDir {
return &FileInfo{
name: f.name,
size: 0,
mode: 0755 | os.ModeDir,
modTime: f.modTime,
isDir: true,
}, nil
}
return &FileInfo{
name: f.name,
size: f.size,
mode: 0644,
modTime: f.modTime,
isDir: false,
}, nil
}
func (f *TorrentFile) Read(p []byte) (n int, err error) {
if f.isDir {
return 0, os.ErrInvalid
}
if f.metadataOnly {
return 0, io.EOF
}
// For preloaded content files (like version.txt)
if f.content != nil {
if f.readOffset >= int64(len(f.content)) {
return 0, io.EOF
}
n = copy(p, f.content[f.readOffset:])
f.readOffset += int64(n)
return n, nil
}
// For streaming files, return an error to force use of StreamResponse
return 0, fmt.Errorf("use StreamResponse method for streaming files")
}
func (f *TorrentFile) Seek(offset int64, whence int) (int64, error) {
if f.isDir {
return 0, os.ErrInvalid
}
// Only handle seeking for preloaded content
if f.content != nil {
newOffset := f.readOffset
switch whence {
case io.SeekStart:
newOffset = offset
case io.SeekCurrent:
newOffset += offset
case io.SeekEnd:
newOffset = int64(len(f.content)) + offset
default:
return 0, os.ErrInvalid
}
if newOffset < 0 {
newOffset = 0
}
if newOffset > int64(len(f.content)) {
newOffset = int64(len(f.content))
}
f.readOffset = newOffset
return f.readOffset, nil
}
// For streaming files, return error to force use of StreamResponse
return 0, fmt.Errorf("use StreamResponse method for streaming files")
}
func (f *TorrentFile) Write(p []byte) (n int, err error) {
return 0, os.ErrPermission
}
func (f *TorrentFile) Readdir(count int) ([]os.FileInfo, error) {
if !f.isDir {
return nil, os.ErrInvalid
}
if count <= 0 {
return f.children, nil
}
if len(f.children) == 0 {
return nil, io.EOF
}
if count > len(f.children) {
count = len(f.children)
}
files := f.children[:count]
f.children = f.children[count:]
return files, nil
}

View File

@@ -24,17 +24,17 @@ import (
const DeleteAllBadTorrentKey = "DELETE_ALL_BAD_TORRENTS"
type Handler struct {
Name string
type TorrentHandler struct {
name string
logger zerolog.Logger
cache *store.Cache
URLBase string
RootPath string
}
func NewHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger) *Handler {
h := &Handler{
Name: name,
func NewTorrentHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger) Handler {
h := &TorrentHandler{
name: name,
cache: cache,
logger: logger,
URLBase: urlBase,
@@ -43,15 +43,18 @@ func NewHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger)
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
func (ht *TorrentHandler) Start(ctx context.Context) error {
return ht.cache.Start(ctx)
}
func (h *Handler) readinessMiddleware(next http.Handler) http.Handler {
func (ht *TorrentHandler) Type() string {
return "torrent"
}
func (ht *TorrentHandler) Readiness(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-h.cache.IsReady():
case <-ht.cache.IsReady():
// WebDAV is ready, proceed
next.ServeHTTP(w, r)
default:
@@ -62,13 +65,23 @@ func (h *Handler) readinessMiddleware(next http.Handler) http.Handler {
})
}
// Name returns the name of the handler
func (ht *TorrentHandler) Name() string {
return ht.name
}
// Mkdir implements webdav.FileSystem
func (ht *TorrentHandler) 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 {
func (ht *TorrentHandler) RemoveAll(ctx context.Context, name string) error {
if !strings.HasPrefix(name, "/") {
name = "/" + name
}
name = utils.PathUnescape(path.Clean(name))
rootDir := path.Clean(h.RootPath)
rootDir := path.Clean(ht.RootPath)
if name == rootDir {
return os.ErrPermission
@@ -80,33 +93,33 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error {
}
// Check if the name is a parent path
if _, ok := h.isParentPath(name); ok {
if _, ok := ht.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(h.getParentItems(), parts[0]) {
if len(parts) == 2 && utils.Contains(ht.getParentItems(), parts[0]) {
torrentName := parts[1]
torrent := h.cache.GetTorrentByName(torrentName)
torrent := ht.cache.GetTorrentByName(torrentName)
if torrent == nil {
return os.ErrNotExist
}
// Remove the torrent from the cache and debrid
h.cache.OnRemove(torrent.Id)
ht.cache.OnRemove(torrent.Id)
return nil
}
// If we reach here, it means the path is a file
if len(parts) >= 2 {
if utils.Contains(h.getParentItems(), parts[0]) {
if utils.Contains(ht.getParentItems(), parts[0]) {
torrentName := parts[1]
cached := h.cache.GetTorrentByName(torrentName)
cached := ht.cache.GetTorrentByName(torrentName)
if cached != nil && len(parts) >= 3 {
filename := filepath.Clean(path.Join(parts[2:]...))
if file, ok := cached.GetFile(filename); ok {
if err := h.cache.RemoveFile(cached.Id, file.Name); err != nil {
h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName)
if err := ht.cache.RemoveFile(cached.Id, file.Name); err != nil {
ht.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName)
return err
}
// If the file was successfully removed, we can return nil
@@ -120,29 +133,29 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error {
}
// Rename implements webdav.FileSystem
func (h *Handler) Rename(ctx context.Context, oldName, newName string) error {
func (ht *TorrentHandler) Rename(ctx context.Context, oldName, newName string) error {
return os.ErrPermission // Read-only filesystem
}
func (h *Handler) getTorrentsFolders(folder string) []os.FileInfo {
return h.cache.GetListing(folder)
func (ht *TorrentHandler) getTorrentsFolders(folder string) []os.FileInfo {
return ht.cache.GetListing(folder)
}
func (h *Handler) getParentItems() []string {
func (ht *TorrentHandler) getParentItems() []string {
parents := []string{"__all__", "torrents", "__bad__"}
// Add custom folders
parents = append(parents, h.cache.GetCustomFolders()...)
parents = append(parents, ht.cache.GetCustomFolders()...)
// version.txt
parents = append(parents, "version.txt")
return parents
}
func (h *Handler) getParentFiles() []os.FileInfo {
func (ht *TorrentHandler) getParentFiles() []os.FileInfo {
now := time.Now()
rootFiles := make([]os.FileInfo, 0, len(h.getParentItems()))
for _, item := range h.getParentItems() {
rootFiles := make([]os.FileInfo, 0, len(ht.getParentItems()))
for _, item := range ht.getParentItems() {
f := &FileInfo{
name: item,
size: 0,
@@ -159,49 +172,49 @@ func (h *Handler) getParentFiles() []os.FileInfo {
return rootFiles
}
// returns the os.FileInfo slice for “depth-1” children of cleanPath
func (h *Handler) getChildren(name string) []os.FileInfo {
// GetChildren returns the os.FileInfo slice for “depth-1” children of cleanPath
func (ht *TorrentHandler) GetChildren(name string) []os.FileInfo {
if name[0] != '/' {
name = "/" + name
}
name = utils.PathUnescape(path.Clean(name))
root := path.Clean(h.RootPath)
root := path.Clean(ht.RootPath)
// toplevel “parents” (e.g. __all__, torrents etc)
if name == root {
return h.getParentFiles()
return ht.getParentFiles()
}
// one level down (e.g. /root/parentFolder)
if parent, ok := h.isParentPath(name); ok {
return h.getTorrentsFolders(parent)
if parent, ok := ht.isParentPath(name); ok {
return ht.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(h.getParentItems(), parts[0]) {
if len(parts) == 2 && utils.Contains(ht.getParentItems(), parts[0]) {
torrentName := parts[1]
if t := h.cache.GetTorrentByName(torrentName); t != nil {
return h.getFileInfos(t)
if t := ht.cache.GetTorrentByName(torrentName); t != nil {
return ht.getFileInfos(t)
}
}
return nil
}
func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
func (ht *TorrentHandler) 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(h.RootPath)
rootDir := path.Clean(ht.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 &File{
cache: h.cache,
return &TorrentFile{
cache: ht.cache,
isDir: false,
content: []byte(versionInfo),
name: "version.txt",
@@ -211,14 +224,14 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
}, nil
}
// 2) directory case: ask getChildren
if children := h.getChildren(name); children != nil {
// 2) directory case: ask Children
if children := ht.GetChildren(name); children != nil {
displayName := filepath.Clean(path.Base(name))
if name == rootDir {
displayName = "/"
}
return &File{
cache: h.cache,
return &TorrentFile{
cache: ht.cache,
isDir: true,
children: children,
name: displayName,
@@ -233,14 +246,14 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
rel := strings.TrimPrefix(name, rootDir+"/")
parts := strings.Split(rel, "/")
if len(parts) >= 2 {
if utils.Contains(h.getParentItems(), parts[0]) {
if utils.Contains(ht.getParentItems(), parts[0]) {
torrentName := parts[1]
cached := h.cache.GetTorrentByName(torrentName)
cached := ht.cache.GetTorrentByName(torrentName)
if cached != nil && len(parts) >= 3 {
filename := filepath.Clean(path.Join(parts[2:]...))
if file, ok := cached.GetFile(filename); ok && !file.Deleted {
return &File{
cache: h.cache,
return &TorrentFile{
cache: ht.cache,
torrentName: torrentName,
fileId: file.Id,
isDir: false,
@@ -255,21 +268,19 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
}
}
}
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)
func (ht *TorrentHandler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
f, err := ht.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return nil, err
}
return f.Stat()
}
func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo {
func (ht *TorrentHandler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo {
torrentFiles := torrent.GetFiles()
files := make([]os.FileInfo, 0, len(torrentFiles))
@@ -294,33 +305,33 @@ func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo {
return files
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (ht *TorrentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
h.handleGet(w, r)
ht.handleGet(w, r)
return
case "HEAD":
h.handleHead(w, r)
ht.handleHead(w, r)
return
case "OPTIONS":
h.handleOptions(w, r)
ht.handleOptions(w, r)
return
case "PROPFIND":
h.handlePropfind(w, r)
ht.handlePropfind(w, r)
return
case "DELETE":
if err := h.handleDelete(w, r); err == nil {
if err := ht.handleDelete(w, r); err == nil {
return
}
// fallthrough to default
}
handler := &webdav.Handler{
FileSystem: h,
FileSystem: ht,
LockSystem: webdav.NewMemLS(),
Logger: func(r *http.Request, err error) {
if err != nil {
h.logger.Trace().
ht.logger.Trace().
Err(err).
Str("method", r.Method).
Str("path", r.URL.Path).
@@ -331,33 +342,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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(urlPath string) (string, bool) {
parents := h.getParentItems()
func (ht *TorrentHandler) isParentPath(urlPath string) (string, bool) {
parents := ht.getParentItems()
lastComponent := path.Base(urlPath)
for _, p := range parents {
if p == lastComponent {
@@ -367,9 +353,9 @@ func (h *Handler) isParentPath(urlPath string) (string, bool) {
return "", false
}
func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
func (ht *TorrentHandler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
var children []os.FileInfo
if f, ok := file.(*File); ok {
if f, ok := file.(*TorrentFile); ok {
children = f.children
} else {
var err error
@@ -385,7 +371,7 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
parentPath := path.Dir(cleanPath)
showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath
isBadPath := strings.HasSuffix(cleanPath, "__bad__")
_, canDelete := h.isParentPath(cleanPath)
_, canDelete := ht.isParentPath(cleanPath)
// Prepare template data
data := struct {
@@ -402,7 +388,7 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
ParentPath: parentPath,
ShowParent: showParent,
Children: children,
URLBase: h.URLBase,
URLBase: ht.URLBase,
IsBadPath: isBadPath,
CanDelete: canDelete,
DeleteAllBadTorrentKey: DeleteAllBadTorrentKey,
@@ -416,8 +402,8 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
// Handlers
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
fRaw, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
func (ht *TorrentHandler) handleGet(w http.ResponseWriter, r *http.Request) {
fRaw, err := ht.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
if err != nil {
http.NotFound(w, r)
return
@@ -431,7 +417,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
}
if fi.IsDir() {
h.serveDirectory(w, r, fRaw)
ht.serveDirectory(w, r, fRaw)
return
}
@@ -448,9 +434,9 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
}
// Handle File struct with direct streaming
if file, ok := fRaw.(*File); ok {
if file, ok := fRaw.(*TorrentFile); ok {
// Handle nginx proxy (X-Accel-Redirect)
if file.content == nil && !file.isRar && h.cache.StreamWithRclone() {
if file.content == nil && !file.isRar && ht.cache.StreamWithRclone() {
link, err := file.getDownloadLink()
if err != nil || link == "" {
http.Error(w, "Could not fetch download link", http.StatusPreconditionFailed)
@@ -475,7 +461,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
if streamErr.StatusCode > 0 && !hasHeadersWritten(w) {
http.Error(w, streamErr.Error(), streamErr.StatusCode)
} else {
h.logger.Error().
ht.logger.Error().
Err(streamErr.Err).
Str("path", r.URL.Path).
Msg("Stream error")
@@ -485,7 +471,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
if !hasHeadersWritten(w) {
http.Error(w, "Stream error", http.StatusInternalServerError)
} else {
h.logger.Error().
ht.logger.Error().
Err(err).
Str("path", r.URL.Path).
Msg("Stream error after headers written")
@@ -505,10 +491,14 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
}
}
func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
f, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
func (ht *TorrentHandler) handlePropfind(w http.ResponseWriter, r *http.Request) {
handlePropfind(ht, ht.logger, w, r)
}
func (ht *TorrentHandler) handleHead(w http.ResponseWriter, r *http.Request) {
f, err := ht.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")
ht.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file")
http.NotFound(w, r)
return
}
@@ -521,7 +511,7 @@ func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
fi, err := f.Stat()
if err != nil {
h.logger.Error().Err(err).Msg("Failed to stat file")
ht.logger.Error().Err(err).Msg("Failed to stat file")
http.Error(w, "Server Error", http.StatusInternalServerError)
return
}
@@ -532,14 +522,14 @@ func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) {
func (ht *TorrentHandler) handleOptions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND")
w.Header().Set("DAV", "1, 2")
w.WriteHeader(http.StatusOK)
}
// handleDelete deletes a torrent by id, or all bad torrents if the id is DeleteAllBadTorrentKey
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error {
func (ht *TorrentHandler) handleDelete(w http.ResponseWriter, r *http.Request) error {
cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes
_, torrentId := path.Split(cleanPath)
@@ -548,25 +538,25 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error {
}
if torrentId == DeleteAllBadTorrentKey {
return h.handleDeleteAll(w)
return ht.handleDeleteAll(w)
}
return h.handleDeleteById(w, torrentId)
return ht.handleDeleteById(w, torrentId)
}
func (h *Handler) handleDeleteById(w http.ResponseWriter, tId string) error {
cachedTorrent := h.cache.GetTorrent(tId)
func (ht *TorrentHandler) handleDeleteById(w http.ResponseWriter, tId string) error {
cachedTorrent := ht.cache.GetTorrent(tId)
if cachedTorrent == nil {
return os.ErrNotExist
}
h.cache.OnRemove(cachedTorrent.Id)
ht.cache.OnRemove(cachedTorrent.Id)
w.WriteHeader(http.StatusNoContent)
return nil
}
func (h *Handler) handleDeleteAll(w http.ResponseWriter) error {
badTorrents := h.cache.GetListing("__bad__")
func (ht *TorrentHandler) handleDeleteAll(w http.ResponseWriter) error {
badTorrents := ht.cache.GetListing("__bad__")
if len(badTorrents) == 0 {
http.Error(w, "No bad torrents to delete", http.StatusNotFound)
return nil
@@ -574,9 +564,9 @@ func (h *Handler) handleDeleteAll(w http.ResponseWriter) error {
for _, fi := range badTorrents {
tName := strings.TrimSpace(strings.SplitN(fi.Name(), "||", 2)[0])
t := h.cache.GetTorrentByName(tName)
t := ht.cache.GetTorrentByName(tName)
if t != nil {
h.cache.OnRemove(t.Id)
ht.cache.OnRemove(t.Id)
}
}

263
pkg/webdav/usenet_file.go Normal file
View File

@@ -0,0 +1,263 @@
package webdav
import (
"context"
"errors"
"fmt"
"github.com/sirrobot01/decypharr/pkg/usenet"
"io"
"net/http"
"os"
"strings"
"time"
)
type UsenetFile struct {
name string
nzbID string
downloadLink string
size int64
isDir bool
fileId string
metadataOnly bool
content []byte
children []os.FileInfo // For directories
usenet usenet.Usenet
modTime time.Time
readOffset int64
rPipe io.ReadCloser
}
// UsenetFile interface implementations for UsenetFile
func (f *UsenetFile) Close() error {
if f.isDir {
return nil // No resources to close for directories
}
f.content = nil
f.children = nil
f.downloadLink = ""
return nil
}
func (f *UsenetFile) servePreloadedContent(w http.ResponseWriter, r *http.Request) error {
content := f.content
size := int64(len(content))
// Handle range requests for preloaded content
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
ranges, err := parseRange(rangeHeader, size)
if err != nil || len(ranges) != 1 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
}
start, end := ranges[0].start, ranges[0].end
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusPartialContent)
_, err = w.Write(content[start : end+1])
return err
}
// Full content
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Accept-Ranges", "bytes")
w.WriteHeader(http.StatusOK)
_, err := w.Write(content)
return err
}
func (f *UsenetFile) StreamResponse(w http.ResponseWriter, r *http.Request) error {
// Handle preloaded content files
if f.content != nil {
return f.servePreloadedContent(w, r)
}
// Try streaming with retry logic
return f.streamWithRetry(w, r, 0)
}
func (f *UsenetFile) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
start, end := f.getRange(r)
if retryCount == 0 {
contentLength := end - start + 1
w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength))
w.Header().Set("Accept-Ranges", "bytes")
if r.Header.Get("Range") != "" {
contentRange := fmt.Sprintf("bytes %d-%d/%d", start, end, f.size)
w.Header().Set("Content-Range", contentRange)
w.WriteHeader(http.StatusPartialContent)
} else {
w.WriteHeader(http.StatusOK)
}
}
err := f.usenet.Stream(r.Context(), f.nzbID, f.name, start, end, w)
if err != nil {
if isConnectionError(err) || strings.Contains(err.Error(), "client disconnected") {
return nil
}
// Don't treat cancellation as an error - it's expected for seek operations
if errors.Is(err, context.Canceled) {
return nil
}
return &streamError{Err: fmt.Errorf("failed to stream file %s: %w", f.name, err), StatusCode: http.StatusInternalServerError}
}
return nil
}
// isConnectionError checks if the error is due to client disconnection
func isConnectionError(err error) bool {
errStr := err.Error()
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
return true // EOF or context cancellation is a common disconnection error
}
return strings.Contains(errStr, "broken pipe") ||
strings.Contains(errStr, "connection reset by peer")
}
func (f *UsenetFile) getRange(r *http.Request) (int64, int64) {
rangeHeader := r.Header.Get("Range")
if rangeHeader == "" {
// No range header - return full file range (0 to size-1)
return 0, f.size - 1
}
// Parse the range header for this specific file
ranges, err := parseRange(rangeHeader, f.size)
if err != nil || len(ranges) != 1 {
return -1, -1
}
// Return the requested range (this is relative to the file, not the entire NZB)
start, end := ranges[0].start, ranges[0].end
if start < 0 || end < 0 || start > end || end >= f.size {
return -1, -1 // Invalid range
}
return start, end
}
func (f *UsenetFile) Stat() (os.FileInfo, error) {
if f.isDir {
return &FileInfo{
name: f.name,
size: f.size,
mode: 0755 | os.ModeDir,
modTime: f.modTime,
isDir: true,
}, nil
}
return &FileInfo{
name: f.name,
size: f.size,
mode: 0644,
modTime: f.modTime,
isDir: false,
}, nil
}
func (f *UsenetFile) Read(p []byte) (int, error) {
if f.isDir {
return 0, os.ErrInvalid
}
// preloaded content (unchanged)
if f.metadataOnly {
return 0, io.EOF
}
if f.content != nil {
if f.readOffset >= int64(len(f.content)) {
return 0, io.EOF
}
n := copy(p, f.content[f.readOffset:])
f.readOffset += int64(n)
return n, nil
}
if f.rPipe == nil {
pr, pw := io.Pipe()
f.rPipe = pr
// start fetch from current offset
go func(start int64) {
err := f.usenet.Stream(context.Background(), f.nzbID, f.name, start, f.size-1, pw)
if err := pw.CloseWithError(err); err != nil {
return
}
}(f.readOffset)
}
n, err := f.rPipe.Read(p)
f.readOffset += int64(n)
return n, err
}
// Seek simply moves the readOffset pointer within [0…size]
func (f *UsenetFile) Seek(offset int64, whence int) (int64, error) {
if f.isDir {
return 0, os.ErrInvalid
}
// preload path (unchanged)
var newOffset int64
switch whence {
case io.SeekStart:
newOffset = offset
case io.SeekCurrent:
newOffset = f.readOffset + offset
case io.SeekEnd:
newOffset = f.size + offset
default:
return 0, os.ErrInvalid
}
if newOffset < 0 {
newOffset = 0
}
if newOffset > f.size {
newOffset = f.size
}
// drop in-flight stream
if f.rPipe != nil {
f.rPipe.Close()
f.rPipe = nil
}
f.readOffset = newOffset
return f.readOffset, nil
}
func (f *UsenetFile) Write(_ []byte) (n int, err error) {
return 0, os.ErrPermission
}
func (f *UsenetFile) Readdir(count int) ([]os.FileInfo, error) {
if !f.isDir {
return nil, os.ErrInvalid
}
if count <= 0 {
return f.children, nil
}
if len(f.children) == 0 {
return nil, io.EOF
}
if count > len(f.children) {
count = len(f.children)
}
files := f.children[:count]
f.children = f.children[count:]
return files, nil
}

View 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)
// 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
}

View File

@@ -6,8 +6,12 @@ import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/pkg/store"
"github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
"github.com/sirrobot01/decypharr/pkg/usenet"
"golang.org/x/net/webdav"
"html/template"
"net/http"
"net/url"
@@ -33,6 +37,10 @@ var (
}
return strings.Join(segments, "/")
},
"split": strings.Split,
"sub": func(a, b int) int {
return a - b
},
"formatSize": func(bytes int64) string {
const (
KB = 1024
@@ -84,21 +92,50 @@ func init() {
chi.RegisterMethod("UNLOCK")
}
type WebDav struct {
Handlers []*Handler
URLBase string
type Handler interface {
http.Handler
Start(ctx context.Context) error
Readiness(next http.Handler) http.Handler
Name() string
OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error)
GetChildren(name string) []os.FileInfo
Type() string
}
func New() *WebDav {
type WebDav struct {
Handlers []Handler
URLBase string
logger zerolog.Logger
}
func New(debridCaches map[string]*store.Cache, usenet usenet.Usenet) *WebDav {
urlBase := config.Get().URLBase
w := &WebDav{
Handlers: make([]*Handler, 0),
Handlers: make([]Handler, 0),
URLBase: urlBase,
logger: logger.New("webdav"),
}
for name, c := range store.Get().Debrid().Caches() {
h := NewHandler(name, urlBase, c, c.Logger())
// Set debrid handlers
for name, c := range debridCaches {
h := NewTorrentHandler(name, urlBase, c, c.Logger())
if h == nil {
w.logger.Warn().Msgf("Debrid handler for %s is nil, skipping", name)
continue
}
w.Handlers = append(w.Handlers, h)
}
// Set usenet handlers
if usenet != nil {
usenetHandler := NewUsenetHandler("usenet", urlBase, usenet, usenet.Logger())
if usenetHandler != nil {
w.Handlers = append(w.Handlers, usenetHandler)
} else {
w.logger.Warn().Msg("Usenet handler is nil, skipping")
}
}
return w
}
@@ -119,9 +156,9 @@ func (wd *WebDav) Start(ctx context.Context) error {
for _, h := range wd.Handlers {
wg.Add(1)
go func(h *Handler) {
go func(h Handler) {
defer wg.Done()
if err := h.cache.Start(ctx); err != nil {
if err := h.Start(ctx); err != nil {
select {
case errChan <- err:
default:
@@ -152,8 +189,8 @@ func (wd *WebDav) Start(ctx context.Context) error {
func (wd *WebDav) mountHandlers(r chi.Router) {
for _, h := range wd.Handlers {
r.Route("/"+h.Name, func(r chi.Router) {
r.Use(h.readinessMiddleware)
r.Route("/"+h.Name(), func(r chi.Router) {
r.Use(h.Readiness)
r.Mount("/", h)
}) // Mount to /name since router is already prefixed with /webdav
}
@@ -166,11 +203,7 @@ func (wd *WebDav) setupRootHandler(r chi.Router) {
func (wd *WebDav) commonMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("DAV", "1, 2")
w.Header().Set("Allow", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
w.Header().Set("Access-Control-Allow-Headers", "Depth, Content-Type, Authorization")
next.ServeHTTP(w, r)
})
@@ -181,7 +214,7 @@ func (wd *WebDav) handleGetRoot() http.HandlerFunc {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
Handlers []*Handler
Handlers []Handler
URLBase string
}{
Handlers: wd.Handlers,
@@ -205,7 +238,7 @@ func (wd *WebDav) handleWebdavRoot() http.HandlerFunc {
children := make([]os.FileInfo, 0, len(wd.Handlers))
for _, h := range wd.Handlers {
children = append(children, &FileInfo{
name: h.Name,
name: h.Name(),
size: 0,
mode: 0755 | os.ModeDir,
modTime: time.Now(),