...
▽

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

Documentation: github.com/cybertec-postgresql/pgwatch/v6/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/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  //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 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 // computed base path with slashes
    45  	indexHTML           []byte // pre-rendered index.html content
    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  // 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  const (
   125  	// cacheControlRevalidate makes browsers revalidate on every request, so an
   126  	// upgrade is picked up without a hard refresh.
   127  	cacheControlRevalidate = "no-cache"
   128  	// cacheControlImmutable is for content-hashed files that never change.
   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  	// Strip base path if present
   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) { // is index.html
   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  	// Determine content type
   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  		// test database connection
   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") //check internal/webui/.env
   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