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 // Create CORS middleware for oembed
182 oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
183 AllowOrigins: []string{"*"},
184 AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
185 AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
186 })
187
188 e.GET("/robots.txt", echo.WrapHandler(staticHandler))
189 e.GET("/ips-v4", echo.WrapHandler(staticHandler))
190 e.GET("/ips-v6", echo.WrapHandler(staticHandler))
191 e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
192 e.GET("/security.txt", func(c echo.Context) error {
193 return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
194 })
195 e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
196 return func(c echo.Context) error {
197 path := c.Request().URL.Path
198 maxAge := 1 * (60 * 60) // default is 1 hour
199
200 // Cache javascript and images files for 1 week, which works because
201 // they're always versioned (e.g. /static/js/main.64c14927.js)
202 if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
203 maxAge = 7 * (60 * 60 * 24) // 1 week
204 }
205
206 c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
207 return next(c)
208 }
209 })
210
211 // actual routes
212 e.GET("/", server.WebHome)
213 e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
214 e.GET("/embed.js", echo.WrapHandler(staticHandler))
215 e.GET("/oembed", server.WebOEmbed, oembedCORS)
216 e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
217
218 // Start the server.
219 log.Infof("starting server address=%s", httpAddress)
220 go func() {
221 if err := server.httpd.ListenAndServe(); err != nil {
222 if !errors.Is(err, http.ErrServerClosed) {
223 log.Errorf("HTTP server shutting down unexpectedly: %s", err)
224 }
225 }
226 }()
227
228 // Wait for a signal to exit.
229 log.Info("registering OS exit signal handler")
230 quit := make(chan struct{})
231 exitSignals := make(chan os.Signal, 1)
232 signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM)
233 go func() {
234 sig := <-exitSignals
235 log.Infof("received OS exit signal: %s", sig)
236
237 // Shut down the HTTP server.
238 if err := server.Shutdown(); err != nil {
239 log.Errorf("HTTP server shutdown error: %s", err)
240 }
241
242 // Trigger the return that causes an exit.
243 close(quit)
244 }()
245 <-quit
246 log.Infof("graceful shutdown complete")
247 return nil
248}
249
250func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
251 srv.echo.ServeHTTP(rw, req)
252}
253
254func (srv *Server) Shutdown() error {
255 log.Info("shutting down")
256
257 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
258 defer cancel()
259
260 // Shutdown metrics server too
261 if srv.metricsHttpd != nil {
262 srv.metricsHttpd.Shutdown(ctx)
263 }
264
265 return srv.httpd.Shutdown(ctx)
266}
267
268func (srv *Server) errorHandler(err error, c echo.Context) {
269 code := http.StatusInternalServerError
270 if he, ok := err.(*echo.HTTPError); ok {
271 code = he.Code
272 }
273 c.Logger().Error(err)
274 data := map[string]interface{}{
275 "statusCode": code,
276 }
277 c.Render(code, "error.html", data)
278}