...

Source file src/github.com/cybertec-postgresql/pgwatch/v3/internal/webserver/webserver.go

Documentation: github.com/cybertec-postgresql/pgwatch/v3/internal/webserver

     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  //go:embed build
    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 // computed base path with slashes
    45  	indexHTML           []byte // pre-rendered index.html content
    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  // prepareIndexHTML renders the index.html template once at startup
   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  	// Strip base path if present
   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) { // is index.html
   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  	// Determine content type
   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  		// test database connection
   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") //check internal/webui/.env
   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