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/v3/internal/db"
21 "github.com/cybertec-postgresql/pgwatch/v3/internal/log"
22 "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics"
23 "github.com/cybertec-postgresql/pgwatch/v3/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 ReadyChecker 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 ReadyChecker
49 }
50
51 func Init(ctx context.Context, opts CmdOpts, mrw metrics.ReaderWriter, srw sources.ReaderWriter, rc ReadyChecker) (_ *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 func (s *WebUIServer) handleStatic(w http.ResponseWriter, r *http.Request) {
125 if r.Method != "GET" {
126 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
127 return
128 }
129
130
131 path := strings.TrimPrefix(r.URL.Path, strings.TrimSuffix(s.basePath, "/"))
132
133 routes := []string{"/", "/sources", "/metrics", "/presets", "/logs"}
134 if slices.Contains(routes, path) {
135 w.Header().Set("Content-Type", "text/html; charset=utf-8")
136 w.Header().Set("Content-Length", fmt.Sprintf("%d", len(s.indexHTML)))
137 _, _ = w.Write(s.indexHTML)
138 s.Debug("index.html served")
139 return
140 }
141
142 path = strings.TrimPrefix(path, "/")
143 file, err := uiFS.Open(path)
144 if err != nil {
145 if os.IsNotExist(err) {
146 s.Println("file", path, "not found:", err)
147 http.NotFound(w, r)
148 return
149 }
150 s.Println("file", path, "cannot be read:", err)
151 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
152 return
153 }
154 defer file.Close()
155
156
157 contentType := mime.TypeByExtension(filepath.Ext(path))
158 w.Header().Set("Content-Type", contentType)
159 if strings.HasPrefix(path, "static/") {
160 w.Header().Set("Cache-Control", "public, max-age=31536000")
161 }
162
163 stat, err := file.Stat()
164 if err == nil && stat.Size() > 0 {
165 w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
166 }
167
168 n, _ := io.Copy(w, file)
169 s.Debug("file", path, "copied", n, "bytes")
170 }
171
172 func (s *WebUIServer) handleLiveness(w http.ResponseWriter, _ *http.Request) {
173 if s.ctx.Err() != nil {
174 w.WriteHeader(http.StatusServiceUnavailable)
175 _, _ = w.Write([]byte(`{"status": "unavailable"}`))
176 return
177 }
178 w.WriteHeader(http.StatusOK)
179 _, _ = w.Write([]byte(`{"status": "ok"}`))
180 }
181
182 func (s *WebUIServer) handleReadiness(w http.ResponseWriter, _ *http.Request) {
183 if s.readyChecker.Ready() {
184 w.WriteHeader(http.StatusOK)
185 _, _ = w.Write([]byte(`{"status": "ok"}`))
186 return
187 }
188 w.WriteHeader(http.StatusServiceUnavailable)
189 _, _ = w.Write([]byte(`{"status": "busy"}`))
190 }
191
192 func (s *WebUIServer) handleTestConnect(w http.ResponseWriter, r *http.Request) {
193 switch r.Method {
194 case http.MethodPost:
195
196 p, err := io.ReadAll(r.Body)
197 if err != nil {
198 http.Error(w, err.Error(), http.StatusBadRequest)
199 return
200 }
201 if err := db.Ping(context.TODO(), string(p)); err != nil {
202 http.Error(w, err.Error(), http.StatusBadRequest)
203 }
204 default:
205 w.Header().Set("Allow", "POST")
206 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
207 return
208 }
209 }
210
211 func corsMiddleware(next http.Handler) http.Handler {
212 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
213 w.Header().Set("Access-Control-Allow-Origin", "http://localhost:4000")
214 w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
215 w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, token")
216 if r.Method == "OPTIONS" {
217 w.WriteHeader(http.StatusOK)
218 return
219 }
220 next.ServeHTTP(w, r)
221 })
222 }
223