forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1package main
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "html/template"
8 "io/fs"
9 "net/http"
10 "os"
11 "os/signal"
12 "strings"
13 "syscall"
14 "time"
15
16 _ "net/http/pprof"
17
18 "github.com/bluesky-social/indigo/atproto/identity"
19 "github.com/bluesky-social/indigo/util/cliutil"
20 "github.com/bluesky-social/indigo/xrpc"
21 "github.com/bluesky-social/social-app/bskyweb"
22 "github.com/prometheus/client_golang/prometheus/promhttp"
23
24 "github.com/labstack/echo-contrib/echoprometheus"
25 "github.com/labstack/echo/v4"
26 "github.com/labstack/echo/v4/middleware"
27
28 "github.com/klauspost/compress/gzhttp"
29 "github.com/klauspost/compress/gzip"
30 "github.com/urfave/cli/v2"
31)
32
33type Server struct {
34 echo *echo.Echo
35 httpd *http.Server
36 metricsHttpd *http.Server
37 xrpcc *xrpc.Client
38 dir identity.Directory
39}
40
41func serve(cctx *cli.Context) error {
42 debug := cctx.Bool("debug")
43 httpAddress := cctx.String("http-address")
44 appviewHost := cctx.String("appview-host")
45 metricsAddress := cctx.String("metrics-address")
46
47 // Echo
48 e := echo.New()
49
50 // create a new session (no auth)
51 xrpcc := &xrpc.Client{
52 Client: cliutil.NewHttpClient(),
53 Host: appviewHost,
54 }
55
56 // httpd
57 var (
58 httpTimeout = 2 * time.Minute
59 httpMaxHeaderBytes = 2 * (1024 * 1024)
60 gzipMinSizeBytes = 1024 * 2
61 gzipCompressionLevel = gzip.BestSpeed
62 gzipExceptMIMETypes = []string{"image/png"}
63 )
64
65 // Wrap the server handler in a gzip handler to compress larger responses.
66 gzipHandler, err := gzhttp.NewWrapper(
67 gzhttp.MinSize(gzipMinSizeBytes),
68 gzhttp.CompressionLevel(gzipCompressionLevel),
69 gzhttp.ExceptContentTypes(gzipExceptMIMETypes),
70 )
71 if err != nil {
72 return err
73 }
74
75 metricsMux := http.DefaultServeMux
76 metricsMux.Handle("/metrics", promhttp.Handler())
77
78 metricsHttpd := &http.Server{
79 Addr: metricsAddress,
80 Handler: metricsMux,
81 }
82
83 go func() {
84 if err := metricsHttpd.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
85 log.Error("failed to start metrics server", "error", err)
86 }
87 }()
88
89 //
90 // server
91 //
92 server := &Server{
93 echo: e,
94 xrpcc: xrpcc,
95 dir: identity.DefaultDirectory(),
96 metricsHttpd: metricsHttpd,
97 }
98
99 // Create the HTTP server.
100 server.httpd = &http.Server{
101 Handler: gzipHandler(server),
102 Addr: httpAddress,
103 WriteTimeout: httpTimeout,
104 ReadTimeout: httpTimeout,
105 MaxHeaderBytes: httpMaxHeaderBytes,
106 }
107
108 e.HideBanner = true
109
110 tmpl := &Template{
111 templates: template.Must(template.ParseFS(bskyweb.EmbedrTemplateFS, "embedr-templates/*.html")),
112 }
113 e.Renderer = tmpl
114 e.HTTPErrorHandler = server.errorHandler
115
116 e.IPExtractor = echo.ExtractIPFromXFFHeader()
117
118 // SECURITY: Do not modify without due consideration.
119 e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
120 ContentTypeNosniff: "nosniff",
121 // diable XFrameOptions; we're embedding here!
122 HSTSMaxAge: 31536000, // 365 days
123 // TODO:
124 // ContentSecurityPolicy
125 // XSSProtection
126 }))
127 e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
128 // Don't log requests for static content.
129 Skipper: func(c echo.Context) bool {
130 return strings.HasPrefix(c.Request().URL.Path, "/static")
131 },
132 }))
133 e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
134 Skipper: middleware.DefaultSkipper,
135 Store: middleware.NewRateLimiterMemoryStoreWithConfig(
136 middleware.RateLimiterMemoryStoreConfig{
137 Rate: 20, // requests per second
138 Burst: 150, // allow bursts
139 ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
140 },
141 ),
142 IdentifierExtractor: func(ctx echo.Context) (string, error) {
143 id := ctx.RealIP()
144 return id, nil
145 },
146 DenyHandler: func(c echo.Context, identifier string, err error) error {
147 return c.String(http.StatusTooManyRequests, "Your request has been rate limited. Please try again later. Contact support@bsky.app if you believe this was a mistake.\n")
148 },
149 }))
150
151 // redirect trailing slash to non-trailing slash.
152 // all of our current endpoints have no trailing slash.
153 e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
154 RedirectCode: http.StatusFound,
155 }))
156
157 echoprom := echoprometheus.NewMiddlewareWithConfig(
158 echoprometheus.MiddlewareConfig{
159 DoNotUseRequestPathFor404: true,
160 },
161 )
162
163 e.Use(echoprom)
164
165 //
166 // configure routes
167 //
168 // static files
169 staticHandler := http.FileServer(func() http.FileSystem {
170 if debug {
171 log.Debugf("serving static file from the local file system")
172 return http.FS(os.DirFS("embedr-static"))
173 }
174 fsys, err := fs.Sub(bskyweb.EmbedrStaticFS, "embedr-static")
175 if err != nil {
176 log.Fatal(err)
177 }
178 return http.FS(fsys)
179 }())
180
181 e.GET("/robots.txt", echo.WrapHandler(staticHandler))
182 e.GET("/ips-v4", echo.WrapHandler(staticHandler))
183 e.GET("/ips-v6", echo.WrapHandler(staticHandler))
184 e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
185 e.GET("/security.txt", func(c echo.Context) error {
186 return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
187 })
188 e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
189 return func(c echo.Context) error {
190 path := c.Request().URL.Path
191 maxAge := 1 * (60 * 60) // default is 1 hour
192
193 // Cache javascript and images files for 1 week, which works because
194 // they're always versioned (e.g. /static/js/main.64c14927.js)
195 if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
196 maxAge = 7 * (60 * 60 * 24) // 1 week
197 }
198
199 c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
200 return next(c)
201 }
202 })
203
204 // actual routes
205 e.GET("/", server.WebHome)
206 e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
207 e.GET("/embed.js", echo.WrapHandler(staticHandler))
208 e.GET("/oembed", server.WebOEmbed)
209 e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
210
211 // Start the server.
212 log.Infof("starting server address=%s", httpAddress)
213 go func() {
214 if err := server.httpd.ListenAndServe(); err != nil {
215 if !errors.Is(err, http.ErrServerClosed) {
216 log.Errorf("HTTP server shutting down unexpectedly: %s", err)
217 }
218 }
219 }()
220
221 // Wait for a signal to exit.
222 log.Info("registering OS exit signal handler")
223 quit := make(chan struct{})
224 exitSignals := make(chan os.Signal, 1)
225 signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM)
226 go func() {
227 sig := <-exitSignals
228 log.Infof("received OS exit signal: %s", sig)
229
230 // Shut down the HTTP server.
231 if err := server.Shutdown(); err != nil {
232 log.Errorf("HTTP server shutdown error: %s", err)
233 }
234
235 // Trigger the return that causes an exit.
236 close(quit)
237 }()
238 <-quit
239 log.Infof("graceful shutdown complete")
240 return nil
241}
242
243func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
244 srv.echo.ServeHTTP(rw, req)
245}
246
247func (srv *Server) Shutdown() error {
248 log.Info("shutting down")
249
250 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
251 defer cancel()
252
253 // Shutdown metrics server too
254 if srv.metricsHttpd != nil {
255 srv.metricsHttpd.Shutdown(ctx)
256 }
257
258 return srv.httpd.Shutdown(ctx)
259}
260
261func (srv *Server) errorHandler(err error, c echo.Context) {
262 code := http.StatusInternalServerError
263 if he, ok := err.(*echo.HTTPError); ok {
264 code = he.Code
265 }
266 c.Logger().Error(err)
267 data := map[string]interface{}{
268 "statusCode": code,
269 }
270 c.Render(code, "error.html", data)
271}