...

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/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 // webui files
    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  		// test database connection
   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