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