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