Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 6.1 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 "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}