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}