1 package webserver
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "io/fs"
8 "mime"
9 "net"
10 "net/http"
11 "os"
12 "path/filepath"
13 "slices"
14 "strings"
15 "time"
16
17 "github.com/cybertec-postgresql/pgwatch/v3/internal/log"
18 "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics"
19 "github.com/cybertec-postgresql/pgwatch/v3/internal/sources"
20 )
21
22 type ReadyChecker interface {
23 Ready() bool
24 }
25
26 type WebUIServer struct {
27 http.Server
28 CmdOpts
29 ctx context.Context
30 l log.LoggerIface
31 uiFS fs.FS
32 metricsReaderWriter metrics.ReaderWriter
33 sourcesReaderWriter sources.ReaderWriter
34 readyChecker ReadyChecker
35 }
36
37 func Init(ctx context.Context, opts CmdOpts, webuifs fs.FS, mrw metrics.ReaderWriter, srw sources.ReaderWriter, rc ReadyChecker) (*WebUIServer, error) {
38 if opts.WebDisable == WebDisableAll {
39 return nil, nil
40 }
41 mux := http.NewServeMux()
42 s := &WebUIServer{
43 Server: http.Server{
44 Addr: opts.WebAddr,
45 ReadTimeout: 10 * time.Second,
46 WriteTimeout: 10 * time.Second,
47 MaxHeaderBytes: 1 << 20,
48 Handler: corsMiddleware(mux),
49 },
50 ctx: ctx,
51 l: log.GetLogger(ctx),
52 CmdOpts: opts,
53 uiFS: webuifs,
54 metricsReaderWriter: mrw,
55 sourcesReaderWriter: srw,
56 readyChecker: rc,
57 }
58
59 mux.Handle("/source", NewEnsureAuth(s.handleSources))
60 mux.Handle("/test-connect", NewEnsureAuth(s.handleTestConnect))
61 mux.Handle("/metric", NewEnsureAuth(s.handleMetrics))
62 mux.Handle("/preset", NewEnsureAuth(s.handlePresets))
63 mux.Handle("/log", NewEnsureAuth(s.serveWsLog))
64 mux.HandleFunc("/login", s.handleLogin)
65 mux.HandleFunc("/liveness", s.handleLiveness)
66 mux.HandleFunc("/readiness", s.handleReadiness)
67 if opts.WebDisable != WebDisableUI {
68 mux.HandleFunc("/", s.handleStatic)
69 }
70
71 ln, err := net.Listen("tcp", s.Addr)
72 if err != nil {
73 return nil, err
74 }
75
76 go func() { panic(s.Serve(ln)) }()
77
78 return s, nil
79 }
80
81 func (Server *WebUIServer) handleStatic(w http.ResponseWriter, r *http.Request) {
82 if r.Method != "GET" {
83 http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
84 return
85 }
86 routes := []string{"/", "/sources", "/metrics", "/presets", "/logs"}
87 path := r.URL.Path
88 if slices.Contains(routes, path) {
89 path = "index.html"
90 } else {
91 path = strings.TrimPrefix(path, "/")
92 }
93
94 file, err := Server.uiFS.Open(path)
95 if err != nil {
96 if os.IsNotExist(err) {
97 Server.l.Println("file", path, "not found:", err)
98 http.NotFound(w, r)
99 return
100 }
101 Server.l.Println("file", path, "cannot be read:", err)
102 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
103 return
104 }
105
106 contentType := mime.TypeByExtension(filepath.Ext(path))
107 w.Header().Set("Content-Type", contentType)
108 if strings.HasPrefix(path, "static/") {
109 w.Header().Set("Cache-Control", "public, max-age=31536000")
110 }
111 stat, err := file.Stat()
112 if err == nil && stat.Size() > 0 {
113 w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
114 }
115
116 n, _ := io.Copy(w, file)
117 Server.l.Debug("file", path, "copied", n, "bytes")
118 }
119
120 func (Server *WebUIServer) handleLiveness(w http.ResponseWriter, _ *http.Request) {
121 if Server.ctx.Err() != nil {
122 w.WriteHeader(http.StatusServiceUnavailable)
123 _, _ = w.Write([]byte(`{"status": "unavailable"}`))
124 return
125 }
126 w.WriteHeader(http.StatusOK)
127 _, _ = w.Write([]byte(`{"status": "ok"}`))
128 }
129
130 func (Server *WebUIServer) handleReadiness(w http.ResponseWriter, _ *http.Request) {
131 if Server.readyChecker.Ready() {
132 w.WriteHeader(http.StatusOK)
133 _, _ = w.Write([]byte(`{"status": "ok"}`))
134 return
135 }
136 w.WriteHeader(http.StatusServiceUnavailable)
137 _, _ = w.Write([]byte(`{"status": "busy"}`))
138 }
139
140 func (Server *WebUIServer) handleTestConnect(w http.ResponseWriter, r *http.Request) {
141 switch r.Method {
142 case http.MethodPost:
143
144 p, err := io.ReadAll(r.Body)
145 if err != nil {
146 http.Error(w, err.Error(), http.StatusBadRequest)
147 return
148 }
149 if err := Server.TryConnectToDB(p); err != nil {
150 http.Error(w, err.Error(), http.StatusBadRequest)
151 }
152 default:
153 w.Header().Set("Allow", "POST")
154 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
155 }
156 }
157