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