1 package webserver
2
3 import (
4 "bytes"
5 "context"
6 "embed"
7 "fmt"
8 "html/template"
9 "io"
10 "io/fs"
11 "mime"
12 "net"
13 "net/http"
14 "os"
15 "path/filepath"
16 "slices"
17 "strings"
18 "time"
19
20 "github.com/cybertec-postgresql/pgwatch/v6/internal/db"
21 "github.com/cybertec-postgresql/pgwatch/v6/internal/log"
22 "github.com/cybertec-postgresql/pgwatch/v6/internal/metrics"
23 "github.com/cybertec-postgresql/pgwatch/v6/internal/sources"
24 )
25
26
27 var buildFS embed.FS
28
29 var uiFS fs.FS
30
31 func init() {
32 uiFS, _ = fs.Sub(buildFS, "build")
33 }
34
35 type Readier interface {
36 Ready() bool
37 }
38
39 type WebUIServer struct {
40 CmdOpts
41 http.Server
42 log.Logger
43 ctx context.Context
44 basePath string
45 indexHTML []byte
46 metricsReaderWriter metrics.ReaderWriter
47 sourcesReaderWriter sources.ReaderWriter
48 readyChecker Readier
49 }
50
51 func Init(ctx context.Context, opts CmdOpts, mrw metrics.ReaderWriter, srw sources.ReaderWriter, rc Readier) (_ *WebUIServer, err error) {
52 if opts.WebDisable == WebDisableAll {
53 return nil, nil
54 }
55 mux := http.NewServeMux()
56 s := &WebUIServer{
57 Server: http.Server{
58 Addr: opts.WebAddr,
59 ReadTimeout: 10 * time.Second,
60 WriteTimeout: 10 * time.Second,
61 MaxHeaderBytes: 1 << 20,
62 Handler: corsMiddleware(mux),
63 },
64 ctx: ctx,
65 Logger: log.GetLogger(ctx),
66 CmdOpts: opts,
67 metricsReaderWriter: mrw,
68 sourcesReaderWriter: srw,
69 readyChecker: rc,
70 }
71
72 s.basePath = "/" + opts.WebBasePath
73 if opts.WebBasePath != "" {
74 s.basePath += "/"
75 }
76
77 mux.Handle(s.basePath+"source", NewEnsureAuth(s.handleSources))
78 mux.Handle(s.basePath+"source/{name}", NewEnsureAuth(s.handleSourceItem))
79 mux.Handle(s.basePath+"test-connect", NewEnsureAuth(s.handleTestConnect))
80 mux.Handle(s.basePath+"metric", NewEnsureAuth(s.handleMetrics))
81 mux.Handle(s.basePath+"metric/{name}", NewEnsureAuth(s.handleMetricItem))
82 mux.Handle(s.basePath+"preset", NewEnsureAuth(s.handlePresets))
83 mux.Handle(s.basePath+"preset/{name}", NewEnsureAuth(s.handlePresetItem))
84 mux.Handle(s.basePath+"log", NewEnsureAuth(s.serveWsLog))
85 mux.HandleFunc(s.basePath+"login", s.handleLogin)
86 mux.HandleFunc(s.basePath+"liveness", s.handleLiveness)
87 mux.HandleFunc(s.basePath+"readiness", s.handleReadiness)
88 if opts.WebDisable != WebDisableUI {
89 if err = s.prepareIndexHTML(); err != nil {
90 return nil, err
91 }
92 mux.HandleFunc(s.basePath, s.handleStatic)
93 }
94
95 ln, err := net.Listen("tcp", s.Addr)
96 if err != nil {
97 return nil, err
98 }
99
100 go func() { panic(s.Serve(ln)) }()
101
102 return s, nil
103 }
104
105
106 func (s *WebUIServer) prepareIndexHTML() error {
107 tmpl, err := template.ParseFS(uiFS, "index.html")
108 if err != nil {
109 return err
110 }
111
112 var buf bytes.Buffer
113 data := map[string]string{
114 "BasePath": s.WebBasePath,
115 }
116 if err := tmpl.Execute(&buf, data); err != nil {
117 return err
118 }
119
120 s.indexHTML = buf.Bytes()
121 return nil
122 }
123
124 const (
125
126
127 cacheControlRevalidate = "no-cache"
128
129 cacheControlImmutable = "public, max-age=31536000, immutable"
130 )
131
132 func (s *WebUIServer) handleStatic(w http.ResponseWriter, r *http.Request) {
133 if r.Method != "GET" {
134 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
135 return
136 }
137
138
139 path := strings.TrimPrefix(r.URL.Path, strings.TrimSuffix(s.basePath, "/"))
140
141 routes := []string{"/", "/sources", "/metrics", "/presets", "/logs"}
142 if slices.Contains(routes, path) {
143 w.Header().Set("Content-Type", "text/html; charset=utf-8")
144 w.Header().Set("Content-Length", fmt.Sprintf("%d", len(s.indexHTML)))
145 w.Header().Set("Cache-Control", cacheControlRevalidate)
146 _, _ = w.Write(s.indexHTML)
147 s.Debug("index.html served")
148 return
149 }
150
151 path = strings.TrimPrefix(path, "/")
152 file, err := uiFS.Open(path)
153 if err != nil {
154 if os.IsNotExist(err) {
155 s.Println("file", path, "not found:", err)
156 http.NotFound(w, r)
157 return
158 }
159 s.Println("file", path, "cannot be read:", err)
160 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
161 return
162 }
163 defer file.Close()
164
165
166 contentType := mime.TypeByExtension(filepath.Ext(path))
167 w.Header().Set("Content-Type", contentType)
168 if strings.HasPrefix(path, "static/") || strings.HasPrefix(path, "assets/") {
169 w.Header().Set("Cache-Control", cacheControlImmutable)
170 } else {
171 w.Header().Set("Cache-Control", cacheControlRevalidate)
172 }
173
174 stat, err := file.Stat()
175 if err == nil && stat.Size() > 0 {
176 w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
177 }
178
179 n, _ := io.Copy(w, file)
180 s.Debug("file", path, "copied", n, "bytes")
181 }
182
183 func (s *WebUIServer) handleLiveness(w http.ResponseWriter, _ *http.Request) {
184 if s.ctx.Err() != nil {
185 w.WriteHeader(http.StatusServiceUnavailable)
186 _, _ = w.Write([]byte(`{"status": "unavailable"}`))
187 return
188 }
189 w.WriteHeader(http.StatusOK)
190 _, _ = w.Write([]byte(`{"status": "ok"}`))
191 }
192
193 func (s *WebUIServer) handleReadiness(w http.ResponseWriter, _ *http.Request) {
194 if s.readyChecker.Ready() {
195 w.WriteHeader(http.StatusOK)
196 _, _ = w.Write([]byte(`{"status": "ok"}`))
197 return
198 }
199 w.WriteHeader(http.StatusServiceUnavailable)
200 _, _ = w.Write([]byte(`{"status": "busy"}`))
201 }
202
203 func (s *WebUIServer) handleTestConnect(w http.ResponseWriter, r *http.Request) {
204 switch r.Method {
205 case http.MethodPost:
206
207 p, err := io.ReadAll(r.Body)
208 if err != nil {
209 http.Error(w, err.Error(), http.StatusBadRequest)
210 return
211 }
212 if err := db.Ping(context.TODO(), string(p)); err != nil {
213 http.Error(w, err.Error(), http.StatusBadRequest)
214 }
215 default:
216 w.Header().Set("Allow", "POST")
217 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
218 return
219 }
220 }
221
222 func corsMiddleware(next http.Handler) http.Handler {
223 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
224 w.Header().Set("Access-Control-Allow-Origin", "http://localhost:4000")
225 w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
226 w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, token")
227 if r.Method == "OPTIONS" {
228 w.WriteHeader(http.StatusOK)
229 return
230 }
231 next.ServeHTTP(w, r)
232 })
233 }
234