package main import ( "context" "flag" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/ngerakines/high-five-app/internal/handlers" "github.com/ngerakines/high-five-app/internal/broker" "github.com/ngerakines/high-five-app/internal/oauth" "github.com/ngerakines/high-five-app/internal/storage" "github.com/ngerakines/high-five-app/internal/websocket" ) // getEnv returns the environment variable value or a default if not set. func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // securityHeaders adds security-related HTTP headers to all responses. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Prevent MIME type sniffing w.Header().Set("X-Content-Type-Options", "nosniff") // Prevent clickjacking w.Header().Set("X-Frame-Options", "DENY") // Control referrer information w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") // Permissions policy - restrict unnecessary APIs w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") next.ServeHTTP(w, r) }) } func main() { // Command line flags with environment variable fallbacks addr := flag.String("addr", getEnv("LISTEN_ADDR", ":8080"), "HTTP server address") redisURL := flag.String("redis", getEnv("REDIS_URL", "redis://localhost:6379"), "Redis URL") baseURL := flag.String("base-url", getEnv("BASE_URL", "http://localhost:8080"), "Base URL for OAuth callbacks") templateDir := flag.String("templates", getEnv("TEMPLATE_DIR", "templates"), "Template directory") flag.Parse() // Read broker credentials from environment variables brokerID := os.Getenv("BROKER_ID") brokerAppPassword := os.Getenv("BROKER_APP_PASSWORD") dryRun := os.Getenv("DRY_RUN") == "true" if brokerID == "" { log.Fatal("BROKER_ID environment variable is required") } if brokerAppPassword == "" { log.Fatal("BROKER_APP_PASSWORD environment variable is required") } if dryRun { log.Println("════════════════════════════════════════════════════════════════") log.Println("DRY-RUN MODE ENABLED") log.Println("No records will be created in any PDS") log.Println("All record creation requests will be logged instead") log.Println("════════════════════════════════════════════════════════════════") } // Initialize Redis store store, err := storage.NewStore(*redisURL) if err != nil { log.Fatalf("Failed to connect to Redis: %v", err) } defer store.Close() // Initialize broker service and authenticate brokerService := broker.NewService(store, brokerID, brokerAppPassword, dryRun) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) if err := brokerService.Authenticate(ctx); err != nil { cancel() log.Fatalf("Failed to authenticate broker: %v", err) } cancel() brokerDID := brokerService.GetDID() log.Printf("Broker authenticated: %s (DID: %s)", brokerID, brokerDID) // Initialize OAuth service oauthConfig := &oauth.Config{ ClientID: *baseURL + "/oauth-client-metadata.json", RedirectURI: *baseURL + "/oauth/callback", Scopes: []string{ "atproto", "repo:com.atprotofans.high-five.support", "repo:com.atprotofans.high-five.supportProof", "repo:com.atprotofans.high-five.highFive", "repo:com.atprotofans.high-five.highFiveProof", "repo:app.bsky.feed.post", "repo:app.bsky.actor.status", }, } oauthService := oauth.NewService(oauthConfig, store, dryRun) // Initialize WebSocket hub hub := websocket.NewHub() go hub.Run() // Initialize handlers highFiveHandler := handlers.NewHighFiveHandler(store, oauthService, hub, brokerService, *baseURL) // Determine if we should use secure cookies (HTTPS) secureMode := strings.HasPrefix(*baseURL, "https://") httpHandler, err := handlers.NewHTTPHandler(store, oauthService, hub, highFiveHandler, *templateDir, secureMode) if err != nil { log.Fatalf("Failed to initialize HTTP handler: %v", err) } // Set up router r := chi.NewRouter() // Middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Compress(5)) r.Use(middleware.Timeout(60 * time.Second)) r.Use(securityHeaders) // Mount routes r.Mount("/", httpHandler.Routes()) // Serve static files r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) // Create server server := &http.Server{ Addr: *addr, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } // Start server in goroutine go func() { log.Printf("Starting server on %s", *addr) log.Printf("Base URL: %s", *baseURL) log.Printf("Broker DID: %s", brokerDID) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server error: %v", err) } }() // Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Println("Shutting down server...") // Give outstanding requests 30 seconds to complete shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Fatalf("Server shutdown error: %v", err) } log.Println("Server stopped") }