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