1package main
2
3import (
4 "context"
5 "log"
6 "net/http"
7 "os"
8 "os/signal"
9 "path/filepath"
10 "strings"
11 "syscall"
12 "time"
13
14 "github.com/go-chi/chi/v5"
15 "github.com/go-chi/chi/v5/middleware"
16 "github.com/go-chi/cors"
17 "github.com/joho/godotenv"
18
19 "margin.at/internal/api"
20 "margin.at/internal/db"
21 "margin.at/internal/firehose"
22 "margin.at/internal/oauth"
23)
24
25func main() {
26 godotenv.Load("../.env", ".env")
27
28 database, err := db.New(getEnv("DATABASE_URL", "margin.db"))
29 if err != nil {
30 log.Fatalf("Failed to connect to database: %v", err)
31 }
32 defer database.Close()
33
34 if err := database.Migrate(); err != nil {
35 log.Fatalf("Failed to run migrations: %v", err)
36 }
37
38 oauthHandler, err := oauth.NewHandler(database)
39 if err != nil {
40 log.Fatalf("Failed to initialize OAuth: %v", err)
41 }
42
43 ingester := firehose.NewIngester(database)
44 firehose.RelayURL = getEnv("BLOCK_RELAY_URL", "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos")
45 log.Printf("Firehose URL: %s", firehose.RelayURL)
46
47 go func() {
48 if err := ingester.Start(context.Background()); err != nil {
49 log.Printf("Firehose ingester error: %v", err)
50 }
51 }()
52
53 r := chi.NewRouter()
54
55 r.Use(middleware.Logger)
56 r.Use(middleware.Recoverer)
57 r.Use(middleware.RequestID)
58 r.Use(middleware.RealIP)
59 r.Use(middleware.Timeout(60 * time.Second))
60 r.Use(middleware.Throttle(100))
61
62 r.Use(cors.Handler(cors.Options{
63 AllowedOrigins: []string{"https://*", "http://*", "chrome-extension://*"},
64 AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
65 AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Session-Token"},
66 ExposedHeaders: []string{"Link"},
67 AllowCredentials: true,
68 MaxAge: 300,
69 }))
70
71 tokenRefresher := api.NewTokenRefresher(database, oauthHandler.GetPrivateKey())
72 annotationSvc := api.NewAnnotationService(database, tokenRefresher)
73
74 handler := api.NewHandler(database, annotationSvc, tokenRefresher)
75 handler.RegisterRoutes(r)
76
77 r.Post("/api/annotations", annotationSvc.CreateAnnotation)
78 r.Put("/api/annotations", annotationSvc.UpdateAnnotation)
79 r.Delete("/api/annotations", annotationSvc.DeleteAnnotation)
80 r.Post("/api/annotations/like", annotationSvc.LikeAnnotation)
81 r.Delete("/api/annotations/like", annotationSvc.UnlikeAnnotation)
82 r.Post("/api/annotations/reply", annotationSvc.CreateReply)
83 r.Delete("/api/annotations/reply", annotationSvc.DeleteReply)
84 r.Post("/api/highlights", annotationSvc.CreateHighlight)
85 r.Put("/api/highlights", annotationSvc.UpdateHighlight)
86 r.Delete("/api/highlights", annotationSvc.DeleteHighlight)
87 r.Post("/api/bookmarks", annotationSvc.CreateBookmark)
88 r.Put("/api/bookmarks", annotationSvc.UpdateBookmark)
89 r.Delete("/api/bookmarks", annotationSvc.DeleteBookmark)
90
91 r.Get("/auth/login", oauthHandler.HandleLogin)
92 r.Post("/auth/start", oauthHandler.HandleStart)
93 r.Get("/auth/callback", oauthHandler.HandleCallback)
94 r.Post("/auth/logout", oauthHandler.HandleLogout)
95 r.Get("/auth/session", oauthHandler.HandleSession)
96 r.Get("/client-metadata.json", oauthHandler.HandleClientMetadata)
97 r.Get("/jwks.json", oauthHandler.HandleJWKS)
98
99 ogHandler := api.NewOGHandler(database)
100 r.Get("/og-image", ogHandler.HandleOGImage)
101 r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage)
102 r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage)
103 r.Get("/{handle}/annotation/{rkey}", ogHandler.HandleAnnotationPage)
104 r.Get("/{handle}/highlight/{rkey}", ogHandler.HandleAnnotationPage)
105 r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage)
106
107 r.Get("/api/tags/trending", handler.HandleGetTrendingTags)
108
109 r.Get("/collection/{uri}", ogHandler.HandleCollectionPage)
110 r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage)
111
112 staticDir := getEnv("STATIC_DIR", "../web/dist")
113 serveStatic(r, staticDir)
114
115 port := getEnv("PORT", "8080")
116 server := &http.Server{
117 Addr: ":" + port,
118 Handler: r,
119 }
120
121 baseURL := getEnv("BASE_URL", "http://localhost:"+port)
122 go func() {
123 log.Printf("🚀 Margin server running on %s", baseURL)
124 log.Printf("📝 App: %s", baseURL)
125 log.Printf("🔗 API: %s/api/annotations", baseURL)
126 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
127 log.Fatalf("Server error: %v", err)
128 }
129 }()
130
131 quit := make(chan os.Signal, 1)
132 signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
133 <-quit
134
135 log.Println("Shutting down server...")
136 ingester.Stop()
137
138 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
139 defer cancel()
140
141 if err := server.Shutdown(ctx); err != nil {
142 log.Fatalf("Server forced to shutdown: %v", err)
143 }
144
145 log.Println("Server exited")
146}
147
148func getEnv(key, fallback string) string {
149 if value, ok := os.LookupEnv(key); ok {
150 return value
151 }
152 return fallback
153}
154
155func serveStatic(r chi.Router, staticDir string) {
156 absPath, err := filepath.Abs(staticDir)
157 if err != nil {
158 log.Printf("Warning: Could not resolve static directory: %v", err)
159 return
160 }
161
162 if _, err := os.Stat(absPath); os.IsNotExist(err) {
163 log.Printf("Warning: Static directory does not exist: %s", absPath)
164 log.Printf("Run 'npm run build' in the web directory first")
165 return
166 }
167
168 log.Printf("📂 Serving static files from: %s", absPath)
169
170 fileServer := http.FileServer(http.Dir(absPath))
171
172 r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
173 path := req.URL.Path
174
175 if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/auth/") {
176 http.NotFound(w, req)
177 return
178 }
179
180 filePath := filepath.Join(absPath, path)
181 if _, err := os.Stat(filePath); err == nil {
182 fileServer.ServeHTTP(w, req)
183 return
184 }
185
186 lastSlash := strings.LastIndex(path, "/")
187 lastSegment := path
188 if lastSlash >= 0 {
189 lastSegment = path[lastSlash+1:]
190 }
191
192 staticExts := []string{".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".map"}
193 for _, ext := range staticExts {
194 if strings.HasSuffix(lastSegment, ext) {
195 http.NotFound(w, req)
196 return
197 }
198 }
199
200 http.ServeFile(w, req, filepath.Join(absPath, "index.html"))
201 })
202}