...

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  	"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 // webui files
    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  		// test database connection
   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") //check internal/webui/.env
   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