this repo has no description
1package main
2
3import (
4 "context"
5 "embed"
6 "errors"
7 "fmt"
8 "io/fs"
9 "log/slog"
10 "net/http"
11 "os"
12 "os/signal"
13 "syscall"
14 "time"
15
16 "github.com/bluesky-social/indigo/atproto/identity"
17
18 "github.com/flosch/pongo2/v6"
19 "github.com/labstack/echo/v4"
20 "github.com/labstack/echo/v4/middleware"
21 slogecho "github.com/samber/slog-echo"
22 "github.com/urfave/cli/v2"
23 "gorm.io/driver/sqlite"
24 "gorm.io/gorm"
25)
26
27//go:embed static/*
28var StaticFS embed.FS
29
30type WebServer struct {
31 echo *echo.Echo
32 httpd *http.Server
33 db *gorm.DB
34 dir identity.Directory
35 jetstreamHost string
36}
37
38func NewWebServer(cctx *cli.Context) (*WebServer, error) {
39 debug := cctx.Bool("debug")
40 httpAddress := cctx.String("bind")
41 jetstreamHost := cctx.String("jetstream-host")
42 db, err := gorm.Open(sqlite.Open(cctx.String("sqlite-path")))
43 if err != nil {
44 return nil, fmt.Errorf("failed to open db: %w", err)
45 }
46
47 e := echo.New()
48
49 // httpd
50 var (
51 httpTimeout = 1 * time.Minute
52 httpMaxHeaderBytes = 1 * (1024 * 1024)
53 )
54
55 srv := &WebServer{
56 echo: e,
57 db: db,
58 dir: identity.DefaultDirectory(),
59 jetstreamHost: jetstreamHost,
60 }
61 srv.httpd = &http.Server{
62 Handler: srv,
63 Addr: httpAddress,
64 WriteTimeout: httpTimeout,
65 ReadTimeout: httpTimeout,
66 MaxHeaderBytes: httpMaxHeaderBytes,
67 }
68
69 e.HideBanner = true
70 e.Use(slogecho.New(slog.Default()))
71 e.Use(middleware.Recover())
72 e.Use(middleware.BodyLimit("64M"))
73 e.HTTPErrorHandler = srv.errorHandler
74 e.Renderer = NewRenderer("templates/", &TemplateFS, debug)
75 e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
76 ContentTypeNosniff: "nosniff",
77 XFrameOptions: "SAMEORIGIN",
78 HSTSMaxAge: 31536000, // 365 days
79 // TODO:
80 // ContentSecurityPolicy
81 // XSSProtection
82 }))
83
84 // redirect trailing slash to non-trailing slash.
85 // all of our current endpoints have no trailing slash.
86 e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
87 RedirectCode: http.StatusFound,
88 }))
89
90 staticHandler := http.FileServer(func() http.FileSystem {
91 if debug {
92 return http.FS(os.DirFS("static"))
93 }
94 fsys, err := fs.Sub(StaticFS, "static")
95 if err != nil {
96 slog.Error("static template error", "err", err)
97 os.Exit(-1)
98 }
99 return http.FS(fsys)
100 }())
101
102 e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)))
103 e.GET("/_health", srv.HandleHealthCheck)
104
105 // basic static routes
106 e.GET("/robots.txt", echo.WrapHandler(staticHandler))
107 e.GET("/favicon.ico", echo.WrapHandler(staticHandler))
108
109 // actual content
110 e.GET("/", srv.WebHome)
111 e.GET("/query", srv.WebQuery)
112 e.GET("/recent", srv.WebRecent)
113 e.GET("/domain/:domain", srv.WebDomain)
114 e.GET("/lexicon/:nsid", srv.WebLexicon)
115 e.GET("/lexicon/:nsid/history", srv.WebLexiconHistory)
116 // TODO: e.GET("/lexicon/:nsid/def/:name", srv.WebLexiconDef)
117
118 e.GET("/demo/record", srv.WebDemoRecord)
119 e.GET("/demo/query", srv.WebDemoQuery)
120
121 return srv, nil
122}
123
124// Starts the server in a goroutine, and returns
125func (srv *WebServer) RunWeb() {
126 // Start the server
127 slog.Info("starting server", "bind", srv.httpd.Addr)
128 go func() {
129 if err := srv.httpd.ListenAndServe(); err != nil {
130 if !errors.Is(err, http.ErrServerClosed) {
131 slog.Error("HTTP server shutting down unexpectedly", "err", err)
132 }
133 }
134 }()
135}
136
137// Runs in this thread
138func (srv *WebServer) RunSignalHandler() error {
139 // Wait for a signal to exit.
140 slog.Info("registering OS exit signal handler")
141 quit := make(chan struct{})
142 exitSignals := make(chan os.Signal, 1)
143 signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM)
144 go func() {
145 sig := <-exitSignals
146 slog.Info("received OS exit signal", "signal", sig)
147
148 // Shut down the HTTP server
149 if err := srv.Shutdown(); err != nil {
150 slog.Error("HTTP server shutdown error", "err", err)
151 }
152
153 // Trigger the return that causes an exit.
154 close(quit)
155 }()
156 <-quit
157 slog.Info("graceful shutdown complete")
158 return nil
159}
160
161type GenericStatus struct {
162 Daemon string `json:"daemon"`
163 Status string `json:"status"`
164 Message string `json:"msg,omitempty"`
165}
166
167func (srv *WebServer) errorHandler(err error, c echo.Context) {
168 code := http.StatusInternalServerError
169 var errorMessage string
170 if he, ok := err.(*echo.HTTPError); ok {
171 code = he.Code
172 errorMessage = fmt.Sprintf("%s", he.Message)
173 }
174 if code >= 500 {
175 slog.Warn("lexidex-http-internal-error", "err", err)
176 }
177 data := pongo2.Context{
178 "statusCode": code,
179 "errorMessage": errorMessage,
180 }
181 if !c.Response().Committed {
182 c.Render(code, "error.html", data)
183 }
184}
185
186func (srv *WebServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
187 srv.echo.ServeHTTP(rw, req)
188}
189
190func (srv *WebServer) Shutdown() error {
191 slog.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 (s *WebServer) HandleHealthCheck(c echo.Context) error {
200 return c.JSON(200, GenericStatus{Status: "ok", Daemon: "lexidex"})
201}