Fork of github.com/did-method-plc/did-method-plc
1package main 2 3import ( 4 "context" 5 "embed" 6 "errors" 7 "fmt" 8 "io/fs" 9 "net/http" 10 "os" 11 "os/signal" 12 "strings" 13 "syscall" 14 "time" 15 16 "github.com/flosch/pongo2/v6" 17 "github.com/klauspost/compress/gzhttp" 18 "github.com/klauspost/compress/gzip" 19 "github.com/labstack/echo/v4" 20 "github.com/labstack/echo/v4/middleware" 21 "github.com/russross/blackfriday/v2" 22 "github.com/urfave/cli/v2" 23) 24 25//go:embed templates/* 26var TemplateFS embed.FS 27 28//go:embed static/* 29var StaticFS embed.FS 30 31//go:embed spec/v0.1/did-plc.md 32var specZeroOneMarkdown []byte 33 34//go:embed spec/plc-server-openapi3.yaml 35var apiOpenapiYaml []byte 36 37type Server struct { 38 echo *echo.Echo 39 httpd *http.Server 40 client *http.Client 41 plcHost string 42} 43 44func serve(cctx *cli.Context) error { 45 debug := cctx.Bool("debug") 46 httpAddress := cctx.String("http-address") 47 48 // Echo 49 e := echo.New() 50 51 // create a new session (no auth) 52 client := http.Client{ 53 Transport: &http.Transport{ 54 Proxy: http.ProxyFromEnvironment, 55 ForceAttemptHTTP2: true, 56 MaxIdleConns: 100, 57 IdleConnTimeout: 90 * time.Second, 58 TLSHandshakeTimeout: 10 * time.Second, 59 ExpectContinueTimeout: 1 * time.Second, 60 }, 61 } 62 63 // httpd variable 64 var ( 65 httpTimeout = 2 * time.Minute 66 httpMaxHeaderBytes = 2 * (1024 * 1024) 67 gzipMinSizeBytes = 1024 * 2 68 gzipCompressionLevel = gzip.BestSpeed 69 gzipExceptMIMETypes = []string{"image/png"} 70 ) 71 72 // Wrap the server handler in a gzip handler to compress larger responses. 73 gzipHandler, err := gzhttp.NewWrapper( 74 gzhttp.MinSize(gzipMinSizeBytes), 75 gzhttp.CompressionLevel(gzipCompressionLevel), 76 gzhttp.ExceptContentTypes(gzipExceptMIMETypes), 77 ) 78 if err != nil { 79 return err 80 } 81 82 server := &Server{ 83 echo: e, 84 client: &client, 85 plcHost: cctx.String("plc-host"), 86 } 87 88 server.httpd = &http.Server{ 89 Handler: gzipHandler(server), 90 Addr: httpAddress, 91 WriteTimeout: httpTimeout, 92 ReadTimeout: httpTimeout, 93 MaxHeaderBytes: httpMaxHeaderBytes, 94 } 95 96 e.HideBanner = true 97 // SECURITY: Do not modify without due consideration. 98 e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ 99 ContentTypeNosniff: "nosniff", 100 XFrameOptions: "SAMEORIGIN", 101 HSTSMaxAge: 31536000, // 365 days 102 // TODO: 103 // ContentSecurityPolicy 104 // XSSProtection 105 })) 106 e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 107 // Don't log requests for static content. 108 Skipper: func(c echo.Context) bool { 109 return strings.HasPrefix(c.Request().URL.Path, "/static") 110 }, 111 })) 112 e.Renderer = NewRenderer("templates/", &TemplateFS, debug) 113 e.HTTPErrorHandler = server.errorHandler 114 115 // redirect trailing slash to non-trailing slash. 116 // all of our current endpoints have no trailing slash. 117 e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ 118 RedirectCode: http.StatusFound, 119 })) 120 121 staticHandler := http.FileServer(func() http.FileSystem { 122 if debug { 123 log.Debugf("serving static file from the local file system") 124 return http.FS(os.DirFS("static")) 125 } 126 fsys, err := fs.Sub(StaticFS, "static") 127 if err != nil { 128 log.Fatal(err) 129 } 130 return http.FS(fsys) 131 }()) 132 133 // static file routes 134 e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 135 e.GET("/favicon.ico", echo.WrapHandler(staticHandler)) 136 e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler))) 137 e.GET("/.well-known/*", echo.WrapHandler(staticHandler)) 138 e.GET("/security.txt", func(c echo.Context) error { 139 return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt") 140 }) 141 142 // meta stuff 143 e.GET("/_health", server.WebHealth) 144 e.GET("/healthz", server.WebHealth) 145 146 // actual pages/views 147 e.GET("/", server.WebHome) 148 e.GET("/resolve", server.WebResolve) 149 e.GET("/did/:did", server.WebDid) 150 e.GET("/spec/v0.1/did-plc", server.WebSpecZeroOne) 151 e.GET("/api/redoc", server.WebRedoc) 152 e.GET("/api/plc-server-openapi3.yaml", server.WebOpenapiYaml) 153 154 // Start the server. 155 log.Infof("starting server address=%s", httpAddress) 156 go func() { 157 if err := server.httpd.ListenAndServe(); err != nil { 158 if !errors.Is(err, http.ErrServerClosed) { 159 log.Errorf("HTTP server shutting down unexpectedly: %s", err) 160 } 161 } 162 }() 163 164 // Wait for a signal to exit. 165 log.Info("registering OS exit signal handler") 166 quit := make(chan struct{}) 167 exitSignals := make(chan os.Signal, 1) 168 signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM) 169 go func() { 170 sig := <-exitSignals 171 log.Infof("received OS exit signal: %s", sig) 172 173 // Shut down the HTTP server. 174 if err := server.Shutdown(); err != nil { 175 log.Errorf("HTTP server shutdown error: %s", err) 176 } 177 178 // Trigger the return that causes an exit. 179 close(quit) 180 }() 181 <-quit 182 log.Infof("graceful shutdown complete") 183 return nil 184} 185 186func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 187 srv.echo.ServeHTTP(rw, req) 188} 189 190func (srv *Server) Shutdown() error { 191 log.Info("shutting down") 192 193 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 194 defer cancel() 195 196 return srv.httpd.Shutdown(ctx) 197} 198 199func (srv *Server) errorHandler(err error, c echo.Context) { 200 code := http.StatusInternalServerError 201 errorMessage := "" 202 if he, ok := err.(*echo.HTTPError); ok { 203 code = he.Code 204 if he.Message != nil { 205 errorMessage = fmt.Sprintf("%s", he.Message) 206 } 207 } 208 c.Logger().Error(err) 209 data := pongo2.Context{ 210 "statusCode": code, 211 "errorMessage": errorMessage, 212 } 213 if err = c.Render(code, "templates/error.html", data); err != nil { 214 c.Logger().Error(err) 215 } 216} 217 218func (srv *Server) WebHome(c echo.Context) error { 219 data := pongo2.Context{} 220 return c.Render(http.StatusOK, "templates/home.html", data) 221} 222 223func (srv *Server) WebSpecZeroOne(c echo.Context) error { 224 data := pongo2.Context{} 225 data["html_title"] = "did:plc Specification v0.1" 226 data["markdown_html"] = string(blackfriday.Run(specZeroOneMarkdown)) 227 return c.Render(http.StatusOK, "templates/markdown.html", data) 228} 229 230func (srv *Server) WebHealth(c echo.Context) error { 231 resp := map[string]interface{}{ 232 "status": "ok", 233 } 234 return c.JSON(http.StatusOK, resp) 235} 236 237func (srv *Server) WebOpenapiYaml(c echo.Context) error { 238 return c.Blob(http.StatusOK, "text/yaml", apiOpenapiYaml) 239} 240 241func (srv *Server) WebRedoc(c echo.Context) error { 242 data := pongo2.Context{} 243 return c.Render(http.StatusOK, "templates/redoc.html", data) 244} 245 246func (srv *Server) WebResolve(c echo.Context) error { 247 data := pongo2.Context{} 248 did := c.QueryParam("did") 249 if did != "" { 250 return c.Redirect(http.StatusMovedPermanently, "/did/"+did) 251 } 252 return c.Render(http.StatusOK, "templates/resolve.html", data) 253} 254 255func (srv *Server) WebDid(c echo.Context) error { 256 data := pongo2.Context{} 257 did := c.Param("did") 258 data["did"] = did 259 if !strings.HasPrefix(did, "did:plc:") { 260 return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("Not a valid DID PLC identifier: %s", did)) 261 } 262 res, err := ResolveDidPlc(srv.client, srv.plcHost, did) 263 if err != nil { 264 return err 265 } 266 if res.StatusCode == 404 { 267 return echo.NewHTTPError(http.StatusNotFound, fmt.Errorf("DID not in PLC directory: %s", did)) 268 } 269 if res.StatusCode == 410 { 270 return echo.NewHTTPError(http.StatusNotFound, fmt.Errorf("DID has been permanently deleted: %s", did)) 271 } 272 data["result"] = res 273 return c.Render(http.StatusOK, "templates/did.html", data) 274}