The codebase that powers boop.cat
boop.cat
1// Copyright 2025 boop.cat
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5package main
6
7import (
8 "fmt"
9 "net/http"
10 "os"
11 "path/filepath"
12 "time"
13
14 "github.com/go-chi/chi/v5"
15 chimiddleware "github.com/go-chi/chi/v5/middleware"
16 "github.com/go-chi/cors"
17 "github.com/joho/godotenv"
18
19 "boop-cat/config"
20 "boop-cat/db"
21 "boop-cat/handlers"
22 "boop-cat/lib"
23 "boop-cat/middleware"
24 "boop-cat/oauth"
25)
26
27func main() {
28
29 _ = godotenv.Load()
30 _ = godotenv.Load("../.env")
31
32 lib.StartDMCAMonitor()
33
34 cfg := config.Load()
35
36 if cfg.SessionSecret == "" {
37 fmt.Fprintln(os.Stderr, "Missing SESSION_SECRET. Generate one: openssl rand -base64 32")
38 os.Exit(1)
39 }
40
41 database, err := db.GetDB(cfg.DBPath)
42 if err != nil {
43 fmt.Fprintf(os.Stderr, "Failed to initialize database: %v\n", err)
44 os.Exit(1)
45 }
46
47 r := chi.NewRouter()
48
49 middleware.InitSessionStore(cfg.SessionSecret, cfg.CookieSecure)
50
51 oauth.InitProviders(cfg.SessionSecret)
52
53 r.Use(chimiddleware.Logger)
54 r.Use(chimiddleware.Recoverer)
55 r.Use(chimiddleware.RealIP)
56 r.Use(middleware.WithUser(database))
57 r.Use(middleware.RateLimit(100, 60*time.Second))
58
59 r.Use(cors.Handler(cors.Options{
60 AllowedOrigins: []string{"*"},
61 AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
62 AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
63 ExposedHeaders: []string{"Link"},
64 AllowCredentials: true,
65 MaxAge: 300,
66 }))
67
68 r.Get("/api/health", func(w http.ResponseWriter, r *http.Request) {
69 w.Header().Set("Content-Type", "application/json")
70 w.Write([]byte(`{"ok":true,"backend":"go"}`))
71 })
72
73 r.Get("/api/config", func(w http.ResponseWriter, r *http.Request) {
74 w.Header().Set("Content-Type", "application/json")
75 fmt.Fprintf(w, `{"deliveryMode":"%s","edgeRootDomain":"%s"}`,
76 cfg.DeliveryMode, cfg.EdgeRootDomain)
77 })
78
79 deployHandler := handlers.NewDeployHandler(database)
80
81 authHandler := handlers.NewAuthHandler(database, deployHandler.Engine)
82 r.Mount("/api/auth", authHandler.Routes())
83 r.Route("/auth", func(r chi.Router) {
84 authHandler.MountOAuthRoutes(r)
85 })
86 r.Get("/api/github/repos", authHandler.GetGitHubRepos)
87 r.Get("/github/installed", authHandler.GitHubInstalled)
88
89 apiKeysHandler := handlers.NewAPIKeysHandler(database)
90 r.Mount("/api/api-keys", apiKeysHandler.Routes())
91
92 sitesHandler := handlers.NewSitesHandler(database, deployHandler.Engine)
93
94 r.Mount("/api/account", handlers.NewAccountHandler(database).Routes())
95
96 cdHandler := handlers.NewCustomDomainHandler(database, deployHandler.Engine)
97
98 r.Route("/api/sites", func(r chi.Router) {
99 r.Use(middleware.RequireLogin)
100
101 r.Get("/", sitesHandler.ListSites)
102 r.Post("/", sitesHandler.CreateSite)
103
104 r.Route("/{siteId}", func(r chi.Router) {
105 r.Patch("/", sitesHandler.UpdateSiteEnv)
106 r.Patch("/settings", sitesHandler.UpdateSiteSettings)
107 r.Put("/settings", sitesHandler.UpdateSiteSettings)
108 r.Post("/settings", sitesHandler.UpdateSiteSettings)
109 r.Delete("/", sitesHandler.DeleteSite)
110
111 r.Post("/deploy", deployHandler.TriggerDeploy)
112 r.Get("/deployments", deployHandler.ListDeployments)
113
114 r.Get("/custom-domains", cdHandler.ListCustomDomains)
115 r.Post("/custom-domains", cdHandler.CreateCustomDomain)
116 r.Delete("/custom-domains/{id}", cdHandler.DeleteCustomDomain)
117 r.Post("/custom-domains/{id}/poll", cdHandler.PollCustomDomain)
118 })
119
120 r.Post("/preview", deployHandler.PreviewSite)
121 })
122
123 r.Route("/api/deployments/{id}", func(r chi.Router) {
124 r.Use(middleware.RequireLogin)
125 r.Get("/", deployHandler.GetDeployment)
126 r.Get("/logs", deployHandler.GetDeploymentLogs)
127 r.Post("/stop", deployHandler.StopDeployment)
128 })
129
130 r.Delete("/api/account", func(w http.ResponseWriter, r *http.Request) {
131 middleware.RequireLogin(http.HandlerFunc(authHandler.DeleteAccount)).ServeHTTP(w, r)
132 })
133
134 ghWebhookHandler := handlers.NewGitHubWebhookHandler(database, deployHandler.Engine)
135 r.Mount("/api/github/webhook", ghWebhookHandler.Routes())
136
137 apiV1Handler := handlers.NewAPIV1Handler(database, deployHandler.Engine)
138 r.Mount("/api/v1", apiV1Handler.Routes())
139
140 adminHandler := handlers.NewAdminHandler(database)
141 r.Mount("/api/admin", adminHandler.Routes())
142
143 atprotoHandler := handlers.NewATProtoHandler(database)
144 r.Get("/client-metadata.json", atprotoHandler.ServeClientMetadata)
145 r.Get("/jwks.json", atprotoHandler.ServeJWKS)
146 r.Get("/auth/atproto", atprotoHandler.BeginAuth)
147 r.Get("/auth/atproto/callback", atprotoHandler.Callback)
148
149 clientDist := filepath.Join("..", "client", "dist")
150 if _, err := os.Stat(clientDist); err == nil {
151
152 fs := http.FileServer(http.Dir(clientDist))
153 r.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154
155 path := filepath.Join(clientDist, r.URL.Path)
156 if _, err := os.Stat(path); os.IsNotExist(err) {
157
158 http.ServeFile(w, r, filepath.Join(clientDist, "index.html"))
159 return
160 }
161 fs.ServeHTTP(w, r)
162 }))
163 }
164
165 addr := fmt.Sprintf(":%d", cfg.Port)
166 fmt.Printf("boop.cat (Go) listening on http://127.0.0.1%s\n", addr)
167 if err := http.ListenAndServe(addr, r); err != nil {
168 fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
169 os.Exit(1)
170 }
171}