diff --git a/.gitignore b/.gitignore index bffd599..2f66142 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Binaries torrentGateway -gateway -main +/gateway +/main *.exe *.exe~ *.dll diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go new file mode 100644 index 0000000..33e70a8 --- /dev/null +++ b/cmd/gateway/main.go @@ -0,0 +1,500 @@ +package main + +import ( + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "git.sovbit.dev/enki/torrentGateway/internal/api" + "git.sovbit.dev/enki/torrentGateway/internal/blossom" + "git.sovbit.dev/enki/torrentGateway/internal/config" + "git.sovbit.dev/enki/torrentGateway/internal/dht" + "git.sovbit.dev/enki/torrentGateway/internal/middleware" + "git.sovbit.dev/enki/torrentGateway/internal/storage" + "git.sovbit.dev/enki/torrentGateway/internal/web" + "github.com/gorilla/mux" +) + +func main() { + // Parse command line flags + configPath := flag.String("config", "configs/config.yaml", "Path to configuration file") + mode := flag.String("mode", "", "Override mode (unified, gateway-only, blossom-only, dht-only)") + flag.Parse() + + // Load configuration + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Override mode if specified + if *mode != "" { + cfg.Mode = *mode + } + + log.Printf("Starting Blossom-BitTorrent Gateway in %s mode", cfg.Mode) + + // Initialize shared storage backend + var storageBackend *storage.Backend + if cfg.IsServiceEnabled("gateway") || cfg.IsServiceEnabled("blossom") { + storageBackend, err = storage.NewBackend( + cfg.Storage.MetadataDB, + cfg.Storage.ChunkStorage, + cfg.Storage.BlobStorage, + cfg.Storage.ChunkSize, + cfg, + ) + if err != nil { + log.Fatalf("Failed to initialize storage backend: %v", err) + } + defer storageBackend.Close() + + // Start automated cleanup service if enabled + if cfg.Admin.AutoCleanup { + log.Printf("Starting automated cleanup service") + go startCleanupService(storageBackend, cfg) + } + } + + // Track running services + var wg sync.WaitGroup + var servers []ServiceManager + + // Declare gateway instance for DHT integration + var gatewayInstance *api.Gateway + + // Start Gateway service + if cfg.IsServiceEnabled("gateway") { + log.Printf("Starting Gateway service on port %d", cfg.Gateway.Port) + + r := mux.NewRouter() + + // Apply security middleware to ALL routes (not just API) + r.Use(middleware.SecurityHeaders) + r.Use(middleware.InputSanitization) + maxUploadSize, _ := cfg.GetMaxUploadSizeBytes() + r.Use(middleware.RequestSizeLimit(maxUploadSize)) + r.Use(middleware.AntiCrawler) + r.Use(middleware.CORS) + + // Register API routes with /api prefix + apiRouter := r.PathPrefix("/api").Subrouter() + + // Register tracker routes on main router (no /api prefix for BitTorrent compatibility) + api.RegisterTrackerRoutes(r, cfg, storageBackend) + + // Register main API routes and get gateway instance + gatewayInstance = api.RegisterRoutes(apiRouter, cfg, storageBackend) + + // Serve static files + webFS := web.GetFS() + staticFS, _ := fs.Sub(webFS, "static") + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + // Serve main pages + r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + data, err := web.GetFile("index.html") + if err != nil { + http.Error(w, "Page not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(data) + }) + + r.HandleFunc("/player.html", func(w http.ResponseWriter, r *http.Request) { + data, err := web.GetFile("player.html") + if err != nil { + http.Error(w, "Page not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(data) + }) + + r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { + data, err := web.GetFile("admin.html") + if err != nil { + http.Error(w, "Page not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html") + w.Write(data) + }) + + gatewayServer := &http.Server{ + Addr: fmt.Sprintf("0.0.0.0:%d", cfg.Gateway.Port), + Handler: r, + } + + servers = append(servers, &HTTPServiceManager{ + name: "Gateway", + server: gatewayServer, + }) + + wg.Add(1) + go func() { + defer wg.Done() + if err := gatewayServer.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("Gateway server error: %v", err) + } + }() + } + + // Start Blossom server + if cfg.IsServiceEnabled("blossom") { + log.Printf("Starting Blossom server on port %d", cfg.BlossomServer.Port) + + blossomServer := blossom.NewServer(storageBackend, &cfg.BlossomServer, cfg) + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.BlossomServer.Port), + Handler: blossomServer, + } + + servers = append(servers, &HTTPServiceManager{ + name: "Blossom", + server: httpServer, + }) + + wg.Add(1) + go func() { + defer wg.Done() + if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("Blossom server error: %v", err) + } + }() + } + + // Start DHT node + if cfg.IsServiceEnabled("dht") { + log.Printf("Starting DHT node on port %d", cfg.DHT.Port) + + dhtNode, err := dht.NewDHT(&cfg.DHT) + if err != nil { + log.Fatalf("Failed to create DHT node: %v", err) + } + + // Create DHT bootstrap manager (only if gateway is enabled) + var dhtBootstrap *dht.DHTBootstrap + if gatewayInstance != nil { + dhtBootstrap = dht.NewDHTBootstrap(dhtNode, gatewayInstance, &cfg.DHT) + } + + servers = append(servers, &DHTServiceManager{ + name: "DHT", + node: dhtNode, + bootstrap: dhtBootstrap, + }) + + wg.Add(1) + go func() { + defer wg.Done() + if err := dhtNode.Start(); err != nil { + log.Printf("DHT node error: %v", err) + return + } + + // Initialize bootstrap functionality after DHT starts (only if gateway is enabled) + if dhtBootstrap != nil { + if err := dhtBootstrap.Initialize(); err != nil { + log.Printf("DHT bootstrap error: %v", err) + } + + // Connect DHT bootstrap to gateway + gatewayInstance.SetDHTBootstrap(dhtBootstrap) + } + }() + } + + // Handle shutdown signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Wait for shutdown signal + <-sigCh + log.Println("Shutdown signal received, stopping services...") + + // Stop all services + for _, srv := range servers { + if err := srv.Stop(); err != nil { + log.Printf("Error stopping %s: %v", srv.Name(), err) + } + } + + // Wait for all services to stop + wg.Wait() + log.Println("All services stopped") +} + +// ServiceManager interface for managing different types of services +type ServiceManager interface { + Stop() error + Name() string +} + +// HTTPServiceManager manages HTTP servers +type HTTPServiceManager struct { + name string + server *http.Server +} + +func (h *HTTPServiceManager) Stop() error { + log.Printf("Stopping %s server", h.name) + return h.server.Close() +} + +func (h *HTTPServiceManager) Name() string { + return h.name +} + +// DHTServiceManager manages DHT nodes +type DHTServiceManager struct { + name string + node *dht.DHT + bootstrap *dht.DHTBootstrap +} + +func (d *DHTServiceManager) Stop() error { + log.Printf("Stopping %s node", d.name) + if d.bootstrap != nil { + d.bootstrap.Stop() + } + return d.node.Stop() +} + +func (d *DHTServiceManager) Name() string { + return d.name +} + +// startCleanupService starts the automated cleanup service +func startCleanupService(storageBackend *storage.Backend, cfg *config.Config) { + // Parse cleanup age + cleanupAge, err := parseDuration(cfg.Admin.CleanupAge) + if err != nil { + log.Printf("Invalid cleanup_age format '%s': %v", cfg.Admin.CleanupAge, err) + return + } + + // Default cleanup interval to 1 hour if not set + cleanupInterval := 1 * time.Hour + + log.Printf("Cleanup service started: cleaning files older than %v every %v", cleanupAge, cleanupInterval) + + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + // Run initial cleanup after 1 minute + initialDelay := time.NewTimer(1 * time.Minute) + defer initialDelay.Stop() + + for { + select { + case <-initialDelay.C: + runCleanupCycle(storageBackend, cfg, cleanupAge) + case <-ticker.C: + runCleanupCycle(storageBackend, cfg, cleanupAge) + } + } +} + +// runCleanupCycle performs a full cleanup cycle +func runCleanupCycle(storageBackend *storage.Backend, cfg *config.Config, cleanupAge time.Duration) { + log.Printf("Starting cleanup cycle...") + start := time.Now() + + // Clean old files + if fileResults, err := cleanupOldFiles(storageBackend, cleanupAge); err != nil { + log.Printf("Error cleaning old files: %v", err) + } else { + log.Printf("Cleaned old files: %s", formatCleanupResults(fileResults)) + } + + // Clean orphaned chunks + if chunkResults, err := cleanupOrphanedChunks(storageBackend, cfg); err != nil { + log.Printf("Error cleaning orphaned chunks: %v", err) + } else { + log.Printf("Cleaned orphaned chunks: %s", formatCleanupResults(chunkResults)) + } + + // Clean inactive users (users with no activity for 180 days) + if userResults, err := cleanupInactiveUsers(storageBackend, 180); err != nil { + log.Printf("Error cleaning inactive users: %v", err) + } else { + log.Printf("Cleaned inactive users: %s", formatCleanupResults(userResults)) + } + + log.Printf("Cleanup cycle completed in %v", time.Since(start)) +} + +// cleanupOldFiles removes files older than the specified duration +func cleanupOldFiles(storageBackend *storage.Backend, olderThan time.Duration) (map[string]interface{}, error) { + cutoffTime := time.Now().Add(-olderThan) + + rows, err := storageBackend.GetDB().Query(` + SELECT hash, original_name, storage_type, created_at + FROM files + WHERE created_at < ? OR last_access < ? + `, cutoffTime, cutoffTime) + if err != nil { + return nil, err + } + defer rows.Close() + + var deletedCount int + var totalSize int64 + + for rows.Next() { + var hash, originalName, storageType string + var createdAt time.Time + + if err := rows.Scan(&hash, &originalName, &storageType, &createdAt); err != nil { + continue + } + + // Get file size before deletion + if metadata, err := storageBackend.GetFileMetadata(hash); err == nil && metadata != nil { + totalSize += metadata.Size + } + + // Delete the file + if err := storageBackend.AdminDeleteFile(hash); err != nil { + log.Printf("Error deleting file %s: %v", hash, err) + continue + } + + deletedCount++ + log.Printf("Deleted old file: %s (%s)", originalName, hash[:8]) + } + + return map[string]interface{}{ + "deleted_count": deletedCount, + "total_size": totalSize, + }, nil +} + +// cleanupOrphanedChunks removes chunks that don't belong to any file +func cleanupOrphanedChunks(storageBackend *storage.Backend, cfg *config.Config) (map[string]interface{}, error) { + rows, err := storageBackend.GetDB().Query(` + SELECT c.chunk_hash, c.size + FROM chunks c + LEFT JOIN files f ON c.file_hash = f.hash + WHERE f.hash IS NULL + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var deletedCount int + var totalSize int64 + + for rows.Next() { + var chunkHash string + var size int64 + + if err := rows.Scan(&chunkHash, &size); err != nil { + continue + } + + // Delete chunk record from database + if _, err := storageBackend.GetDB().Exec("DELETE FROM chunks WHERE chunk_hash = ?", chunkHash); err != nil { + log.Printf("Error deleting chunk record %s: %v", chunkHash, err) + continue + } + + // Delete chunk file from disk + chunkPath := fmt.Sprintf("%s/%s", cfg.Storage.ChunkStorage, chunkHash) + if err := os.Remove(chunkPath); err != nil { + log.Printf("Error deleting chunk file %s: %v", chunkHash, err) + } + + deletedCount++ + totalSize += size + } + + return map[string]interface{}{ + "deleted_count": deletedCount, + "total_size": totalSize, + }, nil +} + +// cleanupInactiveUsers removes users with no activity for specified days +func cleanupInactiveUsers(storageBackend *storage.Backend, days int) (map[string]interface{}, error) { + cutoffTime := time.Now().AddDate(0, 0, -days) + + result, err := storageBackend.GetDB().Exec(` + DELETE FROM users + WHERE last_activity < ? AND pubkey NOT IN ( + SELECT DISTINCT owner_pubkey FROM files WHERE owner_pubkey IS NOT NULL + ) + `, cutoffTime) + if err != nil { + return nil, err + } + + deletedCount, _ := result.RowsAffected() + + return map[string]interface{}{ + "deleted_count": deletedCount, + }, nil +} + +// formatCleanupResults formats cleanup results for logging +func formatCleanupResults(results map[string]interface{}) string { + deletedCount := results["deleted_count"].(int) + if totalSize, hasSize := results["total_size"]; hasSize { + return fmt.Sprintf("%d items (%s)", deletedCount, formatBytes(totalSize.(int64))) + } + return fmt.Sprintf("%d items", deletedCount) +} + +// formatBytes formats byte count as human readable string +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// parseDuration parses duration strings like "90d", "30d", "24h" +func parseDuration(s string) (time.Duration, error) { + if len(s) < 2 { + return 0, fmt.Errorf("invalid duration format") + } + + unit := s[len(s)-1:] + valueStr := s[:len(s)-1] + value, err := strconv.Atoi(valueStr) + if err != nil { + return 0, err + } + + switch strings.ToLower(unit) { + case "d": + return time.Duration(value) * 24 * time.Hour, nil + case "h": + return time.Duration(value) * time.Hour, nil + case "m": + return time.Duration(value) * time.Minute, nil + case "s": + return time.Duration(value) * time.Second, nil + default: + return time.ParseDuration(s) + } +} diff --git a/scripts/install_native.sh b/scripts/install_native.sh index d5c7a8e..396d69a 100755 --- a/scripts/install_native.sh +++ b/scripts/install_native.sh @@ -163,7 +163,6 @@ cd "$PROJECT_ROOT" echo "📦 Installing system dependencies..." apt-get update apt-get install -y \ - golang-go \ git \ sqlite3 \ nginx \ @@ -181,14 +180,78 @@ apt-get install -y \ wget \ ffmpeg -# Verify Go installation +# Install latest stable Go version +echo "📦 Checking for latest Go version..." +LATEST_GO_VERSION=$(curl -s https://golang.org/VERSION?m=text 2>/dev/null) +if [ -z "$LATEST_GO_VERSION" ]; then + echo "❌ Failed to get latest Go version from golang.org" + echo "📦 Trying alternative method..." + LATEST_GO_VERSION=$(curl -s "https://api.github.com/repos/golang/go/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' 2>/dev/null) +fi + +if [ -z "$LATEST_GO_VERSION" ]; then + echo "❌ Could not determine latest Go version, using system package manager" + apt-get install -y golang-go + GO_VERSION=$(go version | grep -o 'go[0-9.]*' | head -1) + echo "✅ Go $GO_VERSION installed from package manager" +else + echo "📦 Latest Go version: $LATEST_GO_VERSION" + + # Check if we already have this version installed + if command -v go &> /dev/null; then + CURRENT_VERSION=$(go version | grep -o 'go[0-9.]*' | head -1) + if [ "$CURRENT_VERSION" = "$LATEST_GO_VERSION" ]; then + echo "✅ Go $CURRENT_VERSION already installed and up to date" + else + echo "📦 Upgrading Go from $CURRENT_VERSION to $LATEST_GO_VERSION..." + fi + else + echo "📦 Installing Go $LATEST_GO_VERSION..." + fi + + # Download and install Go if needed + if ! command -v go &> /dev/null || [ "$(go version | grep -o 'go[0-9.]*' | head -1)" != "$LATEST_GO_VERSION" ]; then + cd /tmp + wget -q "https://golang.org/dl/${LATEST_GO_VERSION}.linux-amd64.tar.gz" + if [ $? -ne 0 ]; then + echo "❌ Failed to download Go $LATEST_GO_VERSION" + echo "📦 Falling back to system package manager" + apt-get install -y golang-go + else + # Remove old Go installation if it exists + rm -rf /usr/local/go + + # Extract new Go installation + tar -C /usr/local -xzf "${LATEST_GO_VERSION}.linux-amd64.tar.gz" + + # Add Go to PATH for current session and permanently + export PATH=/usr/local/go/bin:$PATH + + # Add to system-wide profile + if ! grep -q "/usr/local/go/bin" /etc/profile; then + echo 'export PATH=/usr/local/go/bin:$PATH' >> /etc/profile + fi + + # Add to current shell + if ! echo $PATH | grep -q "/usr/local/go/bin"; then + export PATH=/usr/local/go/bin:$PATH + fi + + echo "✅ Go $LATEST_GO_VERSION installed" + fi + + cd "$PROJECT_ROOT" + fi +fi + +# Final verification if ! command -v go &> /dev/null; then echo "❌ Go installation failed" exit 1 fi GO_VERSION=$(go version | grep -o 'go[0-9.]*' | head -1) -echo "✅ Go $GO_VERSION installed" +echo "✅ Go $GO_VERSION ready" # Step 2: Build application if [ "$SKIP_BUILD" = false ]; then