An open source supporter broker powered by high-fives.
high-five.atprotofans.com/
1package main
2
3import (
4 "context"
5 "flag"
6 "log"
7 "net/http"
8 "os"
9 "os/signal"
10 "strings"
11 "syscall"
12 "time"
13
14 "github.com/go-chi/chi/v5"
15 "github.com/go-chi/chi/v5/middleware"
16
17 "github.com/ngerakines/high-five-app/internal/handlers"
18 "github.com/ngerakines/high-five-app/internal/broker"
19 "github.com/ngerakines/high-five-app/internal/oauth"
20 "github.com/ngerakines/high-five-app/internal/storage"
21 "github.com/ngerakines/high-five-app/internal/websocket"
22)
23
24// getEnv returns the environment variable value or a default if not set.
25func getEnv(key, defaultValue string) string {
26 if value := os.Getenv(key); value != "" {
27 return value
28 }
29 return defaultValue
30}
31
32// securityHeaders adds security-related HTTP headers to all responses.
33func securityHeaders(next http.Handler) http.Handler {
34 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35 // Prevent MIME type sniffing
36 w.Header().Set("X-Content-Type-Options", "nosniff")
37 // Prevent clickjacking
38 w.Header().Set("X-Frame-Options", "DENY")
39 // Control referrer information
40 w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
41 // Permissions policy - restrict unnecessary APIs
42 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
43
44 next.ServeHTTP(w, r)
45 })
46}
47
48func main() {
49 // Command line flags with environment variable fallbacks
50 addr := flag.String("addr", getEnv("LISTEN_ADDR", ":8080"), "HTTP server address")
51 redisURL := flag.String("redis", getEnv("REDIS_URL", "redis://localhost:6379"), "Redis URL")
52 baseURL := flag.String("base-url", getEnv("BASE_URL", "http://localhost:8080"), "Base URL for OAuth callbacks")
53 templateDir := flag.String("templates", getEnv("TEMPLATE_DIR", "templates"), "Template directory")
54 flag.Parse()
55
56 // Read broker credentials from environment variables
57 brokerID := os.Getenv("BROKER_ID")
58 brokerAppPassword := os.Getenv("BROKER_APP_PASSWORD")
59 dryRun := os.Getenv("DRY_RUN") == "true"
60
61 if brokerID == "" {
62 log.Fatal("BROKER_ID environment variable is required")
63 }
64 if brokerAppPassword == "" {
65 log.Fatal("BROKER_APP_PASSWORD environment variable is required")
66 }
67 if dryRun {
68 log.Println("════════════════════════════════════════════════════════════════")
69 log.Println("DRY-RUN MODE ENABLED")
70 log.Println("No records will be created in any PDS")
71 log.Println("All record creation requests will be logged instead")
72 log.Println("════════════════════════════════════════════════════════════════")
73 }
74
75 // Initialize Redis store
76 store, err := storage.NewStore(*redisURL)
77 if err != nil {
78 log.Fatalf("Failed to connect to Redis: %v", err)
79 }
80 defer store.Close()
81
82 // Initialize broker service and authenticate
83 brokerService := broker.NewService(store, brokerID, brokerAppPassword, dryRun)
84
85 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
86 if err := brokerService.Authenticate(ctx); err != nil {
87 cancel()
88 log.Fatalf("Failed to authenticate broker: %v", err)
89 }
90 cancel()
91
92 brokerDID := brokerService.GetDID()
93 log.Printf("Broker authenticated: %s (DID: %s)", brokerID, brokerDID)
94
95 // Initialize OAuth service
96 oauthConfig := &oauth.Config{
97 ClientID: *baseURL + "/oauth-client-metadata.json",
98 RedirectURI: *baseURL + "/oauth/callback",
99 Scopes: []string{
100 "atproto",
101 "repo:com.atprotofans.high-five.support",
102 "repo:com.atprotofans.high-five.supportProof",
103 "repo:com.atprotofans.high-five.highFive",
104 "repo:com.atprotofans.high-five.highFiveProof",
105 "repo:app.bsky.feed.post",
106 "repo:app.bsky.actor.status",
107 },
108 }
109 oauthService := oauth.NewService(oauthConfig, store, dryRun)
110
111 // Initialize WebSocket hub
112 hub := websocket.NewHub()
113 go hub.Run()
114
115 // Initialize handlers
116 highFiveHandler := handlers.NewHighFiveHandler(store, oauthService, hub, brokerService, *baseURL)
117
118 // Determine if we should use secure cookies (HTTPS)
119 secureMode := strings.HasPrefix(*baseURL, "https://")
120
121 httpHandler, err := handlers.NewHTTPHandler(store, oauthService, hub, highFiveHandler, *templateDir, secureMode)
122 if err != nil {
123 log.Fatalf("Failed to initialize HTTP handler: %v", err)
124 }
125
126 // Set up router
127 r := chi.NewRouter()
128
129 // Middleware
130 r.Use(middleware.Logger)
131 r.Use(middleware.Recoverer)
132 r.Use(middleware.Compress(5))
133 r.Use(middleware.Timeout(60 * time.Second))
134 r.Use(securityHeaders)
135
136 // Mount routes
137 r.Mount("/", httpHandler.Routes())
138
139 // Serve static files
140 r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
141
142 // Create server
143 server := &http.Server{
144 Addr: *addr,
145 Handler: r,
146 ReadTimeout: 15 * time.Second,
147 WriteTimeout: 15 * time.Second,
148 IdleTimeout: 60 * time.Second,
149 }
150
151 // Start server in goroutine
152 go func() {
153 log.Printf("Starting server on %s", *addr)
154 log.Printf("Base URL: %s", *baseURL)
155 log.Printf("Broker DID: %s", brokerDID)
156
157 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
158 log.Fatalf("Server error: %v", err)
159 }
160 }()
161
162 // Wait for interrupt signal
163 quit := make(chan os.Signal, 1)
164 signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
165 <-quit
166
167 log.Println("Shutting down server...")
168
169 // Give outstanding requests 30 seconds to complete
170 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
171 defer shutdownCancel()
172
173 if err := server.Shutdown(shutdownCtx); err != nil {
174 log.Fatalf("Server shutdown error: %v", err)
175 }
176
177 log.Println("Server stopped")
178}