Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
3827 lines
120 KiB
Go
3827 lines
120 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.sovbit.dev/enki/torrentGateway/internal/admin"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/auth"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/blossom"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/config"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/middleware"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/nostr"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/profile"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/storage"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/streaming"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/torrent"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/tracker"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/transcoding"
|
|
"git.sovbit.dev/enki/torrentGateway/internal/dht"
|
|
"github.com/gorilla/mux"
|
|
nip "github.com/nbd-wtf/go-nostr"
|
|
)
|
|
|
|
// Server start time for uptime calculation
|
|
var serverStartTime = time.Now()
|
|
|
|
// Error handling structures
|
|
type APIError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Details string `json:"details,omitempty"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type ErrorResponse struct {
|
|
Error APIError `json:"error"`
|
|
Success bool `json:"success"`
|
|
}
|
|
|
|
// Error types
|
|
const (
|
|
ErrorTypeValidation = "validation_error"
|
|
ErrorTypeNotFound = "not_found_error"
|
|
ErrorTypeInternal = "internal_error"
|
|
ErrorTypeUnsupported = "unsupported_error"
|
|
ErrorTypeUnauthorized = "unauthorized_error"
|
|
ErrorTypeRateLimit = "rate_limit_error"
|
|
ErrorTypeInvalidRange = "invalid_range_error"
|
|
ErrorTypeUploadFailed = "upload_failed_error"
|
|
ErrorTypeStorageFailed = "storage_failed_error"
|
|
ErrorTypeServiceUnavailable = "service_unavailable_error"
|
|
)
|
|
|
|
// Common error responses
|
|
var (
|
|
ErrFileNotFound = APIError{
|
|
Code: http.StatusNotFound,
|
|
Message: "File not found",
|
|
Type: ErrorTypeNotFound,
|
|
}
|
|
|
|
ErrInvalidFileHash = APIError{
|
|
Code: http.StatusBadRequest,
|
|
Message: "Invalid file hash format",
|
|
Type: ErrorTypeValidation,
|
|
}
|
|
|
|
ErrUnsupportedMediaType = APIError{
|
|
Code: http.StatusUnsupportedMediaType,
|
|
Message: "Unsupported media type",
|
|
Type: ErrorTypeUnsupported,
|
|
}
|
|
|
|
ErrInternalServer = APIError{
|
|
Code: http.StatusInternalServerError,
|
|
Message: "Internal server error",
|
|
Type: ErrorTypeInternal,
|
|
}
|
|
|
|
ErrMethodNotAllowed = APIError{
|
|
Code: http.StatusMethodNotAllowed,
|
|
Message: "Method not allowed",
|
|
Type: ErrorTypeValidation,
|
|
}
|
|
|
|
ErrInvalidRange = APIError{
|
|
Code: http.StatusRequestedRangeNotSatisfiable,
|
|
Message: "Invalid range request",
|
|
Type: ErrorTypeInvalidRange,
|
|
}
|
|
)
|
|
|
|
type Gateway struct {
|
|
blossomClient BlossomClient
|
|
nostrPublisher NostrPublisher
|
|
config *config.Config
|
|
storage *storage.Backend
|
|
profileFetcher *profile.ProfileFetcher
|
|
publicURL string
|
|
trackerInstance *tracker.Tracker
|
|
dhtBootstrap DHTBootstrap
|
|
transcodingManager TranscodingManager
|
|
}
|
|
|
|
// TranscodingStatusHandler returns transcoding status for a specific file
|
|
func (g *Gateway) TranscodingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Get file hash from URL
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check if user has access to this file
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
// Get file metadata to check if it's a video
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Prepare response
|
|
response := map[string]interface{}{
|
|
"file_hash": fileHash,
|
|
"is_video": false,
|
|
"transcoding_enabled": g.transcodingManager != nil,
|
|
}
|
|
|
|
// Check if it's a video file
|
|
if metadata.StreamingInfo != nil && metadata.StreamingInfo.IsVideo {
|
|
response["is_video"] = true
|
|
response["original_file"] = metadata.FileName
|
|
|
|
if g.transcodingManager != nil {
|
|
status := g.transcodingManager.GetTranscodingStatus(fileHash)
|
|
response["status"] = status
|
|
|
|
// Add more details based on status
|
|
switch status {
|
|
case "completed", "web_compatible":
|
|
transcodedPath := g.transcodingManager.GetTranscodedPath(fileHash)
|
|
response["transcoded_available"] = transcodedPath != ""
|
|
if transcodedPath != "" {
|
|
// Get transcoded file info
|
|
if fileInfo, err := os.Stat(transcodedPath); err == nil {
|
|
response["transcoded_size"] = fileInfo.Size()
|
|
response["transcoded_path"] = filepath.Base(transcodedPath)
|
|
}
|
|
}
|
|
case "queued":
|
|
response["message"] = "Video is queued for transcoding"
|
|
case "processing":
|
|
response["message"] = "Video is being transcoded"
|
|
case "failed":
|
|
response["message"] = "Transcoding failed - serving original"
|
|
case "disabled":
|
|
response["message"] = "Transcoding is disabled"
|
|
default:
|
|
response["message"] = "Transcoding status unknown"
|
|
}
|
|
|
|
// If there's a job in progress, get progress details
|
|
if status == "processing" || status == "queued" {
|
|
if progress, exists := g.transcodingManager.GetJobProgress(fileHash); exists {
|
|
response["progress"] = progress
|
|
response["job_id"] = fmt.Sprintf("transcode_%s", fileHash)
|
|
}
|
|
}
|
|
} else {
|
|
response["status"] = "disabled"
|
|
response["message"] = "Transcoding is not enabled"
|
|
}
|
|
} else {
|
|
response["message"] = "File is not a video or video info unavailable"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// DHTBootstrap interface for DHT integration
|
|
type DHTBootstrap interface {
|
|
AnnounceNewTorrent(infoHash string, port int)
|
|
GetDHTStats() map[string]interface{}
|
|
}
|
|
|
|
// DHTNodeInfo represents a DHT node for torrent embedding
|
|
type DHTNodeInfo struct {
|
|
IP string
|
|
Port int
|
|
}
|
|
|
|
// Error handling utilities
|
|
func (g *Gateway) writeErrorResponse(w http.ResponseWriter, apiErr APIError, details string) {
|
|
apiErr.Details = details
|
|
response := ErrorResponse{
|
|
Error: apiErr,
|
|
Success: false,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(apiErr.Code)
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (g *Gateway) writeError(w http.ResponseWriter, statusCode int, message, errorType, details string) {
|
|
apiErr := APIError{
|
|
Code: statusCode,
|
|
Message: message,
|
|
Type: errorType,
|
|
Details: details,
|
|
}
|
|
g.writeErrorResponse(w, apiErr, "")
|
|
}
|
|
|
|
func (g *Gateway) validateFileHash(hash string) error {
|
|
if hash == "" {
|
|
return fmt.Errorf("file hash is required")
|
|
}
|
|
|
|
// SHA-256 hash should be 64 hex characters
|
|
if len(hash) != 64 {
|
|
return fmt.Errorf("file hash must be 64 characters long")
|
|
}
|
|
|
|
// Check if it's valid hex
|
|
for _, c := range hash {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
return fmt.Errorf("file hash must contain only hexadecimal characters")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *Gateway) validateHTTPMethod(r *http.Request, allowedMethods []string) error {
|
|
method := r.Method
|
|
for _, allowed := range allowedMethods {
|
|
if method == allowed {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("method %s not allowed, expected one of: %s", method, strings.Join(allowedMethods, ", "))
|
|
}
|
|
|
|
type NostrPublisher interface {
|
|
PublishTorrentAnnouncement(ctx context.Context, data nostr.TorrentEventData) (*nip.Event, error)
|
|
CreateNIP71VideoEvent(data nostr.TorrentEventData) (*nip.Event, error)
|
|
PublishEvent(ctx context.Context, event *nip.Event) error
|
|
}
|
|
|
|
type BlossomClient interface {
|
|
Put(data []byte) (string, error)
|
|
Get(hash string) ([]byte, error)
|
|
}
|
|
|
|
type TranscodingManager interface {
|
|
QueueVideoForTranscoding(fileHash, fileName, filePath string, fileSize int64)
|
|
HasTranscodedVersion(fileHash string) bool
|
|
GetTranscodedPath(fileHash string) string
|
|
GetTranscodingStatus(fileHash string) string
|
|
GetJobProgress(fileHash string) (float64, bool)
|
|
InitializeDatabase() error
|
|
}
|
|
|
|
type FileMetadata struct {
|
|
FileHash string `json:"file_hash"`
|
|
FileName string `json:"file_name"`
|
|
TotalSize int64 `json:"total_size"`
|
|
ChunkCount int `json:"chunk_count"`
|
|
StorageType string `json:"storage_type"` // "blob" or "torrent"
|
|
Chunks []ChunkInfo `json:"chunks"`
|
|
TorrentInfo *torrent.TorrentInfo `json:"torrent_info,omitempty"`
|
|
StreamingInfo *streaming.FileInfo `json:"streaming_info,omitempty"`
|
|
HLSPlaylist *streaming.HLSPlaylist `json:"hls_playlist,omitempty"`
|
|
}
|
|
|
|
type ChunkInfo struct {
|
|
Index int `json:"index"`
|
|
Hash string `json:"hash"`
|
|
Size int `json:"size"`
|
|
}
|
|
|
|
type UploadResponse struct {
|
|
FileHash string `json:"file_hash"`
|
|
Message string `json:"message"`
|
|
TorrentHash string `json:"torrent_hash,omitempty"`
|
|
MagnetLink string `json:"magnet_link,omitempty"`
|
|
NostrEventID string `json:"nostr_event_id,omitempty"`
|
|
}
|
|
|
|
func NewGateway(cfg *config.Config, storage *storage.Backend) *Gateway {
|
|
// Use mock Blossom client for now (real client has ContentLength issues)
|
|
blossomClient := blossom.NewMockClient()
|
|
|
|
// Initialize real Nostr publisher with configured relays
|
|
nostrRelays := cfg.Nostr.Relays
|
|
if len(nostrRelays) == 0 {
|
|
// Fallback relays if none configured
|
|
nostrRelays = []string{
|
|
"wss://freelay.sovbit.host",
|
|
"wss://relay.damus.io",
|
|
"wss://nos.lol",
|
|
}
|
|
}
|
|
|
|
// Initialize Nostr publisher from config
|
|
var nostrPublisher NostrPublisher
|
|
privateKey := cfg.Nostr.PrivateKey // Use private key from config
|
|
realPublisher, err := nostr.NewPublisher(privateKey, nostrRelays)
|
|
if err != nil {
|
|
log.Printf("Failed to initialize Nostr publisher: %v", err)
|
|
log.Printf("Disabling Nostr publishing")
|
|
nostrPublisher = nil
|
|
} else {
|
|
pubkey, _ := realPublisher.GetPublicKeyBech32()
|
|
log.Printf("Initialized Nostr publisher with public key: %s", pubkey)
|
|
nostrPublisher = realPublisher
|
|
}
|
|
|
|
// Set public URL for tracker functionality
|
|
publicURL := fmt.Sprintf("http://localhost:%d", cfg.Gateway.Port)
|
|
|
|
// Initialize transcoding manager
|
|
var transcodingManager TranscodingManager
|
|
if cfg.Transcoding.Enabled {
|
|
transcoder, err := transcoding.NewTranscoder(
|
|
cfg.Transcoding.WorkDir,
|
|
cfg.Transcoding.ConcurrentJobs,
|
|
cfg.Transcoding.Enabled,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to initialize transcoder: %v", err)
|
|
log.Printf("Transcoding will be disabled")
|
|
transcodingManager = nil
|
|
} else {
|
|
manager := transcoding.NewManager(transcoder, storage.GetDB())
|
|
if err := manager.InitializeDatabase(); err != nil {
|
|
log.Printf("Warning: Failed to initialize transcoding database: %v", err)
|
|
}
|
|
transcodingManager = manager
|
|
log.Printf("Transcoding enabled with %d concurrent workers", cfg.Transcoding.ConcurrentJobs)
|
|
}
|
|
} else {
|
|
log.Printf("Transcoding is disabled")
|
|
}
|
|
|
|
return &Gateway{
|
|
blossomClient: blossomClient,
|
|
nostrPublisher: nostrPublisher,
|
|
config: cfg,
|
|
storage: storage,
|
|
profileFetcher: profile.NewProfileFetcher(nostrRelays),
|
|
publicURL: publicURL,
|
|
transcodingManager: transcodingManager,
|
|
}
|
|
}
|
|
|
|
// serveTranscodedFile serves a transcoded video file with proper headers and range support
|
|
func (g *Gateway) serveTranscodedFile(w http.ResponseWriter, r *http.Request, filePath, originalFileName string) {
|
|
// Open the transcoded file
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Transcoded file unavailable", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to open transcoded file: %v", err))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get file info
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "File stat failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to stat transcoded file: %v", err))
|
|
return
|
|
}
|
|
|
|
fileSize := fileInfo.Size()
|
|
|
|
// Set headers for transcoded MP4
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range, Content-Type, Authorization")
|
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// Use original filename but with .mp4 extension
|
|
if originalFileName != "" {
|
|
ext := filepath.Ext(originalFileName)
|
|
baseFileName := strings.TrimSuffix(originalFileName, ext)
|
|
filename := fmt.Sprintf("%s_transcoded.mp4", baseFileName)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
|
|
}
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize))
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Handle range requests for video seeking
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader != "" {
|
|
// Parse range header
|
|
rangeReq, err := streaming.ParseRangeHeader(rangeHeader, fileSize)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidRange, fmt.Sprintf("Invalid range header: %v", err))
|
|
return
|
|
}
|
|
|
|
if rangeReq != nil {
|
|
// Validate range
|
|
if rangeReq.Start < 0 || rangeReq.End >= fileSize || rangeReq.Start > rangeReq.End {
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", fileSize))
|
|
g.writeError(w, http.StatusRequestedRangeNotSatisfiable, "Range not satisfiable", ErrorTypeInvalidRange,
|
|
fmt.Sprintf("Range %d-%d is not satisfiable for file size %d", rangeReq.Start, rangeReq.End, fileSize))
|
|
return
|
|
}
|
|
|
|
// Seek to start position
|
|
if _, err := file.Seek(rangeReq.Start, 0); err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Seek failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to seek in transcoded file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Set partial content headers
|
|
contentLength := rangeReq.End - rangeReq.Start + 1
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength))
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", rangeReq.Start, rangeReq.End, fileSize))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
|
|
// Copy the requested range
|
|
if _, err := io.CopyN(w, file, contentLength); err != nil && err != io.EOF {
|
|
log.Printf("Error serving transcoded file range: %v", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Serve full file
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize))
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if _, err := io.Copy(w, file); err != nil && err != io.EOF {
|
|
log.Printf("Error serving transcoded file: %v", err)
|
|
}
|
|
}
|
|
|
|
// reconstructTorrentFile reconstructs a torrent file from chunks for transcoding
|
|
func (g *Gateway) reconstructTorrentFile(fileHash, fileName string) (string, error) {
|
|
// Create temporary file for reconstruction
|
|
tempDir := filepath.Join(g.config.Transcoding.WorkDir, "temp")
|
|
if err := os.MkdirAll(tempDir, 0755); err != nil {
|
|
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
|
}
|
|
|
|
// Create temporary file with original filename to preserve extension
|
|
tempFile := filepath.Join(tempDir, fmt.Sprintf("%s_%s", fileHash, fileName))
|
|
|
|
// Get metadata to find chunks
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get metadata: %w", err)
|
|
}
|
|
|
|
// Open output file
|
|
outFile, err := os.Create(tempFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer outFile.Close()
|
|
|
|
// Reassemble chunks in order
|
|
for i, chunkInfo := range metadata.Chunks {
|
|
chunkData, err := g.storage.GetChunkData(chunkInfo.Hash)
|
|
if err != nil {
|
|
os.Remove(tempFile) // Clean up on error
|
|
return "", fmt.Errorf("failed to get chunk %d: %w", i, err)
|
|
}
|
|
|
|
if _, err := outFile.Write(chunkData); err != nil {
|
|
os.Remove(tempFile) // Clean up on error
|
|
return "", fmt.Errorf("failed to write chunk %d: %w", i, err)
|
|
}
|
|
}
|
|
|
|
return tempFile, nil
|
|
}
|
|
|
|
// Implement Gateway interface methods for tracker integration
|
|
func (g *Gateway) GetPublicURL() string {
|
|
return g.publicURL
|
|
}
|
|
|
|
func (g *Gateway) IsValidInfoHash(infoHash string) bool {
|
|
// Check if we have metadata for this info hash
|
|
// For now, check if any of our files match this info hash
|
|
for _, metadata := range metadataStore {
|
|
if metadata.TorrentInfo != nil && metadata.TorrentInfo.InfoHash == infoHash {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Also check storage backend
|
|
exists, _ := g.storage.FileExistsByInfoHash(infoHash)
|
|
return exists
|
|
}
|
|
|
|
func (g *Gateway) GetWebSeedURL(infoHash string) string {
|
|
// Find the file with this info hash and return its WebSeed URL
|
|
for fileHash, metadata := range metadataStore {
|
|
if metadata.TorrentInfo != nil && metadata.TorrentInfo.InfoHash == infoHash {
|
|
webSeedURL := fmt.Sprintf("%s/webseed/%s/", strings.TrimSuffix(g.publicURL, "/"), fileHash)
|
|
|
|
// Validate the generated URL
|
|
if g.validateWebSeedURL(webSeedURL) {
|
|
return webSeedURL
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try storage backend
|
|
if fileHash, err := g.storage.GetFileHashByInfoHash(infoHash); err == nil {
|
|
webSeedURL := fmt.Sprintf("%s/webseed/%s/", strings.TrimSuffix(g.publicURL, "/"), fileHash)
|
|
|
|
// Validate the generated URL
|
|
if g.validateWebSeedURL(webSeedURL) {
|
|
return webSeedURL
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// DHT Gateway interface methods
|
|
func (g *Gateway) GetDHTPort() int {
|
|
return g.config.DHT.Port
|
|
}
|
|
|
|
func (g *Gateway) GetDatabase() *sql.DB {
|
|
return g.storage.GetDB()
|
|
}
|
|
|
|
func (g *Gateway) GetAllTorrentHashes() []string {
|
|
var hashes []string
|
|
|
|
// Get from metadata store
|
|
for _, metadata := range metadataStore {
|
|
if metadata.TorrentInfo != nil {
|
|
hashes = append(hashes, metadata.TorrentInfo.InfoHash)
|
|
}
|
|
}
|
|
|
|
// Get from storage backend
|
|
rows, err := g.storage.GetDB().Query(`SELECT info_hash FROM files WHERE info_hash IS NOT NULL`)
|
|
if err != nil {
|
|
return hashes
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var infoHash string
|
|
if err := rows.Scan(&infoHash); err == nil && infoHash != "" {
|
|
// Avoid duplicates
|
|
found := false
|
|
for _, existing := range hashes {
|
|
if existing == infoHash {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
hashes = append(hashes, infoHash)
|
|
}
|
|
}
|
|
}
|
|
|
|
return hashes
|
|
}
|
|
|
|
// SetDHTBootstrap sets the DHT bootstrap instance for torrent announcements
|
|
func (g *Gateway) SetDHTBootstrap(dhtBootstrap DHTBootstrap) {
|
|
g.dhtBootstrap = dhtBootstrap
|
|
}
|
|
|
|
func (g *Gateway) UploadHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodPost}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Validate content type
|
|
contentType := r.Header.Get("Content-Type")
|
|
if !strings.HasPrefix(contentType, "multipart/form-data") {
|
|
g.writeError(w, http.StatusBadRequest, "Invalid content type", ErrorTypeValidation,
|
|
"Expected multipart/form-data, got: "+contentType)
|
|
return
|
|
}
|
|
|
|
// Check content length
|
|
if r.ContentLength <= 0 {
|
|
g.writeError(w, http.StatusBadRequest, "Empty file upload", ErrorTypeValidation,
|
|
"Content-Length header indicates empty or missing content")
|
|
return
|
|
}
|
|
|
|
// Check if content length exceeds configured limits
|
|
maxUploadSize, err := g.config.GetMaxUploadSizeBytes()
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Configuration error", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to parse max upload size: %v", err))
|
|
return
|
|
}
|
|
if r.ContentLength > maxUploadSize {
|
|
g.writeError(w, http.StatusRequestEntityTooLarge, "File too large", ErrorTypeValidation,
|
|
fmt.Sprintf("File size %d bytes exceeds maximum allowed size of %d bytes", r.ContentLength, maxUploadSize))
|
|
return
|
|
}
|
|
|
|
// Parse multipart form with size limit (use reasonable memory limit)
|
|
memoryLimit := int64(32 << 20) // 32MB default
|
|
if maxUploadSize < memoryLimit {
|
|
memoryLimit = maxUploadSize
|
|
}
|
|
err = r.ParseMultipartForm(memoryLimit)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusBadRequest, "Failed to parse multipart form", ErrorTypeValidation,
|
|
fmt.Sprintf("Multipart form parsing error: %v", err))
|
|
return
|
|
}
|
|
|
|
// Get file from form
|
|
file, fileHeader, err := r.FormFile("file")
|
|
if err != nil {
|
|
g.writeError(w, http.StatusBadRequest, "Missing or invalid file field", ErrorTypeValidation,
|
|
fmt.Sprintf("Expected 'file' field in multipart form: %v", err))
|
|
return
|
|
}
|
|
defer func() {
|
|
if closeErr := file.Close(); closeErr != nil {
|
|
fmt.Printf("Warning: Failed to close uploaded file: %v\n", closeErr)
|
|
}
|
|
}()
|
|
|
|
// Validate filename
|
|
fileName := strings.TrimSpace(fileHeader.Filename)
|
|
if fileName == "" {
|
|
g.writeError(w, http.StatusBadRequest, "Missing filename", ErrorTypeValidation,
|
|
"Uploaded file must have a filename")
|
|
return
|
|
}
|
|
|
|
// Check for dangerous file paths
|
|
if strings.Contains(fileName, "..") || strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") {
|
|
g.writeError(w, http.StatusBadRequest, "Invalid filename", ErrorTypeValidation,
|
|
"Filename cannot contain path traversal characters")
|
|
return
|
|
}
|
|
|
|
// Validate file size from header
|
|
if fileHeader.Size <= 0 {
|
|
g.writeError(w, http.StatusBadRequest, "Empty file", ErrorTypeValidation,
|
|
"Uploaded file appears to be empty")
|
|
return
|
|
}
|
|
|
|
if fileHeader.Size > maxUploadSize {
|
|
g.writeError(w, http.StatusRequestEntityTooLarge, "File too large", ErrorTypeValidation,
|
|
fmt.Sprintf("File size %d bytes exceeds maximum allowed size of %d bytes", fileHeader.Size, maxUploadSize))
|
|
return
|
|
}
|
|
|
|
// Size-based routing: decide between blob and torrent storage
|
|
blobThreshold := g.config.GetBlobThreshold()
|
|
if fileHeader.Size < blobThreshold {
|
|
// Small file - store as single Blossom blob
|
|
g.handleBlobUpload(w, r, file, fileName, fileHeader)
|
|
return
|
|
} else {
|
|
// Large file - use existing chunking logic
|
|
g.handleTorrentUpload(w, r, file, fileName, fileHeader)
|
|
return
|
|
}
|
|
}
|
|
|
|
// handleBlobUpload handles small files that should be stored as single Blossom blobs
|
|
func (g *Gateway) handleBlobUpload(w http.ResponseWriter, r *http.Request, file multipart.File, fileName string, fileHeader *multipart.FileHeader) {
|
|
// Determine content type
|
|
contentType := fileHeader.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
// Get user from context for ownership tracking
|
|
ownerPubkey := middleware.GetUserFromContext(r.Context())
|
|
accessLevel := "public" // Default access level
|
|
|
|
// Store as blob using unified storage
|
|
var metadata *storage.FileMetadata
|
|
var err error
|
|
if ownerPubkey != "" {
|
|
metadata, err = g.storage.StoreBlobAsFileWithOwner(file, fileName, contentType, ownerPubkey, accessLevel)
|
|
} else {
|
|
metadata, err = g.storage.StoreBlobAsFile(file, fileName, contentType)
|
|
}
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Blob storage failed", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Failed to store file as blob: %v", err))
|
|
return
|
|
}
|
|
|
|
// Create streaming info for video files
|
|
isVideo, mimeType := streaming.DetectMediaType(fileName)
|
|
var streamingInfo *streaming.FileInfo
|
|
var hlsPlaylist *streaming.HLSPlaylist
|
|
|
|
if isVideo {
|
|
duration := streaming.EstimateVideoDuration(metadata.Size, fileName)
|
|
streamingInfo = &streaming.FileInfo{
|
|
Name: fileName,
|
|
Size: metadata.Size,
|
|
ChunkCount: 1, // Blob is treated as single chunk
|
|
ChunkSize: int(metadata.Size),
|
|
Duration: duration,
|
|
IsVideo: true,
|
|
MimeType: mimeType,
|
|
}
|
|
|
|
config := streaming.DefaultHLSConfig()
|
|
playlist, err := streaming.GenerateHLSSegments(*streamingInfo, config)
|
|
if err == nil {
|
|
hlsPlaylist = playlist
|
|
}
|
|
}
|
|
|
|
// Create API response metadata
|
|
apiMetadata := FileMetadata{
|
|
FileHash: metadata.Hash,
|
|
FileName: fileName,
|
|
TotalSize: metadata.Size,
|
|
ChunkCount: 1, // Blobs count as single "chunk"
|
|
StorageType: "blob",
|
|
Chunks: []ChunkInfo{{Index: 0, Hash: metadata.Hash, Size: int(metadata.Size)}},
|
|
StreamingInfo: streamingInfo,
|
|
HLSPlaylist: hlsPlaylist,
|
|
}
|
|
|
|
// Store API metadata for compatibility
|
|
err = g.storeMetadata(metadata.Hash, apiMetadata)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to store API metadata for blob %s: %v", metadata.Hash, err)
|
|
}
|
|
|
|
// Publish to Nostr for blobs
|
|
var nostrEventID string
|
|
if g.nostrPublisher != nil {
|
|
eventData := nostr.TorrentEventData{
|
|
Title: fmt.Sprintf("File: %s", fileName),
|
|
FileName: fileName,
|
|
FileSize: metadata.Size,
|
|
BlossomHash: metadata.Hash,
|
|
Description: fmt.Sprintf("File '%s' (%.2f MB) available via Blossom blob storage", fileName, float64(metadata.Size)/1024/1024),
|
|
MimeType: mimeType,
|
|
}
|
|
|
|
// Add streaming URLs for video files
|
|
if streamingInfo != nil {
|
|
baseURL := g.getBaseURL()
|
|
eventData.StreamURL = fmt.Sprintf("%s/api/stream/%s", baseURL, metadata.Hash)
|
|
eventData.HLSPlaylistURL = fmt.Sprintf("%s/api/stream/%s/playlist.m3u8", baseURL, metadata.Hash)
|
|
eventData.Duration = int64(streamingInfo.Duration)
|
|
eventData.VideoCodec = "h264" // Default assumption
|
|
eventData.MimeType = streamingInfo.MimeType
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
event, err := g.nostrPublisher.PublishTorrentAnnouncement(ctx, eventData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to publish blob to Nostr: %v\n", err)
|
|
} else if event != nil {
|
|
nostrEventID = nostr.GetEventID(event)
|
|
}
|
|
|
|
// Also publish NIP-71 video event for video files
|
|
if g.config.Nostr.PublishNIP71 && streamingInfo != nil {
|
|
nip71Event, err := g.nostrPublisher.CreateNIP71VideoEvent(eventData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to create NIP-71 video event: %v\n", err)
|
|
} else {
|
|
err = g.nostrPublisher.PublishEvent(ctx, nip71Event)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to publish NIP-71 video event: %v\n", err)
|
|
} else {
|
|
fmt.Printf("Published NIP-71 video event: %s\n", nip71Event.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Queue video for transcoding if applicable
|
|
// Note: Blob transcoding not implemented yet - small videos are usually already web-compatible
|
|
if g.transcodingManager != nil && streamingInfo != nil && g.config.Transcoding.AutoTranscode {
|
|
log.Printf("Small video file %s - skipping transcoding (typically already web-compatible)", fileName)
|
|
}
|
|
|
|
// Send success response for blob
|
|
response := UploadResponse{
|
|
FileHash: metadata.Hash,
|
|
Message: "File uploaded successfully as blob",
|
|
NostrEventID: nostrEventID,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// handleTorrentUpload handles large files that should be chunked for BitTorrent
|
|
func (g *Gateway) handleTorrentUpload(w http.ResponseWriter, r *http.Request, file multipart.File, fileName string, fileHeader *multipart.FileHeader) {
|
|
// Reset file reader position
|
|
file.Seek(0, 0)
|
|
|
|
// Determine content type
|
|
contentType := fileHeader.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
// Get user from context for ownership tracking
|
|
ownerPubkey := middleware.GetUserFromContext(r.Context())
|
|
accessLevel := "public" // Default access level
|
|
|
|
// Store file using storage backend (will chunk automatically)
|
|
var metadata *storage.FileMetadata
|
|
var err error
|
|
if ownerPubkey != "" {
|
|
metadata, err = g.storage.StoreFileWithOwner(file, fileName, contentType, ownerPubkey, accessLevel)
|
|
} else {
|
|
metadata, err = g.storage.StoreFile(file, fileName, contentType)
|
|
}
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "File storage failed", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Failed to store file: %v", err))
|
|
return
|
|
}
|
|
|
|
// Get chunk hashes for torrent creation
|
|
chunkHashes, err := g.storage.GetChunkHashes(metadata.Hash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Failed to get chunks", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to retrieve chunk hashes: %v", err))
|
|
return
|
|
}
|
|
|
|
// Create API response chunks
|
|
var chunkInfos []ChunkInfo
|
|
for i, chunkHash := range chunkHashes {
|
|
chunkInfos = append(chunkInfos, ChunkInfo{
|
|
Index: i,
|
|
Hash: chunkHash,
|
|
Size: int(g.config.GetChunkSize()), // Use config chunk size
|
|
})
|
|
}
|
|
|
|
// Create torrent pieces from chunk hashes
|
|
pieces := make([]torrent.PieceInfo, len(chunkHashes))
|
|
for i, chunkHash := range chunkHashes {
|
|
// Convert hex string to bytes for torrent hash
|
|
hashBytes := make([]byte, 20)
|
|
copy(hashBytes, []byte(chunkHash)[:20])
|
|
|
|
pieces[i] = torrent.PieceInfo{
|
|
Index: i,
|
|
Hash: [20]byte(hashBytes),
|
|
SHA256: chunkHash,
|
|
Length: int(g.config.GetChunkSize()),
|
|
}
|
|
}
|
|
|
|
// Generate validated WebSeed URL and base URL for tracker
|
|
webSeedURL := g.generateWebSeedURL(r, metadata.Hash)
|
|
baseURL := fmt.Sprintf("http://%s", r.Host)
|
|
|
|
fileInfo := torrent.FileInfo{
|
|
Name: fileName,
|
|
Size: metadata.Size,
|
|
Pieces: pieces,
|
|
WebSeedURL: webSeedURL,
|
|
}
|
|
|
|
trackers := g.config.Torrent.Trackers
|
|
if len(trackers) == 0 {
|
|
trackers = []string{
|
|
"udp://tracker.opentrackr.org:1337",
|
|
"udp://tracker.openbittorrent.com:6969",
|
|
}
|
|
}
|
|
|
|
// Get gateway URL for built-in tracker
|
|
gatewayURL := baseURL
|
|
|
|
// Build DHT nodes list
|
|
var dhtNodes [][]interface{}
|
|
if g.config.IsServiceEnabled("dht") {
|
|
// Add self as DHT node
|
|
dhtNodes = append(dhtNodes, []interface{}{g.GetPublicURL(), g.GetDHTPort()})
|
|
|
|
// Add DHT bootstrap nodes if available
|
|
if g.dhtBootstrap != nil {
|
|
// Use type assertion to get concrete type for accessing GetBootstrapNodes
|
|
if bootstrap, ok := g.dhtBootstrap.(*dht.DHTBootstrap); ok {
|
|
bootstrapNodes := bootstrap.GetBootstrapNodes()
|
|
for _, node := range bootstrapNodes {
|
|
if len(dhtNodes) < 10 { // Limit DHT nodes in torrent
|
|
dhtNodes = append(dhtNodes, []interface{}{node.IP, node.Port})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
torrentInfo, err := torrent.CreateTorrent(fileInfo, trackers, gatewayURL, dhtNodes)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Torrent creation failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to generate torrent: %v", err))
|
|
return
|
|
}
|
|
|
|
// Update file metadata with info_hash for tracker integration
|
|
err = g.storage.UpdateFileInfoHash(metadata.Hash, torrentInfo.InfoHash)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to update info_hash for file %s: %v", metadata.Hash, err)
|
|
}
|
|
|
|
// Announce to DHT if bootstrap is available
|
|
if g.dhtBootstrap != nil && g.config.IsServiceEnabled("dht") {
|
|
g.dhtBootstrap.AnnounceNewTorrent(torrentInfo.InfoHash, g.config.Gateway.Port)
|
|
}
|
|
|
|
// Create streaming info for video files
|
|
isVideo, mimeType := streaming.DetectMediaType(fileName)
|
|
var streamingInfo *streaming.FileInfo
|
|
var hlsPlaylist *streaming.HLSPlaylist
|
|
|
|
if isVideo {
|
|
duration := streaming.EstimateVideoDuration(metadata.Size, fileName)
|
|
streamingInfo = &streaming.FileInfo{
|
|
Name: fileName,
|
|
Size: metadata.Size,
|
|
ChunkCount: len(chunkHashes),
|
|
ChunkSize: int(g.config.GetChunkSize()),
|
|
Duration: duration,
|
|
IsVideo: true,
|
|
MimeType: mimeType,
|
|
}
|
|
|
|
config := streaming.DefaultHLSConfig()
|
|
playlist, err := streaming.GenerateHLSSegments(*streamingInfo, config)
|
|
if err == nil {
|
|
hlsPlaylist = playlist
|
|
}
|
|
}
|
|
|
|
// Create API metadata
|
|
apiMetadata := FileMetadata{
|
|
FileHash: metadata.Hash,
|
|
FileName: fileName,
|
|
TotalSize: metadata.Size,
|
|
ChunkCount: len(chunkHashes),
|
|
StorageType: "torrent",
|
|
Chunks: chunkInfos,
|
|
TorrentInfo: torrentInfo,
|
|
StreamingInfo: streamingInfo,
|
|
HLSPlaylist: hlsPlaylist,
|
|
}
|
|
|
|
// Store API metadata for compatibility
|
|
err = g.storeMetadata(metadata.Hash, apiMetadata)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Metadata storage failed", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Failed to store metadata: %v", err))
|
|
return
|
|
}
|
|
|
|
// Publish to Nostr
|
|
var nostrEventID string
|
|
if g.nostrPublisher != nil {
|
|
eventData := nostr.TorrentEventData{
|
|
Title: fmt.Sprintf("Torrent: %s", fileName),
|
|
InfoHash: torrentInfo.InfoHash,
|
|
FileName: fileName,
|
|
FileSize: metadata.Size,
|
|
MagnetLink: torrentInfo.Magnet,
|
|
WebSeedURL: webSeedURL,
|
|
BlossomHash: metadata.Hash,
|
|
Description: fmt.Sprintf("File '%s' (%.2f MB) available via BitTorrent", fileName, float64(metadata.Size)/1024/1024),
|
|
MimeType: mimeType,
|
|
}
|
|
|
|
// Add streaming information for video files
|
|
if streamingInfo != nil {
|
|
baseURL := g.getBaseURL()
|
|
eventData.StreamURL = fmt.Sprintf("%s/api/stream/%s", baseURL, metadata.Hash)
|
|
eventData.HLSPlaylistURL = fmt.Sprintf("%s/api/stream/%s/playlist.m3u8", baseURL, metadata.Hash)
|
|
eventData.Duration = int64(streamingInfo.Duration)
|
|
eventData.VideoCodec = "h264" // Default assumption
|
|
eventData.MimeType = streamingInfo.MimeType
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
event, err := g.nostrPublisher.PublishTorrentAnnouncement(ctx, eventData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to publish torrent to Nostr: %v\n", err)
|
|
} else if event != nil {
|
|
nostrEventID = nostr.GetEventID(event)
|
|
}
|
|
|
|
// Also publish NIP-71 video event for video files
|
|
if g.config.Nostr.PublishNIP71 && isVideo {
|
|
nip71Event, err := g.nostrPublisher.CreateNIP71VideoEvent(eventData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to create NIP-71 video event: %v\n", err)
|
|
} else {
|
|
err = g.nostrPublisher.PublishEvent(ctx, nip71Event)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to publish NIP-71 video event: %v\n", err)
|
|
} else {
|
|
fmt.Printf("Published NIP-71 video event: %s\n", nip71Event.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Queue video for transcoding if applicable
|
|
if g.transcodingManager != nil && streamingInfo != nil && g.config.Transcoding.AutoTranscode {
|
|
// For torrent files, we need to reconstruct the original file for transcoding
|
|
go func() {
|
|
// Run in background to not block upload response
|
|
originalPath, err := g.reconstructTorrentFile(metadata.Hash, fileName)
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to reconstruct file %s for transcoding: %v", fileName, err)
|
|
return
|
|
}
|
|
|
|
log.Printf("Queueing large video file %s for transcoding", fileName)
|
|
g.transcodingManager.QueueVideoForTranscoding(metadata.Hash, fileName, originalPath, metadata.Size)
|
|
}()
|
|
}
|
|
|
|
// Send success response for torrent
|
|
response := UploadResponse{
|
|
FileHash: metadata.Hash,
|
|
Message: "File uploaded successfully as torrent",
|
|
TorrentHash: torrentInfo.InfoHash,
|
|
MagnetLink: torrentInfo.Magnet,
|
|
NostrEventID: nostrEventID,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (g *Gateway) DownloadHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet, http.MethodHead}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate file hash from URL
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check file access permissions
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
// Get metadata with error handling
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Validate metadata
|
|
if metadata == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid metadata", ErrorTypeInternal,
|
|
"Retrieved metadata is null")
|
|
return
|
|
}
|
|
|
|
if metadata.TotalSize <= 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid file size", ErrorTypeInternal,
|
|
"File metadata indicates invalid size")
|
|
return
|
|
}
|
|
|
|
// Route based on storage type
|
|
if metadata.StorageType == "blob" {
|
|
g.downloadBlob(w, r, metadata)
|
|
return
|
|
} else {
|
|
// Default to torrent/chunk handling for backward compatibility
|
|
g.downloadTorrent(w, r, metadata)
|
|
return
|
|
}
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", metadata.TotalSize))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT") // Placeholder
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Set response headers for GET request
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", metadata.TotalSize))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
// Set filename if available
|
|
if metadata.FileName != "" {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", metadata.FileName))
|
|
}
|
|
|
|
// Retrieve and stream chunks
|
|
bytesWritten := int64(0)
|
|
for i, chunkInfo := range metadata.Chunks {
|
|
// Validate chunk info
|
|
if chunkInfo.Hash == "" {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk hash", ErrorTypeInternal,
|
|
fmt.Sprintf("Chunk %d has empty hash", i))
|
|
return
|
|
}
|
|
|
|
if chunkInfo.Size <= 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk size", ErrorTypeInternal,
|
|
fmt.Sprintf("Chunk %d has invalid size: %d", i, chunkInfo.Size))
|
|
return
|
|
}
|
|
|
|
// Retrieve chunk data
|
|
chunkData, err := g.blossomClient.Get(chunkInfo.Hash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Chunk retrieval failed", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Failed to retrieve chunk %d (%s): %v", i, chunkInfo.Hash, err))
|
|
return
|
|
}
|
|
|
|
// Validate retrieved data
|
|
if len(chunkData) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty chunk data", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Chunk %d returned empty data", i))
|
|
return
|
|
}
|
|
|
|
if len(chunkData) != chunkInfo.Size {
|
|
g.writeError(w, http.StatusInternalServerError, "Chunk size mismatch", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Chunk %d size mismatch: expected %d, got %d", i, chunkInfo.Size, len(chunkData)))
|
|
return
|
|
}
|
|
|
|
// Write chunk to response
|
|
written, err := w.Write(chunkData)
|
|
if err != nil {
|
|
// Client may have disconnected - log but don't send error response
|
|
fmt.Printf("Warning: Failed to write chunk %d to client: %v\n", i, err)
|
|
return
|
|
}
|
|
|
|
bytesWritten += int64(written)
|
|
|
|
// Validate write completed successfully
|
|
if written != len(chunkData) {
|
|
fmt.Printf("Warning: Partial write for chunk %d: wrote %d of %d bytes\n", i, written, len(chunkData))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Final validation
|
|
if bytesWritten != metadata.TotalSize {
|
|
fmt.Printf("Warning: Total bytes written (%d) doesn't match expected size (%d)\n",
|
|
bytesWritten, metadata.TotalSize)
|
|
}
|
|
}
|
|
|
|
// downloadBlob handles downloading files stored as single Blossom blobs
|
|
func (g *Gateway) downloadBlob(w http.ResponseWriter, r *http.Request, metadata *FileMetadata) {
|
|
// For blobs, the file hash IS the blob hash
|
|
blobHash := metadata.FileHash
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", metadata.TotalSize))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
if metadata.FileName != "" {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", metadata.FileName))
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Get blob data from storage backend
|
|
reader, _, err := g.storage.GetBlobData(blobHash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Failed to retrieve blob", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Could not get blob from storage: %v", err))
|
|
return
|
|
}
|
|
if reader == nil {
|
|
g.writeError(w, http.StatusNotFound, "Blob not found", ErrorTypeNotFound,
|
|
fmt.Sprintf("Blob with hash %s not found", blobHash))
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
// Read blob data
|
|
blobData, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Failed to read blob", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Could not read blob data: %v", err))
|
|
return
|
|
}
|
|
|
|
// Set response headers for GET request
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(blobData)))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
if metadata.FileName != "" {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", metadata.FileName))
|
|
}
|
|
|
|
// Write blob data
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(blobData)
|
|
}
|
|
|
|
// downloadTorrent handles downloading files stored as BitTorrent chunks
|
|
func (g *Gateway) downloadTorrent(w http.ResponseWriter, r *http.Request, metadata *FileMetadata) {
|
|
if len(metadata.Chunks) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "No chunks found", ErrorTypeInternal,
|
|
"Torrent file metadata indicates no chunks available")
|
|
return
|
|
}
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", metadata.TotalSize))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
if metadata.FileName != "" {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", metadata.FileName))
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Set response headers for GET request
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", metadata.TotalSize))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
if metadata.FileName != "" {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", metadata.FileName))
|
|
}
|
|
|
|
// Retrieve and stream chunks
|
|
bytesWritten := int64(0)
|
|
for i, chunkInfo := range metadata.Chunks {
|
|
// Validate chunk info
|
|
if chunkInfo.Hash == "" {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk hash", ErrorTypeInternal,
|
|
fmt.Sprintf("Chunk %d has empty hash", i))
|
|
return
|
|
}
|
|
|
|
// Get chunk data
|
|
chunkData, err := g.blossomClient.Get(chunkInfo.Hash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Failed to retrieve chunk", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Could not get chunk %d: %v", i, err))
|
|
return
|
|
}
|
|
|
|
// Write chunk data
|
|
written, err := w.Write(chunkData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to write chunk %d to client: %v\n", i, err)
|
|
return
|
|
}
|
|
|
|
bytesWritten += int64(written)
|
|
}
|
|
}
|
|
|
|
// Temporary in-memory storage for metadata
|
|
var metadataStore = make(map[string]FileMetadata)
|
|
|
|
func (g *Gateway) storeMetadata(fileHash string, metadata FileMetadata) error {
|
|
metadataStore[fileHash] = metadata
|
|
return nil
|
|
}
|
|
|
|
func (g *Gateway) getMetadata(fileHash string) (*FileMetadata, error) {
|
|
// Get metadata from storage backend
|
|
dbMetadata, err := g.storage.GetFileMetadata(fileHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get metadata from storage: %w", err)
|
|
}
|
|
if dbMetadata == nil {
|
|
return nil, fmt.Errorf("metadata not found for hash: %s", fileHash)
|
|
}
|
|
|
|
// Convert storage metadata to API metadata format
|
|
chunks := []ChunkInfo{}
|
|
if dbMetadata.StorageType == "torrent" {
|
|
// Get chunks for torrent files
|
|
chunkData, err := g.storage.GetFileChunks(fileHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get chunks: %w", err)
|
|
}
|
|
chunks = make([]ChunkInfo, len(chunkData))
|
|
for i, chunk := range chunkData {
|
|
chunks[i] = ChunkInfo{
|
|
Index: chunk.ChunkIndex,
|
|
Hash: chunk.ChunkHash,
|
|
Size: int(chunk.Size),
|
|
}
|
|
}
|
|
}
|
|
|
|
metadata := &FileMetadata{
|
|
FileHash: dbMetadata.Hash,
|
|
FileName: dbMetadata.OriginalName,
|
|
TotalSize: dbMetadata.Size,
|
|
ChunkCount: dbMetadata.ChunkCount,
|
|
StorageType: dbMetadata.StorageType,
|
|
Chunks: chunks,
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// WebSeed handlers (BEP-19 support) - Enhanced for BitTorrent client compatibility
|
|
func (g *Gateway) WebSeedHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet, http.MethodHead}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate parameters
|
|
vars := mux.Vars(r)
|
|
infoHash := vars["hash"]
|
|
requestPath := r.URL.Path
|
|
|
|
// Detect BitTorrent client for optimizations
|
|
clientInfo := g.detectBitTorrentClient(r)
|
|
|
|
// Track WebSeed statistics
|
|
g.updateWebSeedStats(infoHash, "request", 1)
|
|
|
|
// Parse request type (piece or file)
|
|
if strings.Contains(requestPath, "/piece/") {
|
|
g.handleWebSeedPieceRequest(w, r, infoHash, clientInfo)
|
|
} else if strings.Contains(requestPath, "/files/") {
|
|
g.handleWebSeedFileRequest(w, r, infoHash, clientInfo)
|
|
} else {
|
|
// Default: serve entire file (BEP-19 compatibility)
|
|
g.handleWebSeedFileRequest(w, r, infoHash, clientInfo)
|
|
}
|
|
}
|
|
|
|
// handleWebSeedPieceRequest handles piece-specific requests (/webseed/{infohash}/piece/{index})
|
|
func (g *Gateway) handleWebSeedPieceRequest(w http.ResponseWriter, r *http.Request, infoHash string, clientInfo string) {
|
|
vars := mux.Vars(r)
|
|
pieceStr := vars["piece"]
|
|
|
|
// Validate and parse piece index
|
|
pieceIndex, err := strconv.Atoi(pieceStr)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusBadRequest, "Invalid piece index", ErrorTypeValidation,
|
|
fmt.Sprintf("Piece index must be a valid integer: %s", pieceStr))
|
|
return
|
|
}
|
|
|
|
// Get piece data
|
|
pieceData, _, err := g.getPieceData(infoHash, pieceIndex)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusNotFound, "Piece not found", ErrorTypeNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
// Set optimal headers for BitTorrent clients
|
|
g.setWebSeedHeaders(w, len(pieceData), clientInfo)
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Check for range request
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader != "" {
|
|
g.handleRangeRequest(w, r, pieceData, rangeHeader)
|
|
g.updateWebSeedStats(infoHash, "bytes_served", int64(len(pieceData)))
|
|
return
|
|
}
|
|
|
|
// Serve full piece
|
|
written, err := w.Write(pieceData)
|
|
if err != nil {
|
|
log.Printf("WebSeed piece %d write error for %s: %v", pieceIndex, clientInfo, err)
|
|
return
|
|
}
|
|
|
|
// Update statistics
|
|
g.updateWebSeedStats(infoHash, "pieces_served", 1)
|
|
g.updateWebSeedStats(infoHash, "bytes_served", int64(written))
|
|
|
|
log.Printf("WebSeed served piece %d (%d bytes) to %s", pieceIndex, written, clientInfo)
|
|
}
|
|
|
|
// handleWebSeedFileRequest handles file requests (/webseed/{infohash}/files/{path} or /)
|
|
func (g *Gateway) handleWebSeedFileRequest(w http.ResponseWriter, r *http.Request, infoHash string, clientInfo string) {
|
|
// For single-file torrents, delegate to download handler with hash lookup
|
|
fileHash, err := g.getFileHashFromInfoHash(infoHash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusNotFound, "File not found", ErrorTypeNotFound,
|
|
fmt.Sprintf("No file found for info hash: %s", infoHash))
|
|
return
|
|
}
|
|
|
|
// Update request path to use file hash
|
|
r = mux.SetURLVars(r, map[string]string{"hash": fileHash})
|
|
|
|
// Set WebSeed-specific headers
|
|
g.setWebSeedHeaders(w, 0, clientInfo) // Size will be set by download handler
|
|
|
|
// Update statistics
|
|
g.updateWebSeedStats(infoHash, "file_requests", 1)
|
|
|
|
// Delegate to optimized download handler
|
|
g.DownloadHandler(w, r)
|
|
}
|
|
|
|
// getPieceData extracts exact piece data from file chunks with concurrent request optimization
|
|
func (g *Gateway) getPieceData(infoHash string, pieceIndex int) ([]byte, *FileMetadata, error) {
|
|
// Get file hash from info hash
|
|
fileHash, err := g.getFileHashFromInfoHash(infoHash)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("file not found for info hash: %s", infoHash)
|
|
}
|
|
|
|
// Get metadata
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("metadata not found: %v", err)
|
|
}
|
|
|
|
// Validate piece index
|
|
if pieceIndex < 0 || pieceIndex >= len(metadata.Chunks) {
|
|
return nil, nil, fmt.Errorf("piece index %d out of range (0-%d)", pieceIndex, len(metadata.Chunks)-1)
|
|
}
|
|
|
|
// Get torrent info to calculate piece boundaries
|
|
if metadata.TorrentInfo == nil {
|
|
return nil, nil, fmt.Errorf("torrent info not available")
|
|
}
|
|
|
|
// Check piece cache first (before acquiring loading mutex)
|
|
if cachedPiece := g.getPieceFromCache(infoHash, pieceIndex); cachedPiece != nil {
|
|
g.updateWebSeedStats(infoHash, "cache_hits", 1)
|
|
return cachedPiece, metadata, nil
|
|
}
|
|
|
|
// Prevent concurrent loading of the same piece
|
|
pieceKey := fmt.Sprintf("%s:%d", infoHash, pieceIndex)
|
|
|
|
// Get or create mutex for this piece
|
|
pieceLoadMutexLock.Lock()
|
|
pieceMutex, exists := pieceLoadMutex[pieceKey]
|
|
if !exists {
|
|
pieceMutex = &sync.Mutex{}
|
|
pieceLoadMutex[pieceKey] = pieceMutex
|
|
}
|
|
pieceLoadMutexLock.Unlock()
|
|
|
|
// Lock this specific piece loading
|
|
pieceMutex.Lock()
|
|
defer func() {
|
|
pieceMutex.Unlock()
|
|
// Clean up mutex map to prevent memory leaks
|
|
pieceLoadMutexLock.Lock()
|
|
delete(pieceLoadMutex, pieceKey)
|
|
pieceLoadMutexLock.Unlock()
|
|
}()
|
|
|
|
// Check cache again in case another goroutine loaded it
|
|
if cachedPiece := g.getPieceFromCache(infoHash, pieceIndex); cachedPiece != nil {
|
|
g.updateWebSeedStats(infoHash, "cache_hits", 1)
|
|
return cachedPiece, metadata, nil
|
|
}
|
|
|
|
chunkInfo := metadata.Chunks[pieceIndex]
|
|
|
|
// Retrieve chunk data from storage
|
|
pieceData, err := g.blossomClient.Get(chunkInfo.Hash)
|
|
if err != nil {
|
|
g.updateWebSeedStats(infoHash, "cache_misses", 1)
|
|
return nil, nil, fmt.Errorf("failed to retrieve piece %d: %v", pieceIndex, err)
|
|
}
|
|
|
|
// Validate piece data
|
|
if len(pieceData) != chunkInfo.Size {
|
|
return nil, nil, fmt.Errorf("piece size mismatch: expected %d, got %d", chunkInfo.Size, len(pieceData))
|
|
}
|
|
|
|
// Cache the piece for future requests
|
|
g.cachePiece(infoHash, pieceIndex, pieceData)
|
|
g.updateWebSeedStats(infoHash, "cache_misses", 1)
|
|
|
|
return pieceData, metadata, nil
|
|
}
|
|
|
|
// detectBitTorrentClient identifies the BitTorrent client and returns optimization info
|
|
func (g *Gateway) detectBitTorrentClient(r *http.Request) string {
|
|
userAgent := r.Header.Get("User-Agent")
|
|
|
|
// Enhanced client detection with version parsing
|
|
clientPatterns := []struct {
|
|
pattern string
|
|
name string
|
|
needsOptimization bool
|
|
}{
|
|
{"qBittorrent", "qBittorrent", true},
|
|
{"Transmission", "Transmission", true},
|
|
{"libtorrent", "libtorrent", true},
|
|
{"Deluge", "Deluge", false},
|
|
{"rtorrent", "rtorrent", false},
|
|
{"uTorrent", "uTorrent", true},
|
|
{"BitTorrent", "BitTorrent", true},
|
|
{"aria2", "aria2", false},
|
|
{"WebTorrent", "WebTorrent", true},
|
|
{"ltorrent", "libtorrent", true}, // Alternative spelling
|
|
{"Azureus", "Azureus", false},
|
|
{"BitComet", "BitComet", false},
|
|
}
|
|
|
|
for _, client := range clientPatterns {
|
|
if strings.Contains(userAgent, client.pattern) {
|
|
return fmt.Sprintf("%s (%s)", client.name, userAgent)
|
|
}
|
|
}
|
|
|
|
// Check for curl/wget (testing tools)
|
|
if strings.Contains(userAgent, "curl") || strings.Contains(userAgent, "wget") {
|
|
return fmt.Sprintf("HTTP-Tool (%s)", userAgent)
|
|
}
|
|
|
|
return fmt.Sprintf("Unknown (%s)", userAgent)
|
|
}
|
|
|
|
// getClientOptimizations returns optimization settings based on detected client
|
|
func (g *Gateway) getClientOptimizations(clientInfo string) map[string]interface{} {
|
|
opts := make(map[string]interface{})
|
|
|
|
// Default optimizations
|
|
opts["keep_alive"] = true
|
|
opts["buffer_size"] = 64 * 1024 // 64KB default buffer
|
|
opts["max_connections"] = 10
|
|
|
|
// Client-specific optimizations
|
|
if strings.Contains(clientInfo, "qBittorrent") {
|
|
opts["buffer_size"] = 256 * 1024 // qBittorrent likes larger buffers
|
|
opts["max_connections"] = 20
|
|
} else if strings.Contains(clientInfo, "Transmission") {
|
|
opts["buffer_size"] = 128 * 1024
|
|
opts["prefer_ranges"] = true
|
|
} else if strings.Contains(clientInfo, "libtorrent") {
|
|
opts["buffer_size"] = 512 * 1024 // libtorrent can handle large buffers
|
|
opts["max_connections"] = 30
|
|
} else if strings.Contains(clientInfo, "WebTorrent") {
|
|
opts["buffer_size"] = 32 * 1024 // Web clients prefer smaller buffers
|
|
opts["cors_headers"] = true
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
// setWebSeedHeaders sets optimal headers for BitTorrent WebSeed compatibility
|
|
func (g *Gateway) setWebSeedHeaders(w http.ResponseWriter, contentLength int, clientInfo string) {
|
|
// Get client-specific optimizations
|
|
opts := g.getClientOptimizations(clientInfo)
|
|
|
|
// Standard WebSeed headers
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Header().Set("X-WebSeed-Server", "TorrentGateway/1.0")
|
|
|
|
// CORS headers for web clients
|
|
if corsHeaders, ok := opts["cors_headers"].(bool); ok && corsHeaders {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
}
|
|
|
|
if contentLength > 0 {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength))
|
|
}
|
|
|
|
// Client-specific optimizations
|
|
if strings.Contains(clientInfo, "qBittorrent") {
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no") // Disable proxy buffering for qBittorrent
|
|
} else if strings.Contains(clientInfo, "Transmission") {
|
|
w.Header().Set("Server", "nginx/1.0") // Transmission likes nginx
|
|
w.Header().Set("Connection", "keep-alive")
|
|
} else if strings.Contains(clientInfo, "libtorrent") {
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Content-Duration", "0") // Hint for streaming optimizations
|
|
} else if strings.Contains(clientInfo, "WebTorrent") {
|
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range")
|
|
w.Header().Set("Timing-Allow-Origin", "*")
|
|
}
|
|
}
|
|
|
|
// getFileHashFromInfoHash maps info hash to file hash
|
|
func (g *Gateway) getFileHashFromInfoHash(infoHash string) (string, error) {
|
|
// Check memory store first
|
|
for fileHash, metadata := range metadataStore {
|
|
if metadata.TorrentInfo != nil && metadata.TorrentInfo.InfoHash == infoHash {
|
|
return fileHash, nil
|
|
}
|
|
}
|
|
|
|
// Check storage backend
|
|
return g.storage.GetFileHashByInfoHash(infoHash)
|
|
}
|
|
|
|
// WebSeedStats tracks detailed statistics for WebSeed usage
|
|
type WebSeedStats struct {
|
|
InfoHash string
|
|
TotalServed int64
|
|
PiecesServed map[int]int64
|
|
BytesServed int64
|
|
CacheHits int64
|
|
CacheMisses int64
|
|
ActiveConns int32
|
|
LastAccess time.Time
|
|
ClientStats map[string]int64
|
|
}
|
|
|
|
var webSeedStatsMap = make(map[string]*WebSeedStats)
|
|
var webSeedStatsMutex sync.RWMutex
|
|
|
|
// updateWebSeedStats tracks comprehensive WebSeed usage statistics
|
|
func (g *Gateway) updateWebSeedStats(infoHash string, statType string, value int64) {
|
|
webSeedStatsMutex.Lock()
|
|
defer webSeedStatsMutex.Unlock()
|
|
|
|
stats, exists := webSeedStatsMap[infoHash]
|
|
if !exists {
|
|
stats = &WebSeedStats{
|
|
InfoHash: infoHash,
|
|
PiecesServed: make(map[int]int64),
|
|
ClientStats: make(map[string]int64),
|
|
LastAccess: time.Now(),
|
|
}
|
|
webSeedStatsMap[infoHash] = stats
|
|
}
|
|
|
|
stats.LastAccess = time.Now()
|
|
|
|
switch statType {
|
|
case "pieces_served":
|
|
stats.TotalServed += value
|
|
case "bytes_served":
|
|
stats.BytesServed += value
|
|
case "cache_hits":
|
|
stats.CacheHits += value
|
|
case "cache_misses":
|
|
stats.CacheMisses += value
|
|
case "file_requests":
|
|
stats.TotalServed += value
|
|
}
|
|
|
|
// Log significant events
|
|
if statType == "pieces_served" || statType == "file_requests" || statType == "bytes_served" {
|
|
log.Printf("WebSeed %s: %s += %d (total: %d)", infoHash[:8], statType, value, stats.TotalServed)
|
|
}
|
|
}
|
|
|
|
// getWebSeedStats returns statistics for a specific torrent
|
|
func (g *Gateway) getWebSeedStats(infoHash string) *WebSeedStats {
|
|
webSeedStatsMutex.RLock()
|
|
defer webSeedStatsMutex.RUnlock()
|
|
|
|
if stats, exists := webSeedStatsMap[infoHash]; exists {
|
|
// Return a copy to avoid race conditions
|
|
statsCopy := *stats
|
|
statsCopy.PiecesServed = make(map[int]int64)
|
|
for k, v := range stats.PiecesServed {
|
|
statsCopy.PiecesServed[k] = v
|
|
}
|
|
statsCopy.ClientStats = make(map[string]int64)
|
|
for k, v := range stats.ClientStats {
|
|
statsCopy.ClientStats[k] = v
|
|
}
|
|
return &statsCopy
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Enhanced piece caching with LRU implementation
|
|
type PieceCacheEntry struct {
|
|
data []byte
|
|
size int64
|
|
hits int64
|
|
lastAccess time.Time
|
|
}
|
|
|
|
type PieceCache struct {
|
|
cache map[string]*PieceCacheEntry
|
|
mutex sync.RWMutex
|
|
maxSize int64
|
|
maxPieces int
|
|
totalSize int64
|
|
}
|
|
|
|
var pieceCacheInstance = &PieceCache{
|
|
cache: make(map[string]*PieceCacheEntry),
|
|
maxSize: 50 * 1024 * 1024, // 50MB max cache size
|
|
maxPieces: 200, // Max 200 pieces cached
|
|
}
|
|
|
|
// Concurrent piece loading prevention
|
|
var pieceLoadMutex = make(map[string]*sync.Mutex)
|
|
var pieceLoadMutexLock sync.Mutex
|
|
|
|
func (g *Gateway) getPieceFromCache(infoHash string, pieceIndex int) []byte {
|
|
pieceCacheInstance.mutex.RLock()
|
|
defer pieceCacheInstance.mutex.RUnlock()
|
|
|
|
key := fmt.Sprintf("%s:%d", infoHash, pieceIndex)
|
|
entry, exists := pieceCacheInstance.cache[key]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
// Update access statistics
|
|
entry.hits++
|
|
entry.lastAccess = time.Now()
|
|
|
|
return entry.data
|
|
}
|
|
|
|
func (g *Gateway) cachePiece(infoHash string, pieceIndex int, data []byte) {
|
|
// Only cache pieces smaller than 2MB
|
|
if len(data) > 2*1024*1024 {
|
|
return
|
|
}
|
|
|
|
pieceCacheInstance.mutex.Lock()
|
|
defer pieceCacheInstance.mutex.Unlock()
|
|
|
|
key := fmt.Sprintf("%s:%d", infoHash, pieceIndex)
|
|
dataSize := int64(len(data))
|
|
|
|
// Check if we need to evict entries
|
|
if len(pieceCacheInstance.cache) >= pieceCacheInstance.maxPieces ||
|
|
pieceCacheInstance.totalSize+dataSize > pieceCacheInstance.maxSize {
|
|
g.evictLRUPieces(dataSize)
|
|
}
|
|
|
|
// Add new entry
|
|
pieceCacheInstance.cache[key] = &PieceCacheEntry{
|
|
data: data,
|
|
size: dataSize,
|
|
hits: 1,
|
|
lastAccess: time.Now(),
|
|
}
|
|
pieceCacheInstance.totalSize += dataSize
|
|
}
|
|
|
|
// evictLRUPieces removes least recently used pieces to make space
|
|
func (g *Gateway) evictLRUPieces(neededSpace int64) {
|
|
// Create list of entries sorted by last access time
|
|
type cacheEntry struct {
|
|
key string
|
|
lastAccess time.Time
|
|
size int64
|
|
}
|
|
|
|
var entries []cacheEntry
|
|
for key, entry := range pieceCacheInstance.cache {
|
|
entries = append(entries, cacheEntry{
|
|
key: key,
|
|
lastAccess: entry.lastAccess,
|
|
size: entry.size,
|
|
})
|
|
}
|
|
|
|
// Sort by last access (oldest first)
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].lastAccess.Before(entries[j].lastAccess)
|
|
})
|
|
|
|
// Remove entries until we have enough space
|
|
spaceFreed := int64(0)
|
|
for _, entry := range entries {
|
|
delete(pieceCacheInstance.cache, entry.key)
|
|
pieceCacheInstance.totalSize -= entry.size
|
|
spaceFreed += entry.size
|
|
|
|
// Stop when we have enough space or reduced cache by 25%
|
|
if spaceFreed >= neededSpace || len(pieceCacheInstance.cache) <= pieceCacheInstance.maxPieces*3/4 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// generateWebSeedURL creates and validates WebSeed URL for torrent
|
|
func (g *Gateway) generateWebSeedURL(r *http.Request, fileHash string) string {
|
|
// Determine base URL - prefer public URL from config, fallback to request host
|
|
var baseURL string
|
|
if g.publicURL != "" && g.publicURL != "http://localhost" {
|
|
baseURL = g.publicURL
|
|
} else {
|
|
// Use HTTPS if request came over TLS
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
baseURL = fmt.Sprintf("%s://%s", scheme, r.Host)
|
|
}
|
|
|
|
// Ensure trailing slash for directory-style URL (BEP-19 requirement)
|
|
webSeedURL := fmt.Sprintf("%s/webseed/%s/", strings.TrimSuffix(baseURL, "/"), fileHash)
|
|
|
|
// Validate URL accessibility (basic check)
|
|
if !g.validateWebSeedURL(webSeedURL) {
|
|
log.Printf("Warning: WebSeed URL may not be accessible: %s", webSeedURL)
|
|
}
|
|
|
|
return webSeedURL
|
|
}
|
|
|
|
// validateWebSeedURL performs basic validation of WebSeed URL accessibility
|
|
func (g *Gateway) validateWebSeedURL(webSeedURL string) bool {
|
|
// Basic URL format validation
|
|
if !strings.HasSuffix(webSeedURL, "/") {
|
|
log.Printf("WebSeed URL validation failed: missing trailing slash")
|
|
return false
|
|
}
|
|
|
|
if !strings.Contains(webSeedURL, "/webseed/") {
|
|
log.Printf("WebSeed URL validation failed: missing /webseed/ path")
|
|
return false
|
|
}
|
|
|
|
// In production, you might want to perform an actual HTTP test:
|
|
// resp, err := http.Head(webSeedURL)
|
|
// return err == nil && resp.StatusCode == 200
|
|
|
|
return true
|
|
}
|
|
|
|
// WebSeedHealthHandler checks WebSeed service health and accessibility
|
|
func (g *Gateway) WebSeedHealthHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
health := map[string]interface{}{
|
|
"status": "healthy",
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
"service": "webseed",
|
|
"version": "1.0",
|
|
}
|
|
|
|
// Test basic WebSeed functionality
|
|
tests := map[string]bool{
|
|
"storage_backend": g.storage != nil,
|
|
"blossom_client": g.blossomClient != nil,
|
|
"piece_cache": len(pieceCacheInstance.cache) >= 0, // Cache is available
|
|
"url_validation": g.validateWebSeedURL("http://example.com/webseed/test/"),
|
|
}
|
|
|
|
// Count cached pieces and calculate cache stats
|
|
pieceCacheInstance.mutex.RLock()
|
|
cacheSize := len(pieceCacheInstance.cache)
|
|
totalCacheSize := pieceCacheInstance.totalSize
|
|
pieceCacheInstance.mutex.RUnlock()
|
|
|
|
// Get WebSeed statistics
|
|
var totalFiles, totalPieces int64
|
|
if g.storage != nil {
|
|
// Count files with torrent info (WebSeed-enabled files)
|
|
for _, metadata := range metadataStore {
|
|
if metadata.TorrentInfo != nil {
|
|
totalFiles++
|
|
totalPieces += int64(len(metadata.Chunks))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate aggregate statistics from all torrents
|
|
webSeedStatsMutex.RLock()
|
|
var totalCacheHits, totalCacheMisses, totalBytesServed int64
|
|
for _, stats := range webSeedStatsMap {
|
|
totalCacheHits += stats.CacheHits
|
|
totalCacheMisses += stats.CacheMisses
|
|
totalBytesServed += stats.BytesServed
|
|
}
|
|
webSeedStatsMutex.RUnlock()
|
|
|
|
// Calculate cache hit rate
|
|
var cacheHitRate float64
|
|
if totalCacheHits+totalCacheMisses > 0 {
|
|
cacheHitRate = float64(totalCacheHits) / float64(totalCacheHits+totalCacheMisses) * 100
|
|
}
|
|
|
|
health["tests"] = tests
|
|
health["statistics"] = map[string]interface{}{
|
|
"cached_pieces": cacheSize,
|
|
"cache_size_mb": float64(totalCacheSize) / (1024 * 1024),
|
|
"cache_max_size_mb": float64(pieceCacheInstance.maxSize) / (1024 * 1024),
|
|
"webseed_files": totalFiles,
|
|
"total_pieces": totalPieces,
|
|
"cache_hit_rate": fmt.Sprintf("%.1f%%", cacheHitRate),
|
|
"cache_hits": totalCacheHits,
|
|
"cache_misses": totalCacheMisses,
|
|
"bandwidth_served": fmt.Sprintf("%.2f MB", float64(totalBytesServed)/(1024*1024)),
|
|
"active_torrents": len(webSeedStatsMap),
|
|
}
|
|
|
|
// Determine overall health
|
|
allTestsPassed := true
|
|
for _, passed := range tests {
|
|
if !passed {
|
|
allTestsPassed = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if !allTestsPassed {
|
|
health["status"] = "degraded"
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
} else {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// Encode response
|
|
if err := json.NewEncoder(w).Encode(health); err != nil {
|
|
log.Printf("Failed to encode WebSeed health response: %v", err)
|
|
}
|
|
}
|
|
|
|
// P2PStatsHandler returns comprehensive P2P statistics across all components
|
|
func (g *Gateway) P2PStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
stats := make(map[string]interface{})
|
|
|
|
// Tracker statistics
|
|
if g.trackerInstance != nil {
|
|
trackerStats := make(map[string]interface{})
|
|
trackerStats["status"] = "active"
|
|
trackerStats["uptime_seconds"] = time.Since(time.Now()).Seconds() // Placeholder
|
|
|
|
stats["tracker"] = trackerStats
|
|
}
|
|
|
|
// DHT statistics
|
|
if g.dhtBootstrap != nil {
|
|
dhtStats := make(map[string]interface{})
|
|
dhtStats["status"] = "active"
|
|
dhtStats["routing_table_size"] = "N/A" // Would need DHT interface methods
|
|
dhtStats["active_searches"] = 0
|
|
dhtStats["stored_values"] = 0
|
|
|
|
stats["dht"] = dhtStats
|
|
}
|
|
|
|
// WebSeed statistics (from our enhanced implementation)
|
|
webSeedStatsMutex.RLock()
|
|
var totalCacheHits, totalCacheMisses, totalBytesServed int64
|
|
var activeTorrents int
|
|
for _, torrentStats := range webSeedStatsMap {
|
|
totalCacheHits += torrentStats.CacheHits
|
|
totalCacheMisses += torrentStats.CacheMisses
|
|
totalBytesServed += torrentStats.BytesServed
|
|
activeTorrents++
|
|
}
|
|
webSeedStatsMutex.RUnlock()
|
|
|
|
pieceCacheInstance.mutex.RLock()
|
|
cacheSize := len(pieceCacheInstance.cache)
|
|
totalCacheSize := pieceCacheInstance.totalSize
|
|
pieceCacheInstance.mutex.RUnlock()
|
|
|
|
var cacheHitRate float64
|
|
if totalCacheHits+totalCacheMisses > 0 {
|
|
cacheHitRate = float64(totalCacheHits) / float64(totalCacheHits+totalCacheMisses)
|
|
}
|
|
|
|
stats["webseed"] = map[string]interface{}{
|
|
"active_transfers": activeTorrents,
|
|
"bandwidth_served": fmt.Sprintf("%.2f MB", float64(totalBytesServed)/(1024*1024)),
|
|
"cache_hit_rate": cacheHitRate,
|
|
"cached_pieces": cacheSize,
|
|
"cache_size_mb": float64(totalCacheSize) / (1024 * 1024),
|
|
"cache_efficiency": fmt.Sprintf("%.1f%%", cacheHitRate*100),
|
|
}
|
|
|
|
// Overall P2P coordination statistics
|
|
stats["coordination"] = map[string]interface{}{
|
|
"integration_active": g.trackerInstance != nil && g.dhtBootstrap != nil,
|
|
"webseed_enabled": true,
|
|
"total_components": 3, // Tracker + DHT + WebSeed
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
|
log.Printf("Failed to encode P2P stats response: %v", err)
|
|
g.writeError(w, http.StatusInternalServerError, "Internal server error", ErrorTypeInternal, err.Error())
|
|
}
|
|
}
|
|
|
|
// P2PDiagnosticsHandler provides comprehensive P2P diagnostics
|
|
func (g *Gateway) P2PDiagnosticsHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
diagnostics := map[string]interface{}{
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
"version": "1.0",
|
|
}
|
|
|
|
// Test tracker accessibility
|
|
trackerAccessible := g.trackerInstance != nil
|
|
diagnostics["tracker_accessible"] = trackerAccessible
|
|
|
|
// Test DHT reachability
|
|
dhtReachable := g.dhtBootstrap != nil
|
|
diagnostics["dht_reachable"] = dhtReachable
|
|
|
|
// Test WebSeed functionality
|
|
webseedFunctional := g.testWebSeedFunctionality()
|
|
diagnostics["webseed_functional"] = webseedFunctional
|
|
|
|
// Network diagnostics
|
|
publicIP := g.getPublicIP()
|
|
diagnostics["public_ip"] = publicIP
|
|
|
|
// Port forwarding detection (simplified)
|
|
portForwarding := g.detectPortForwarding()
|
|
diagnostics["port_forwarding"] = portForwarding
|
|
|
|
// NAT type detection (simplified)
|
|
natType := g.detectNATType()
|
|
diagnostics["nat_type"] = natType
|
|
|
|
// Calculate connectivity score
|
|
connectivityScore := g.calculateConnectivityScore(trackerAccessible, dhtReachable, webseedFunctional, portForwarding)
|
|
diagnostics["connectivity_score"] = connectivityScore
|
|
|
|
// Performance metrics
|
|
diagnostics["performance"] = map[string]interface{}{
|
|
"avg_response_time_ms": g.getAverageResponseTime(),
|
|
"cache_efficiency": g.getCacheEfficiency(),
|
|
"active_connections": g.getActiveConnections(),
|
|
}
|
|
|
|
// System resource usage
|
|
diagnostics["resources"] = map[string]interface{}{
|
|
"memory_usage_mb": g.getMemoryUsage(),
|
|
"goroutines": g.getGoroutineCount(),
|
|
"open_files": g.getOpenFileCount(),
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(diagnostics); err != nil {
|
|
log.Printf("Failed to encode P2P diagnostics response: %v", err)
|
|
g.writeError(w, http.StatusInternalServerError, "Internal server error", ErrorTypeInternal, err.Error())
|
|
}
|
|
}
|
|
|
|
// Diagnostic helper methods
|
|
func (g *Gateway) testWebSeedFunctionality() bool {
|
|
// Test WebSeed health endpoint
|
|
return true // Simplified for now
|
|
}
|
|
|
|
func (g *Gateway) getPublicIP() string {
|
|
// In production, this would query an external service
|
|
return "Unknown"
|
|
}
|
|
|
|
func (g *Gateway) detectPortForwarding() string {
|
|
// In production, this would test port reachability
|
|
return "unknown"
|
|
}
|
|
|
|
func (g *Gateway) detectNATType() string {
|
|
// In production, this would use STUN/TURN to detect NAT type
|
|
return "unknown"
|
|
}
|
|
|
|
func (g *Gateway) calculateConnectivityScore(tracker, dht, webseed bool, portForwarding string) int {
|
|
score := 0
|
|
|
|
if tracker {
|
|
score += 25
|
|
}
|
|
if dht {
|
|
score += 25
|
|
}
|
|
if webseed {
|
|
score += 30 // WebSeed is most important
|
|
}
|
|
|
|
switch portForwarding {
|
|
case "detected":
|
|
score += 20
|
|
case "partial":
|
|
score += 10
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
func (g *Gateway) getAverageResponseTime() float64 {
|
|
// Would track real response times in production
|
|
return 25.5
|
|
}
|
|
|
|
func (g *Gateway) getCacheEfficiency() float64 {
|
|
// Get real cache hit rate
|
|
webSeedStatsMutex.RLock()
|
|
var totalHits, totalMisses int64
|
|
for _, stats := range webSeedStatsMap {
|
|
totalHits += stats.CacheHits
|
|
totalMisses += stats.CacheMisses
|
|
}
|
|
webSeedStatsMutex.RUnlock()
|
|
|
|
if totalHits+totalMisses == 0 {
|
|
return 0.0
|
|
}
|
|
|
|
return float64(totalHits) / float64(totalHits+totalMisses)
|
|
}
|
|
|
|
func (g *Gateway) getActiveConnections() int {
|
|
// Would track real active connections in production
|
|
return 15
|
|
}
|
|
|
|
func (g *Gateway) getMemoryUsage() int {
|
|
// Would get real memory usage in production
|
|
return 128 // MB
|
|
}
|
|
|
|
func (g *Gateway) getGoroutineCount() int {
|
|
// Would get real goroutine count in production
|
|
return 45
|
|
}
|
|
|
|
func (g *Gateway) getOpenFileCount() int {
|
|
// Would get real open file count in production
|
|
return 128
|
|
}
|
|
|
|
// handleRangeRequest handles HTTP range requests for WebSeed
|
|
func (g *Gateway) handleRangeRequest(w http.ResponseWriter, r *http.Request, data []byte, rangeHeader string) {
|
|
// Parse range header (e.g., "bytes=0-499" or "bytes=500-")
|
|
if !strings.HasPrefix(rangeHeader, "bytes=") {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
|
|
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
|
|
rangeParts := strings.Split(rangeSpec, "-")
|
|
|
|
if len(rangeParts) != 2 {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
|
|
dataLen := int64(len(data))
|
|
var start, end int64
|
|
var err error
|
|
|
|
// Parse start
|
|
if rangeParts[0] != "" {
|
|
start, err = strconv.ParseInt(rangeParts[0], 10, 64)
|
|
if err != nil || start < 0 {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse end
|
|
if rangeParts[1] != "" {
|
|
end, err = strconv.ParseInt(rangeParts[1], 10, 64)
|
|
if err != nil || end >= dataLen {
|
|
end = dataLen - 1
|
|
}
|
|
} else {
|
|
end = dataLen - 1
|
|
}
|
|
|
|
// Validate range
|
|
if start > end || start >= dataLen {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", dataLen))
|
|
return
|
|
}
|
|
|
|
// Calculate content length
|
|
contentLength := end - start + 1
|
|
|
|
// Set range response headers
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, dataLen))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
|
|
// Write the requested range
|
|
_, err = w.Write(data[start : end+1])
|
|
if err != nil {
|
|
log.Printf("WebSeed range request write error: %v", err)
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) TorrentHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet, http.MethodHead}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate file hash
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check file access permissions
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
// Get metadata
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Validate metadata
|
|
if metadata == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid metadata", ErrorTypeInternal,
|
|
"Retrieved metadata is null")
|
|
return
|
|
}
|
|
|
|
// Check if torrent is available
|
|
if metadata.TorrentInfo == nil {
|
|
g.writeError(w, http.StatusNotFound, "Torrent not available", ErrorTypeNotFound,
|
|
"No torrent data found for this file")
|
|
return
|
|
}
|
|
|
|
// Validate torrent data
|
|
if len(metadata.TorrentInfo.TorrentData) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty torrent data", ErrorTypeInternal,
|
|
"Torrent data is empty or corrupted")
|
|
return
|
|
}
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Type", "application/x-bittorrent")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(metadata.TorrentInfo.TorrentData)))
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Set response headers and serve torrent file
|
|
w.Header().Set("Content-Type", "application/x-bittorrent")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
// Set filename with safe fallback
|
|
filename := "download.torrent"
|
|
if metadata.FileName != "" {
|
|
// Sanitize filename for safe usage
|
|
safeName := strings.ReplaceAll(metadata.FileName, " ", "_")
|
|
safeName = strings.ReplaceAll(safeName, "..", "")
|
|
filename = safeName + ".torrent"
|
|
}
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(metadata.TorrentInfo.TorrentData)))
|
|
|
|
// Write torrent data
|
|
written, err := w.Write(metadata.TorrentInfo.TorrentData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to write torrent data to client: %v\n", err)
|
|
return
|
|
}
|
|
|
|
// Validate complete write
|
|
if written != len(metadata.TorrentInfo.TorrentData) {
|
|
fmt.Printf("Warning: Partial torrent write: wrote %d of %d bytes\n",
|
|
written, len(metadata.TorrentInfo.TorrentData))
|
|
}
|
|
}
|
|
|
|
// HLS Streaming handlers
|
|
func (g *Gateway) HLSPlaylistHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet, http.MethodHead}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate file hash
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check file access permissions
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
// Get metadata
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Validate metadata
|
|
if metadata == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid metadata", ErrorTypeInternal,
|
|
"Retrieved metadata is null")
|
|
return
|
|
}
|
|
|
|
// Check if file has streaming info
|
|
if metadata.StreamingInfo == nil {
|
|
g.writeError(w, http.StatusBadRequest, "File not suitable for streaming", ErrorTypeUnsupported,
|
|
"File does not have streaming metadata")
|
|
return
|
|
}
|
|
|
|
if !metadata.StreamingInfo.IsVideo {
|
|
g.writeError(w, http.StatusBadRequest, "File is not a video", ErrorTypeUnsupported,
|
|
fmt.Sprintf("File type '%s' is not supported for HLS streaming", metadata.StreamingInfo.MimeType))
|
|
return
|
|
}
|
|
|
|
// Check if HLS playlist is available
|
|
if metadata.HLSPlaylist == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "HLS playlist not available", ErrorTypeInternal,
|
|
"HLS playlist generation failed or not completed")
|
|
return
|
|
}
|
|
|
|
// Validate playlist segments
|
|
if len(metadata.HLSPlaylist.Segments) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty HLS playlist", ErrorTypeInternal,
|
|
"HLS playlist contains no segments")
|
|
return
|
|
}
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
|
w.Header().Set("Cache-Control", "public, max-age=300") // 5 minutes for playlists
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Validate host header for base URL generation
|
|
host := r.Host
|
|
if host == "" {
|
|
g.writeError(w, http.StatusBadRequest, "Missing host header", ErrorTypeValidation,
|
|
"Host header is required for HLS manifest generation")
|
|
return
|
|
}
|
|
|
|
// Generate manifest with proper base URL
|
|
baseURL := fmt.Sprintf("http://%s/api/stream/%s/segment", host, fileHash)
|
|
manifest := metadata.HLSPlaylist.GenerateM3U8Manifest(baseURL)
|
|
|
|
// Validate generated manifest
|
|
if manifest == "" {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty manifest generated", ErrorTypeInternal,
|
|
"HLS manifest generation produced empty result")
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(manifest, "#EXTM3U") {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid manifest format", ErrorTypeInternal,
|
|
"Generated manifest is not valid M3U8 format")
|
|
return
|
|
}
|
|
|
|
// Set response headers and serve manifest
|
|
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
w.Header().Set("Cache-Control", "public, max-age=300") // 5 minutes cache
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifest)))
|
|
|
|
written, err := w.Write([]byte(manifest))
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to write HLS manifest to client: %v\n", err)
|
|
return
|
|
}
|
|
|
|
if written != len(manifest) {
|
|
fmt.Printf("Warning: Partial manifest write: wrote %d of %d bytes\n", written, len(manifest))
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) HLSSegmentHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet, http.MethodHead}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate parameters
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
segmentURI := vars["segment"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check file access permissions
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
if segmentURI == "" {
|
|
g.writeError(w, http.StatusBadRequest, "Missing segment identifier", ErrorTypeValidation,
|
|
"Segment URI is required")
|
|
return
|
|
}
|
|
|
|
// Validate segment URI format
|
|
if !strings.HasPrefix(segmentURI, "segment_") || !strings.HasSuffix(segmentURI, ".ts") {
|
|
g.writeError(w, http.StatusBadRequest, "Invalid segment format", ErrorTypeValidation,
|
|
"Segment URI must match format: segment_N.ts")
|
|
return
|
|
}
|
|
|
|
// Get metadata
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Validate metadata
|
|
if metadata == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid metadata", ErrorTypeInternal,
|
|
"Retrieved metadata is null")
|
|
return
|
|
}
|
|
|
|
// Check if file is a video (required for HLS segments)
|
|
isVideo, _ := streaming.DetectMediaType(metadata.FileName)
|
|
if !isVideo {
|
|
g.writeError(w, http.StatusBadRequest, "Not a video file", ErrorTypeUnsupported,
|
|
"HLS segments are only available for video files")
|
|
return
|
|
}
|
|
|
|
// Check if HLS playlist is available
|
|
if metadata.HLSPlaylist == nil {
|
|
g.writeError(w, http.StatusNotFound, "HLS playlist not available", ErrorTypeNotFound,
|
|
"No HLS streaming data found for this file")
|
|
return
|
|
}
|
|
|
|
// Get segment info
|
|
segment, err := metadata.HLSPlaylist.GetSegmentByURI(segmentURI)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusNotFound, "Segment not found", ErrorTypeNotFound,
|
|
fmt.Sprintf("HLS segment '%s' not found: %v", segmentURI, err))
|
|
return
|
|
}
|
|
|
|
// Validate segment
|
|
if segment == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid segment", ErrorTypeInternal,
|
|
"Segment lookup returned null result")
|
|
return
|
|
}
|
|
|
|
if len(segment.ChunkIndexes) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty segment", ErrorTypeInternal,
|
|
"Segment contains no chunk indexes")
|
|
return
|
|
}
|
|
|
|
if segment.Size <= 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid segment size", ErrorTypeInternal,
|
|
fmt.Sprintf("Segment has invalid size: %d", segment.Size))
|
|
return
|
|
}
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Type", "video/mp2t")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", segment.Size))
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Set response headers
|
|
w.Header().Set("Content-Type", "video/mp2t")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", segment.Size))
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
// Write segment data by concatenating relevant chunks
|
|
bytesWritten := int64(0)
|
|
for i, chunkIndex := range segment.ChunkIndexes {
|
|
// Validate chunk index
|
|
if chunkIndex < 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk index", ErrorTypeInternal,
|
|
fmt.Sprintf("Negative chunk index %d in segment", chunkIndex))
|
|
return
|
|
}
|
|
|
|
if chunkIndex >= len(metadata.Chunks) {
|
|
g.writeError(w, http.StatusInternalServerError, "Chunk index out of range", ErrorTypeInternal,
|
|
fmt.Sprintf("Chunk index %d out of range (0-%d)", chunkIndex, len(metadata.Chunks)-1))
|
|
return
|
|
}
|
|
|
|
// Get chunk info
|
|
chunkInfo := metadata.Chunks[chunkIndex]
|
|
|
|
// Validate chunk info
|
|
if chunkInfo.Hash == "" {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk hash", ErrorTypeInternal,
|
|
fmt.Sprintf("Chunk %d has empty hash", chunkIndex))
|
|
return
|
|
}
|
|
|
|
// Retrieve chunk data
|
|
chunkData, err := g.blossomClient.Get(chunkInfo.Hash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Chunk retrieval failed", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Failed to retrieve chunk %d for segment: %v", chunkIndex, err))
|
|
return
|
|
}
|
|
|
|
// Validate chunk data
|
|
if len(chunkData) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty chunk data", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Chunk %d returned empty data", chunkIndex))
|
|
return
|
|
}
|
|
|
|
// Write chunk data
|
|
written, err := w.Write(chunkData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to write chunk %d for segment %s: %v\n", chunkIndex, segmentURI, err)
|
|
return
|
|
}
|
|
|
|
bytesWritten += int64(written)
|
|
|
|
// Validate write
|
|
if written != len(chunkData) {
|
|
fmt.Printf("Warning: Partial chunk write for segment %s: wrote %d of %d bytes for chunk %d\n",
|
|
segmentURI, written, len(chunkData), i)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Final validation
|
|
if bytesWritten != segment.Size {
|
|
fmt.Printf("Warning: Segment %s size mismatch: wrote %d, expected %d\n",
|
|
segmentURI, bytesWritten, segment.Size)
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) StreamingHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Handle CORS preflight for Firefox
|
|
if r.Method == http.MethodOptions {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range, Content-Type, Authorization")
|
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet, http.MethodHead}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate file hash
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check file access permissions
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
// Get metadata
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Validate metadata
|
|
if metadata == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid metadata", ErrorTypeInternal,
|
|
"Retrieved metadata is null")
|
|
return
|
|
}
|
|
|
|
if len(metadata.Chunks) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "No chunks found", ErrorTypeInternal,
|
|
"File metadata indicates no chunks available")
|
|
return
|
|
}
|
|
|
|
if metadata.TotalSize <= 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid file size", ErrorTypeInternal,
|
|
"File metadata indicates invalid size")
|
|
return
|
|
}
|
|
|
|
// Check for transcoded version first (higher priority for video files)
|
|
var transcodedPath string
|
|
if g.transcodingManager != nil && metadata.StreamingInfo != nil && metadata.StreamingInfo.IsVideo {
|
|
transcodedPath = g.transcodingManager.GetTranscodedPath(fileHash)
|
|
if transcodedPath != "" {
|
|
log.Printf("Serving transcoded version for %s", fileHash)
|
|
// Serve the transcoded file directly (it's a single MP4 file)
|
|
g.serveTranscodedFile(w, r, transcodedPath, metadata.FileName)
|
|
return
|
|
}
|
|
|
|
// Log transcoding status for debugging
|
|
status := g.transcodingManager.GetTranscodingStatus(fileHash)
|
|
if status != "unknown" && status != "disabled" {
|
|
log.Printf("Transcoded version not ready for %s, status: %s - serving original chunks", fileHash, status)
|
|
}
|
|
}
|
|
|
|
// Get range header for partial content support
|
|
rangeHeader := r.Header.Get("Range")
|
|
|
|
// Set appropriate headers
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range, Content-Type, Authorization")
|
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Header().Set("ETag", fmt.Sprintf("\"%s\"", fileHash))
|
|
w.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// Set content type based on file
|
|
contentType := "application/octet-stream"
|
|
if metadata.StreamingInfo != nil && metadata.StreamingInfo.MimeType != "" {
|
|
contentType = metadata.StreamingInfo.MimeType
|
|
|
|
// Keep original video MIME types for better browser compatibility
|
|
// The JavaScript player will handle unsupported formats gracefully
|
|
// This allows Chrome to show video controls and Firefox to show proper errors
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
|
|
// Set filename if available
|
|
if metadata.FileName != "" {
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", metadata.FileName))
|
|
}
|
|
|
|
// Handle HEAD request
|
|
if r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", metadata.TotalSize))
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Handle range request
|
|
if rangeHeader != "" {
|
|
rangeReq, err := streaming.ParseRangeHeader(rangeHeader, metadata.TotalSize)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidRange, fmt.Sprintf("Invalid range header: %v", err))
|
|
return
|
|
}
|
|
|
|
if rangeReq != nil {
|
|
// Validate range request
|
|
if rangeReq.Start < 0 || rangeReq.End >= metadata.TotalSize || rangeReq.Start > rangeReq.End {
|
|
g.writeError(w, http.StatusRequestedRangeNotSatisfiable, "Range not satisfiable", ErrorTypeInvalidRange,
|
|
fmt.Sprintf("Range %d-%d is not satisfiable for file size %d", rangeReq.Start, rangeReq.End, metadata.TotalSize))
|
|
return
|
|
}
|
|
|
|
// Calculate which chunks we need
|
|
chunkRange := streaming.CalculateChunkRange(rangeReq, int(g.config.GetChunkSize()))
|
|
|
|
// Validate chunk range
|
|
if chunkRange.StartChunk < 0 || chunkRange.EndChunk >= len(metadata.Chunks) {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk range", ErrorTypeInternal,
|
|
fmt.Sprintf("Calculated chunk range %d-%d invalid for %d chunks",
|
|
chunkRange.StartChunk, chunkRange.EndChunk, len(metadata.Chunks)))
|
|
return
|
|
}
|
|
|
|
// Set partial content headers
|
|
w.Header().Set("Content-Range", rangeReq.FormatContentRange(metadata.TotalSize))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", rangeReq.Size))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
|
|
// Write the requested byte range
|
|
bytesWritten := int64(0)
|
|
targetBytes := rangeReq.Size
|
|
|
|
for chunkIdx := chunkRange.StartChunk; chunkIdx <= chunkRange.EndChunk && chunkIdx < len(metadata.Chunks); chunkIdx++ {
|
|
chunkInfo := metadata.Chunks[chunkIdx]
|
|
|
|
// Validate chunk info
|
|
if chunkInfo.Hash == "" {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk hash", ErrorTypeInternal,
|
|
fmt.Sprintf("Chunk %d has empty hash", chunkIdx))
|
|
return
|
|
}
|
|
|
|
// Retrieve chunk data
|
|
chunkData, err := g.blossomClient.Get(chunkInfo.Hash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Chunk retrieval failed", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Failed to retrieve chunk %d: %v", chunkIdx, err))
|
|
return
|
|
}
|
|
|
|
// Validate chunk data
|
|
if len(chunkData) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty chunk data", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Chunk %d returned empty data", chunkIdx))
|
|
return
|
|
}
|
|
|
|
// Calculate start and end positions within this chunk
|
|
var startPos, endPos int64
|
|
|
|
if chunkIdx == chunkRange.StartChunk {
|
|
startPos = chunkRange.StartOffset
|
|
} else {
|
|
startPos = 0
|
|
}
|
|
|
|
if chunkIdx == chunkRange.EndChunk {
|
|
endPos = chunkRange.EndOffset + 1
|
|
} else {
|
|
endPos = int64(len(chunkData))
|
|
}
|
|
|
|
// Ensure we don't exceed chunk boundaries
|
|
if endPos > int64(len(chunkData)) {
|
|
endPos = int64(len(chunkData))
|
|
}
|
|
|
|
if startPos >= int64(len(chunkData)) {
|
|
continue // Skip this chunk
|
|
}
|
|
|
|
// Write the relevant portion of this chunk
|
|
if startPos < endPos {
|
|
chunkPortion := chunkData[startPos:endPos]
|
|
written, err := w.Write(chunkPortion)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Client disconnected during range request: %v\n", err)
|
|
return
|
|
}
|
|
bytesWritten += int64(written)
|
|
|
|
// Stop if we've written enough bytes
|
|
if bytesWritten >= targetBytes {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate we wrote the expected amount
|
|
if bytesWritten != targetBytes {
|
|
fmt.Printf("Warning: Range request wrote %d bytes, expected %d\n", bytesWritten, targetBytes)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Serve entire file (no range request)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", metadata.TotalSize))
|
|
|
|
bytesWritten := int64(0)
|
|
for i, chunkInfo := range metadata.Chunks {
|
|
// Validate chunk info
|
|
if chunkInfo.Hash == "" {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid chunk hash", ErrorTypeInternal,
|
|
fmt.Sprintf("Chunk %d has empty hash", i))
|
|
return
|
|
}
|
|
|
|
// Retrieve chunk data
|
|
chunkData, err := g.blossomClient.Get(chunkInfo.Hash)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Chunk retrieval failed", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Failed to retrieve chunk %d: %v", i, err))
|
|
return
|
|
}
|
|
|
|
// Validate chunk data
|
|
if len(chunkData) == 0 {
|
|
g.writeError(w, http.StatusInternalServerError, "Empty chunk data", ErrorTypeStorageFailed,
|
|
fmt.Sprintf("Chunk %d returned empty data", i))
|
|
return
|
|
}
|
|
|
|
// Write chunk data
|
|
written, err := w.Write(chunkData)
|
|
if err != nil {
|
|
fmt.Printf("Warning: Client disconnected during streaming: %v\n", err)
|
|
return
|
|
}
|
|
|
|
bytesWritten += int64(written)
|
|
|
|
// Validate write
|
|
if written != len(chunkData) {
|
|
fmt.Printf("Warning: Partial chunk write: wrote %d of %d bytes for chunk %d\n",
|
|
written, len(chunkData), i)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Final validation
|
|
if bytesWritten != metadata.TotalSize {
|
|
fmt.Printf("Warning: Streaming wrote %d bytes, expected %d\n", bytesWritten, metadata.TotalSize)
|
|
}
|
|
}
|
|
|
|
// DHTStatsHandler returns DHT node statistics
|
|
func (g *Gateway) DHTStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|
if !g.config.IsServiceEnabled("dht") {
|
|
g.writeError(w, http.StatusServiceUnavailable, "DHT service not enabled", ErrorTypeServiceUnavailable, "DHT service is not enabled on this gateway")
|
|
return
|
|
}
|
|
|
|
if g.dhtBootstrap == nil {
|
|
g.writeError(w, http.StatusServiceUnavailable, "DHT bootstrap not available", ErrorTypeServiceUnavailable, "DHT bootstrap functionality is not available")
|
|
return
|
|
}
|
|
|
|
stats := g.dhtBootstrap.GetDHTStats()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"data": stats,
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
log.Printf("Failed to encode DHT stats response: %v", err)
|
|
}
|
|
}
|
|
|
|
func RegisterRoutes(r *mux.Router, cfg *config.Config, storage *storage.Backend) *Gateway {
|
|
gateway := NewGateway(cfg, storage)
|
|
|
|
// Initialize tracker if enabled
|
|
var trackerInstance *tracker.Tracker
|
|
var announceHandler *tracker.AnnounceHandler
|
|
var scrapeHandler *tracker.ScrapeHandler
|
|
if cfg.IsServiceEnabled("tracker") {
|
|
trackerInstance = tracker.NewTracker(&cfg.Tracker, gateway)
|
|
announceHandler = tracker.NewAnnounceHandler(trackerInstance)
|
|
scrapeHandler = tracker.NewScrapeHandler(trackerInstance)
|
|
log.Printf("BitTorrent tracker enabled")
|
|
}
|
|
|
|
// Store tracker instance in gateway for stats
|
|
gateway.trackerInstance = trackerInstance
|
|
|
|
// Initialize authentication
|
|
nostrAuth := auth.NewNostrAuth(storage.GetDB())
|
|
authMiddleware := middleware.NewAuthMiddleware(nostrAuth)
|
|
authHandlers := NewAuthHandlers(nostrAuth, gateway)
|
|
|
|
// Initialize rate limiter with config values
|
|
uploadRate, uploadBurst, downloadRate, downloadBurst, streamRate, streamBurst := cfg.GetRateLimitValues()
|
|
rateLimiterConfig := &middleware.RateLimitConfig{
|
|
UploadRatePerIP: uploadRate,
|
|
UploadBurstPerIP: uploadBurst,
|
|
DownloadRate: downloadRate,
|
|
DownloadBurst: downloadBurst,
|
|
StreamRatePerFile: streamRate,
|
|
StreamBurstPerFile: streamBurst,
|
|
CleanupInterval: 5 * time.Minute,
|
|
LimiterTTL: 15 * time.Minute,
|
|
}
|
|
rateLimiter := middleware.NewRateLimiter(rateLimiterConfig)
|
|
|
|
// Initialize admin authentication if enabled
|
|
var adminHandlers *admin.AdminHandlers
|
|
if cfg.Admin.Enabled {
|
|
adminAuth := admin.NewAdminAuth(cfg.Admin.Pubkeys, nostrAuth, storage.GetDB())
|
|
adminHandlers = admin.NewAdminHandlers(adminAuth, gateway, cfg.Nostr.Relays)
|
|
}
|
|
|
|
// Security middleware is now applied at the main router level
|
|
|
|
// BitTorrent tracker endpoints (public, no auth required)
|
|
if announceHandler != nil {
|
|
r.Handle("/announce", announceHandler).Methods("GET")
|
|
}
|
|
if scrapeHandler != nil {
|
|
r.Handle("/scrape", scrapeHandler).Methods("GET")
|
|
}
|
|
|
|
// Authentication endpoints (no auth required)
|
|
r.HandleFunc("/auth/challenge", authHandlers.ChallengeHandler).Methods("GET")
|
|
r.HandleFunc("/auth/login", authHandlers.LoginHandler).Methods("POST")
|
|
r.HandleFunc("/auth/logout", authHandlers.LogoutHandler).Methods("POST")
|
|
|
|
// Public endpoints (optional auth for ownership tracking)
|
|
publicRoutes := r.PathPrefix("").Subrouter()
|
|
publicRoutes.Use(authMiddleware.OptionalAuth)
|
|
|
|
// Download endpoints with rate limiting
|
|
publicRoutes.HandleFunc("/download/{hash}", rateLimiter.DownloadMiddleware(gateway.DownloadHandler)).Methods("GET")
|
|
publicRoutes.HandleFunc("/torrent/{hash}", rateLimiter.DownloadMiddleware(gateway.TorrentHandler)).Methods("GET")
|
|
// WebSeed health endpoint (must come before other webseed routes)
|
|
publicRoutes.HandleFunc("/webseed/health", gateway.WebSeedHealthHandler).Methods("GET")
|
|
|
|
// Enhanced WebSeed endpoints with piece and file support
|
|
publicRoutes.HandleFunc("/webseed/{hash}/piece/{piece}", rateLimiter.DownloadMiddleware(gateway.WebSeedHandler)).Methods("GET", "HEAD")
|
|
publicRoutes.HandleFunc("/webseed/{hash}/files/{path:.*}", rateLimiter.DownloadMiddleware(gateway.WebSeedHandler)).Methods("GET", "HEAD")
|
|
publicRoutes.HandleFunc("/webseed/{hash}/", rateLimiter.DownloadMiddleware(gateway.WebSeedHandler)).Methods("GET", "HEAD")
|
|
publicRoutes.HandleFunc("/webseed/{hash}", rateLimiter.DownloadMiddleware(gateway.WebSeedHandler)).Methods("GET", "HEAD")
|
|
|
|
// Streaming endpoints with specific rate limiting
|
|
publicRoutes.HandleFunc("/stream/{hash}", rateLimiter.StreamMiddleware(gateway.StreamingHandler)).Methods("GET", "HEAD", "OPTIONS")
|
|
publicRoutes.HandleFunc("/stream/{hash}/playlist.m3u8", rateLimiter.StreamMiddleware(gateway.HLSPlaylistHandler)).Methods("GET")
|
|
publicRoutes.HandleFunc("/stream/{hash}/segment/{segment}", rateLimiter.StreamMiddleware(gateway.HLSSegmentHandler)).Methods("GET")
|
|
publicRoutes.HandleFunc("/info/{hash}", gateway.InfoHandler).Methods("GET")
|
|
publicRoutes.HandleFunc("/webtorrent/{hash}", gateway.WebTorrentInfoHandler).Methods("GET")
|
|
publicRoutes.HandleFunc("/thumbnail/{hash}.jpg", gateway.ThumbnailHandler).Methods("GET")
|
|
publicRoutes.HandleFunc("/files", gateway.ListFilesHandler).Methods("GET")
|
|
publicRoutes.HandleFunc("/profile/{pubkey}", gateway.ProfileHandler).Methods("GET")
|
|
|
|
// System stats endpoint (public)
|
|
r.HandleFunc("/stats", systemStatsHandler(storage, trackerInstance)).Methods("GET")
|
|
|
|
// Branding configuration endpoint (public)
|
|
r.HandleFunc("/branding", brandingHandler(cfg)).Methods("GET")
|
|
|
|
// DHT stats endpoint (public)
|
|
r.HandleFunc("/dht/stats", gateway.DHTStatsHandler).Methods("GET")
|
|
|
|
// Integrated P2P stats endpoint (public)
|
|
r.HandleFunc("/p2p/stats", gateway.P2PStatsHandler).Methods("GET")
|
|
|
|
// P2P diagnostics endpoint (public)
|
|
r.HandleFunc("/p2p/diagnostics", gateway.P2PDiagnosticsHandler).Methods("GET")
|
|
|
|
// Protected user endpoints (auth required)
|
|
userRoutes := r.PathPrefix("/users/me").Subrouter()
|
|
userRoutes.Use(authMiddleware.RequireAuth)
|
|
userRoutes.HandleFunc("/stats", authHandlers.UserStatsHandler).Methods("GET")
|
|
userRoutes.HandleFunc("/files", authHandlers.UserFilesHandler).Methods("GET")
|
|
userRoutes.HandleFunc("/files/{hash}", authHandlers.DeleteFileHandler).Methods("DELETE")
|
|
userRoutes.HandleFunc("/files/{hash}/access", authHandlers.UpdateFileAccessHandler).Methods("PUT")
|
|
userRoutes.HandleFunc("/files/{hash}/transcoding-status", gateway.TranscodingStatusHandler).Methods("GET")
|
|
userRoutes.HandleFunc("/admin-status", authHandlers.AdminStatusHandler).Methods("GET")
|
|
|
|
// Upload endpoint now requires authentication
|
|
r.HandleFunc("/upload", rateLimiter.UploadMiddleware(
|
|
authMiddleware.RequireAuth(http.HandlerFunc(gateway.UploadHandler)).ServeHTTP,
|
|
)).Methods("POST")
|
|
|
|
// Admin endpoints (if enabled)
|
|
if adminHandlers != nil {
|
|
adminRoutes := r.PathPrefix("/admin").Subrouter()
|
|
adminRoutes.HandleFunc("/stats", adminHandlers.AdminStatsHandler).Methods("GET")
|
|
adminRoutes.HandleFunc("/users", adminHandlers.AdminUsersHandler).Methods("GET")
|
|
adminRoutes.HandleFunc("/files", adminHandlers.AdminFilesHandler).Methods("GET")
|
|
adminRoutes.HandleFunc("/files/{hash}", adminHandlers.AdminDeleteFileHandler).Methods("DELETE")
|
|
adminRoutes.HandleFunc("/users/{pubkey}/ban", adminHandlers.AdminBanUserHandler).Methods("POST")
|
|
adminRoutes.HandleFunc("/users/{pubkey}/unban", adminHandlers.AdminUnbanUserHandler).Methods("POST")
|
|
adminRoutes.HandleFunc("/reports", adminHandlers.AdminReportsHandler).Methods("GET")
|
|
adminRoutes.HandleFunc("/cleanup", adminHandlers.AdminCleanupHandler).Methods("POST")
|
|
adminRoutes.HandleFunc("/logs", adminHandlers.AdminLogsHandler).Methods("GET")
|
|
}
|
|
|
|
r.HandleFunc("/health", healthHandler).Methods("GET")
|
|
|
|
// Catch-all handler for unmatched API routes
|
|
r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gateway.writeError(w, http.StatusNotFound, "API endpoint not found", ErrorTypeNotFound,
|
|
fmt.Sprintf("The requested API endpoint %s was not found", r.URL.Path))
|
|
})
|
|
|
|
return gateway
|
|
}
|
|
|
|
// InfoHandler returns file metadata for the web UI
|
|
func (g *Gateway) InfoHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate file hash
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check file access permissions
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
// Get metadata
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Validate metadata
|
|
if metadata == nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Invalid metadata", ErrorTypeInternal,
|
|
"Retrieved metadata is null")
|
|
return
|
|
}
|
|
|
|
// Create response with file info
|
|
response := map[string]interface{}{
|
|
"file_hash": metadata.FileHash,
|
|
"name": metadata.FileName,
|
|
"size": metadata.TotalSize,
|
|
"chunks": len(metadata.Chunks),
|
|
}
|
|
|
|
// Add torrent info if available
|
|
if metadata.TorrentInfo != nil {
|
|
response["magnet_link"] = metadata.TorrentInfo.Magnet
|
|
response["torrent_hash"] = metadata.TorrentInfo.InfoHash
|
|
}
|
|
|
|
// Add streaming info if available
|
|
if metadata.StreamingInfo != nil {
|
|
response["is_video"] = metadata.StreamingInfo.IsVideo
|
|
response["mime_type"] = metadata.StreamingInfo.MimeType
|
|
response["duration"] = metadata.StreamingInfo.Duration
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
fmt.Printf("Error: Failed to encode info response: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// ListFilesHandler returns a list of all uploaded files
|
|
func (g *Gateway) ListFilesHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get all files from metadata store
|
|
files := []map[string]interface{}{}
|
|
for hash, metadata := range metadataStore {
|
|
fileInfo := map[string]interface{}{
|
|
"file_hash": hash,
|
|
"name": metadata.FileName,
|
|
"size": metadata.TotalSize,
|
|
"chunks": len(metadata.Chunks),
|
|
}
|
|
|
|
// Add torrent info if available
|
|
if metadata.TorrentInfo != nil {
|
|
fileInfo["magnet_link"] = metadata.TorrentInfo.Magnet
|
|
fileInfo["torrent_hash"] = metadata.TorrentInfo.InfoHash
|
|
}
|
|
|
|
// Add streaming info if available
|
|
if metadata.StreamingInfo != nil {
|
|
fileInfo["is_video"] = metadata.StreamingInfo.IsVideo
|
|
fileInfo["mime_type"] = metadata.StreamingInfo.MimeType
|
|
fileInfo["duration"] = metadata.StreamingInfo.Duration
|
|
}
|
|
|
|
files = append(files, fileInfo)
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"files": files,
|
|
"count": len(files),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
fmt.Printf("Error: Failed to encode files list response: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// DeleteFileHandler removes a file and its metadata
|
|
func (g *Gateway) DeleteFileHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodDelete}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate file hash
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check if file exists
|
|
_, exists := metadataStore[fileHash]
|
|
if !exists {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
// Delete from metadata store
|
|
delete(metadataStore, fileHash)
|
|
|
|
// TODO: In a real implementation, we would also:
|
|
// - Delete chunks from Blossom storage
|
|
// - Clean up any cached files
|
|
// - Remove from torrent tracker
|
|
// For now, we just remove from metadata store
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "File deleted successfully",
|
|
"file_hash": fileHash,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
fmt.Printf("Error: Failed to encode delete response: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Gateway utility methods for admin functionality
|
|
func (g *Gateway) GetDB() *sql.DB {
|
|
return g.storage.GetDB()
|
|
}
|
|
|
|
func (g *Gateway) GetStorage() *storage.Backend {
|
|
return g.storage
|
|
}
|
|
|
|
// CleanupOldFiles removes files older than the specified duration
|
|
func (g *Gateway) CleanupOldFiles(olderThan time.Duration) (map[string]interface{}, error) {
|
|
cutoffTime := time.Now().Add(-olderThan)
|
|
|
|
// Get files to delete
|
|
rows, err := g.storage.GetDB().Query(`
|
|
SELECT hash, original_name, size FROM files
|
|
WHERE created_at < ?
|
|
ORDER BY created_at ASC
|
|
`, cutoffTime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query old files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var deletedFiles []string
|
|
var totalSize int64
|
|
count := 0
|
|
|
|
for rows.Next() {
|
|
var hash, name string
|
|
var size int64
|
|
if err := rows.Scan(&hash, &name, &size); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Delete the file (admin delete)
|
|
if err := g.storage.AdminDeleteFile(hash); err == nil {
|
|
deletedFiles = append(deletedFiles, fmt.Sprintf("%s (%s)", name, hash[:8]))
|
|
totalSize += size
|
|
count++
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"deleted_count": count,
|
|
"total_size": totalSize,
|
|
"files": deletedFiles,
|
|
}, nil
|
|
}
|
|
|
|
// CleanupOrphanedChunks removes chunk files that no longer have metadata
|
|
func (g *Gateway) CleanupOrphanedChunks() (map[string]interface{}, error) {
|
|
// Find chunks in database that don't have files
|
|
rows, err := g.storage.GetDB().Query(`
|
|
SELECT DISTINCT c.chunk_hash
|
|
FROM chunks c
|
|
LEFT JOIN files f ON c.file_hash = f.hash
|
|
WHERE f.hash IS NULL
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query orphaned chunks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var orphanedChunks []string
|
|
count := 0
|
|
|
|
for rows.Next() {
|
|
var chunkHash string
|
|
if err := rows.Scan(&chunkHash); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Delete chunk metadata
|
|
_, err := g.storage.GetDB().Exec("DELETE FROM chunks WHERE chunk_hash = ?", chunkHash)
|
|
if err == nil {
|
|
orphanedChunks = append(orphanedChunks, chunkHash[:8])
|
|
count++
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"deleted_count": count,
|
|
"chunks": orphanedChunks,
|
|
}, nil
|
|
}
|
|
|
|
// CleanupInactiveUsers removes users who haven't logged in for specified days
|
|
func (g *Gateway) CleanupInactiveUsers(days int) (map[string]interface{}, error) {
|
|
cutoffTime := time.Now().AddDate(0, 0, -days)
|
|
|
|
// Get inactive users (who have no files and haven't logged in recently)
|
|
rows, err := g.storage.GetDB().Query(`
|
|
SELECT u.pubkey, u.display_name
|
|
FROM users u
|
|
WHERE u.last_login < ?
|
|
AND u.file_count = 0
|
|
AND NOT EXISTS(SELECT 1 FROM banned_users WHERE pubkey = u.pubkey)
|
|
`, cutoffTime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query inactive users: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var deletedUsers []string
|
|
count := 0
|
|
|
|
for rows.Next() {
|
|
var pubkey, displayName string
|
|
if err := rows.Scan(&pubkey, &displayName); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Delete user and their sessions
|
|
_, err := g.storage.GetDB().Exec("DELETE FROM sessions WHERE pubkey = ?", pubkey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
_, err = g.storage.GetDB().Exec("DELETE FROM users WHERE pubkey = ?", pubkey)
|
|
if err == nil {
|
|
name := displayName
|
|
if name == "" {
|
|
name = pubkey[:8] + "..."
|
|
}
|
|
deletedUsers = append(deletedUsers, name)
|
|
count++
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"deleted_count": count,
|
|
"users": deletedUsers,
|
|
}, nil
|
|
}
|
|
|
|
// ProfileHandler fetches user profile metadata from their relay set
|
|
func (g *Gateway) ProfileHandler(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
pubkey := vars["pubkey"]
|
|
|
|
if pubkey == "" {
|
|
g.writeError(w, http.StatusBadRequest, "Missing pubkey", ErrorTypeValidation, "Pubkey parameter is required")
|
|
return
|
|
}
|
|
|
|
// Validate pubkey format (64 character hex)
|
|
if len(pubkey) != 64 {
|
|
g.writeError(w, http.StatusBadRequest, "Invalid pubkey format", ErrorTypeValidation, "Pubkey must be 64 character hex string")
|
|
return
|
|
}
|
|
|
|
profile, err := g.profileFetcher.GetUserProfile(pubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusNotFound, "Profile not found", ErrorTypeNotFound, fmt.Sprintf("Could not fetch profile for user: %v", err))
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"pubkey": pubkey,
|
|
"profile": profile,
|
|
})
|
|
}
|
|
|
|
// formatUptime converts a duration to a human-readable uptime string
|
|
func formatUptime(duration time.Duration) string {
|
|
days := int(duration.Hours()) / 24
|
|
hours := int(duration.Hours()) % 24
|
|
minutes := int(duration.Minutes()) % 60
|
|
|
|
if days > 0 {
|
|
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
|
} else if hours > 0 {
|
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
|
} else {
|
|
return fmt.Sprintf("%dm", minutes)
|
|
}
|
|
}
|
|
|
|
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func brandingHandler(cfg *config.Config) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
// Return branding configuration
|
|
branding := map[string]string{
|
|
"site_name": cfg.Branding.SiteName,
|
|
"logo_url": cfg.Branding.LogoURL,
|
|
"logo_width": cfg.Branding.LogoWidth,
|
|
"logo_height": cfg.Branding.LogoHeight,
|
|
"favicon_url": cfg.Branding.FaviconURL,
|
|
"description": cfg.Branding.Description,
|
|
"footer_text": cfg.Branding.FooterText,
|
|
"support_url": cfg.Branding.SupportURL,
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(branding)
|
|
}
|
|
}
|
|
|
|
func systemStatsHandler(storage *storage.Backend, trackerInstance *tracker.Tracker) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
// Get database for queries
|
|
db := storage.GetDB()
|
|
|
|
// Get total file count and storage
|
|
var totalFiles int
|
|
var totalStorage int64
|
|
err := db.QueryRow(`
|
|
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
|
FROM files
|
|
`).Scan(&totalFiles, &totalStorage)
|
|
if err != nil {
|
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get blob count and storage
|
|
var blobFiles int
|
|
var blobStorage int64
|
|
err = db.QueryRow(`
|
|
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
|
FROM files WHERE storage_type = 'blob'
|
|
`).Scan(&blobFiles, &blobStorage)
|
|
if err != nil {
|
|
blobFiles = 0
|
|
blobStorage = 0
|
|
}
|
|
|
|
// Get torrent count and storage
|
|
var torrentFiles int
|
|
var torrentStorage int64
|
|
err = db.QueryRow(`
|
|
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
|
FROM files WHERE storage_type = 'torrent'
|
|
`).Scan(&torrentFiles, &torrentStorage)
|
|
if err != nil {
|
|
torrentFiles = 0
|
|
torrentStorage = 0
|
|
}
|
|
|
|
// Get user count
|
|
var userCount int
|
|
err = db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
|
|
if err != nil {
|
|
userCount = 0
|
|
}
|
|
|
|
// Get chunk count
|
|
var chunkCount int
|
|
err = db.QueryRow(`SELECT COUNT(*) FROM chunks`).Scan(&chunkCount)
|
|
if err != nil {
|
|
chunkCount = 0
|
|
}
|
|
|
|
stats := map[string]interface{}{
|
|
"gateway": map[string]interface{}{
|
|
"status": "healthy",
|
|
"port": 9876,
|
|
"uploads": torrentFiles, // Gateway handles torrent uploads
|
|
"storage": torrentStorage,
|
|
"users": userCount,
|
|
},
|
|
"blossom": map[string]interface{}{
|
|
"status": "healthy",
|
|
"port": 8081,
|
|
"blobs": blobFiles,
|
|
"storage": blobStorage,
|
|
},
|
|
"dht": map[string]interface{}{
|
|
"status": "healthy",
|
|
"port": 6882,
|
|
"peers": 0, // Would need DHT integration
|
|
"torrents": torrentFiles,
|
|
},
|
|
"system": map[string]interface{}{
|
|
"mode": "unified",
|
|
"uptime": formatUptime(time.Since(serverStartTime)),
|
|
"storage": totalStorage,
|
|
"connections": 0, // Would need connection tracking
|
|
"chunks": chunkCount,
|
|
"total_files": totalFiles,
|
|
},
|
|
}
|
|
|
|
// Add tracker stats if enabled
|
|
if trackerInstance != nil {
|
|
trackerStats := trackerInstance.GetStats()
|
|
stats["tracker"] = map[string]interface{}{
|
|
"status": "healthy",
|
|
"torrents": trackerStats["torrents"],
|
|
"peers": trackerStats["peers"],
|
|
"seeders": trackerStats["seeders"],
|
|
"leechers": trackerStats["leechers"],
|
|
}
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(stats)
|
|
}
|
|
}
|
|
|
|
// RegisterTrackerRoutes registers tracker endpoints on the main router
|
|
func RegisterTrackerRoutes(r *mux.Router, cfg *config.Config, storage *storage.Backend) {
|
|
if !cfg.IsServiceEnabled("tracker") {
|
|
return
|
|
}
|
|
|
|
gateway := NewGateway(cfg, storage)
|
|
trackerInstance := tracker.NewTracker(&cfg.Tracker, gateway)
|
|
announceHandler := tracker.NewAnnounceHandler(trackerInstance)
|
|
scrapeHandler := tracker.NewScrapeHandler(trackerInstance)
|
|
|
|
// WebSocket tracker for WebTorrent clients
|
|
wsTracker := tracker.NewWebSocketTracker()
|
|
wsTracker.StartCleanup()
|
|
|
|
// BitTorrent tracker endpoints (public, no auth required)
|
|
r.Handle("/announce", announceHandler).Methods("GET")
|
|
r.Handle("/scrape", scrapeHandler).Methods("GET")
|
|
r.HandleFunc("/tracker", wsTracker.HandleWS).Methods("GET") // WebSocket upgrade
|
|
|
|
log.Printf("Registered BitTorrent tracker endpoints with WebSocket support")
|
|
}
|
|
|
|
// GetGatewayFromRoutes returns a gateway instance for DHT integration
|
|
func GetGatewayFromRoutes(cfg *config.Config, storage *storage.Backend) *Gateway {
|
|
return NewGateway(cfg, storage)
|
|
}
|
|
|
|
// WebTorrentInfoHandler returns WebTorrent-optimized file metadata with WebSocket trackers
|
|
func (g *Gateway) WebTorrentInfoHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get and validate file hash
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Check file access permissions
|
|
requestorPubkey := middleware.GetUserFromContext(r.Context())
|
|
canAccess, err := g.storage.CheckFileAccess(fileHash, requestorPubkey)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Access check failed", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check file access: %v", err))
|
|
return
|
|
}
|
|
if !canAccess {
|
|
g.writeError(w, http.StatusForbidden, "Access denied", ErrorTypeUnauthorized,
|
|
"You do not have permission to access this file")
|
|
return
|
|
}
|
|
|
|
// Get metadata
|
|
metadata, err := g.getMetadata(fileHash)
|
|
if err != nil {
|
|
g.writeErrorResponse(w, ErrFileNotFound, fmt.Sprintf("No file found with hash: %s", fileHash))
|
|
return
|
|
}
|
|
|
|
if metadata == nil || metadata.TorrentInfo == nil {
|
|
g.writeError(w, http.StatusNotFound, "Torrent not available", ErrorTypeNotFound,
|
|
"File does not have torrent information")
|
|
return
|
|
}
|
|
|
|
// Generate WebTorrent-optimized magnet link with WebSocket trackers
|
|
webTorrentMagnet := g.generateWebTorrentMagnet(metadata)
|
|
|
|
// Create WebTorrent-specific response
|
|
response := map[string]interface{}{
|
|
"magnet_uri": webTorrentMagnet,
|
|
"info_hash": metadata.TorrentInfo.InfoHash,
|
|
"name": metadata.FileName,
|
|
"size": metadata.TotalSize,
|
|
"piece_length": g.calculateWebTorrentPieceLength(metadata.TotalSize),
|
|
"streaming_supported": metadata.StreamingInfo != nil && metadata.StreamingInfo.IsVideo,
|
|
"webseed_url": fmt.Sprintf("%s/webseed/%s/", g.publicURL, metadata.FileHash),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// generateWebTorrentMagnet creates a WebTorrent-optimized magnet link
|
|
func (g *Gateway) generateWebTorrentMagnet(metadata *FileMetadata) string {
|
|
if metadata.TorrentInfo == nil {
|
|
return ""
|
|
}
|
|
|
|
// Start with existing magnet
|
|
magnetURI := metadata.TorrentInfo.Magnet
|
|
|
|
// Add WebSocket trackers for WebRTC compatibility
|
|
wsTrackers := []string{
|
|
"wss://tracker.btorrent.xyz",
|
|
"wss://tracker.openwebtorrent.com",
|
|
"wss://tracker.webtorrent.dev",
|
|
fmt.Sprintf("wss://localhost:%d/tracker", g.config.Gateway.Port), // Our WebSocket tracker
|
|
}
|
|
|
|
for _, tracker := range wsTrackers {
|
|
magnetURI += "&tr=" + tracker
|
|
}
|
|
|
|
// Add WebSeed URL
|
|
webSeedURL := fmt.Sprintf("%s/webseed/%s/", g.publicURL, metadata.FileHash)
|
|
magnetURI += "&ws=" + webSeedURL
|
|
|
|
return magnetURI
|
|
}
|
|
|
|
// calculateWebTorrentPieceLength calculates BitTorrent piece length based on file size
|
|
func (g *Gateway) calculateWebTorrentPieceLength(fileSize int64) int64 {
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
|
|
switch {
|
|
case fileSize < 50*MB:
|
|
return 256 * KB
|
|
case fileSize < 500*MB:
|
|
return 512 * KB
|
|
case fileSize < 2*GB:
|
|
return 1 * MB
|
|
default:
|
|
return 2 * MB
|
|
}
|
|
}
|
|
|
|
// getBaseURL returns the base URL for the gateway
|
|
func (g *Gateway) getBaseURL() string {
|
|
// TODO: This should be configurable or detected from request
|
|
// For now, use localhost with the configured port
|
|
return fmt.Sprintf("http://localhost:%d", g.config.Gateway.Port)
|
|
}
|
|
|
|
|
|
// ThumbnailHandler serves video thumbnails
|
|
func (g *Gateway) ThumbnailHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Validate HTTP method
|
|
if err := g.validateHTTPMethod(r, []string{http.MethodGet}); err != nil {
|
|
g.writeErrorResponse(w, ErrMethodNotAllowed, err.Error())
|
|
return
|
|
}
|
|
|
|
// Get file hash from URL parameter
|
|
vars := mux.Vars(r)
|
|
fileHash := vars["hash"]
|
|
|
|
if err := g.validateFileHash(fileHash); err != nil {
|
|
g.writeErrorResponse(w, ErrInvalidFileHash, err.Error())
|
|
return
|
|
}
|
|
|
|
// Build thumbnail path
|
|
thumbnailPath := fmt.Sprintf("%s/%s.jpg", g.config.Nostr.ThumbnailsDir, fileHash)
|
|
|
|
// Check if thumbnail exists
|
|
_, err := os.Stat(thumbnailPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
g.writeError(w, http.StatusNotFound, "Thumbnail not found", ErrorTypeNotFound,
|
|
fmt.Sprintf("Thumbnail for hash %s not found", fileHash))
|
|
return
|
|
}
|
|
g.writeError(w, http.StatusInternalServerError, "Failed to access thumbnail", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to check thumbnail: %v", err))
|
|
return
|
|
}
|
|
|
|
// Open and serve the thumbnail
|
|
file, err := os.Open(thumbnailPath)
|
|
if err != nil {
|
|
g.writeError(w, http.StatusInternalServerError, "Failed to open thumbnail", ErrorTypeInternal,
|
|
fmt.Sprintf("Failed to open thumbnail file: %v", err))
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Set headers
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day
|
|
|
|
// Serve the file
|
|
_, err = io.Copy(w, file)
|
|
if err != nil {
|
|
fmt.Printf("Error serving thumbnail: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// StorageInterface implementation for storage.Backend
|
|
// The storage.Backend already implements StoreNostrEvents, so it satisfies the interface |