github.com/tickoalcantara12/micro/v3@v3.0.0-20221007104245-9d75b9bcbab9/client/web/web.go (about)

     1  // Package web is a web dashboard
     2  package web
     3  
     4  import (
     5  	"context"
     6  	"embed"
     7  	"encoding/json"
     8  	"fmt"
     9  	"html/template"
    10  	"io/fs"
    11  	"log"
    12  	"net/http"
    13  	"net/url"
    14  	"os"
    15  	"path/filepath"
    16  	"sort"
    17  	"strings"
    18  	"sync"
    19  	"time"
    20  
    21  	"github.com/fatih/camelcase"
    22  	"github.com/gorilla/mux"
    23  	"github.com/tickoalcantara12/micro/v3/client/web/html"
    24  	"github.com/tickoalcantara12/micro/v3/cmd"
    25  	"github.com/tickoalcantara12/micro/v3/service/auth"
    26  	"github.com/tickoalcantara12/micro/v3/service/registry"
    27  	"github.com/serenize/snaker"
    28  	"github.com/urfave/cli/v2"
    29  )
    30  
    31  //Meta Fields of micro web
    32  var (
    33  	Name      = "web"
    34  	API       = "http://localhost:8080"
    35  	Address   = ":8082"
    36  	Namespace = "micro"
    37  	Resolver  = "path"
    38  	LoginURL  = "/login"
    39  	// Host name the web dashboard is served on
    40  	Host, _ = os.Hostname()
    41  	// Token cookie name
    42  	TokenCookieName = "micro-token"
    43  
    44  	// create a session store
    45  	mtx sync.RWMutex
    46  	sessions = map[string]*session{}
    47  )
    48  
    49  type srv struct {
    50  	*mux.Router
    51  	// registry we use
    52  	registry registry.Registry
    53  }
    54  
    55  type reg struct {
    56  	registry.Registry
    57  
    58  	sync.RWMutex
    59  	lastPull time.Time
    60  	services []*registry.Service
    61  }
    62  
    63  type session struct {
    64  	// account related to the session
    65  	Account *auth.Account
    66  	// token used for the session
    67  	Token string
    68  }
    69  
    70  //go:embed html/* html/assets/*
    71  var content embed.FS
    72  
    73  func init() {
    74  	cmd.Register(
    75  		&cli.Command{
    76  			Name:   "web",
    77  			Usage:  "Run the micro web UI",
    78  			Action: Run,
    79  			Flags:  Flags,
    80  		},
    81  	)
    82  }
    83  
    84  // ServeHTTP serves the web dashboard and proxies where appropriate
    85  func (s *srv) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    86  	// check if authenticated
    87  	if r.URL.Path != LoginURL {
    88  		c, err := r.Cookie(TokenCookieName)
    89  		if err != nil || c == nil {
    90  			http.Redirect(w, r, LoginURL, 302)
    91  			return
    92  		}
    93  
    94  		// check the token is valid
    95  		token := strings.TrimPrefix(c.Value, TokenCookieName+"=")
    96  		if len(token) == 0 {
    97  			http.Redirect(w, r, LoginURL, 302)
    98  			return
    99  		}
   100  
   101  		// if we have a session retrieve it
   102  		mtx.RLock()
   103  		sess, ok := sessions[token]
   104  		mtx.RUnlock()
   105  
   106  		// no session, go get the account
   107  		if !ok {
   108  			// can't inspect the token
   109  			acc, err := auth.Inspect(token)
   110  			if err != nil {
   111  				http.Error(w, "Unauthorized", 401)
   112  				return
   113  			}
   114  
   115  			// save the session
   116  			mtx.Lock()
   117  			sess = &session{
   118  				Account: acc,
   119  				Token: token,
   120  			}
   121  			sessions[token] = sess
   122  			mtx.Unlock()
   123  		}
   124  
   125  		// create a new context
   126  		ctx := context.WithValue(r.Context(), session{}, sess)
   127  
   128  		// redefine request with context
   129  		r = r.Clone(ctx)
   130  	}
   131  
   132  	// set defaults on the request
   133  	if len(r.URL.Host) == 0 {
   134  		r.URL.Host = r.Host
   135  	}
   136  	if len(r.URL.Scheme) == 0 {
   137  		r.URL.Scheme = "http"
   138  	}
   139  
   140  	s.Router.ServeHTTP(w, r)
   141  }
   142  
   143  func split(v string) string {
   144  	parts := camelcase.Split(strings.Replace(v, ".", "", 1))
   145  	return strings.Join(parts, " ")
   146  }
   147  
   148  func format(v *registry.Value) string {
   149  	if v == nil || len(v.Values) == 0 {
   150  		return "{}"
   151  	}
   152  	var f []string
   153  	for _, k := range v.Values {
   154  		f = append(f, formatEndpoint(k, 0))
   155  	}
   156  	return fmt.Sprintf("{\n%s}", strings.Join(f, ""))
   157  }
   158  
   159  func formatEndpoint(v *registry.Value, r int) string {
   160  	// default format is tabbed plus the value plus new line
   161  	fparts := []string{"", "%s %s", "\n"}
   162  	for i := 0; i < r+1; i++ {
   163  		fparts[0] += "\t"
   164  	}
   165  	// its just a primitive of sorts so return
   166  	if len(v.Values) == 0 {
   167  		return fmt.Sprintf(strings.Join(fparts, ""), snaker.CamelToSnake(v.Name), v.Type)
   168  	}
   169  
   170  	// this thing has more things, it's complex
   171  	fparts[1] += " {"
   172  
   173  	vals := []interface{}{snaker.CamelToSnake(v.Name), v.Type}
   174  
   175  	for _, val := range v.Values {
   176  		fparts = append(fparts, "%s")
   177  		vals = append(vals, formatEndpoint(val, r+1))
   178  	}
   179  
   180  	// at the end
   181  	l := len(fparts) - 1
   182  	for i := 0; i < r+1; i++ {
   183  		fparts[l] += "\t"
   184  	}
   185  	fparts = append(fparts, "}\n")
   186  
   187  	return fmt.Sprintf(strings.Join(fparts, ""), vals...)
   188  }
   189  
   190  func faviconHandler(w http.ResponseWriter, r *http.Request) {
   191  	return
   192  }
   193  
   194  func (s *srv) notFoundHandler(w http.ResponseWriter, r *http.Request) {
   195  	w.WriteHeader(http.StatusNotFound)
   196  	s.render(w, r, html.NotFoundTemplate, nil)
   197  }
   198  
   199  func (s *srv) indexHandler(w http.ResponseWriter, r *http.Request) {
   200  	if r.Method == "OPTIONS" {
   201  		return
   202  	}
   203  
   204  	domain := registry.DefaultDomain
   205  
   206  	services, err := s.registry.ListServices(registry.ListDomain(domain))
   207  	if err != nil {
   208  		log.Printf("Error listing services: %v", err)
   209  	}
   210  
   211  	type webService struct {
   212  		Name string
   213  		Link string
   214  		Icon string // TODO: lookup icon
   215  	}
   216  
   217  	var webServices []webService
   218  	for _, srv := range services {
   219  		name := srv.Name
   220  		link := fmt.Sprintf("/%v", name)
   221  
   222  		if len(srv.Endpoints) == 0 {
   223  			continue
   224  		}
   225  
   226  		// in the case of 3 letter things e.g m3o convert to M3O
   227  		if len(name) <= 3 && strings.ContainsAny(name, "012345789") {
   228  			name = strings.ToUpper(name)
   229  		}
   230  
   231  		webServices = append(webServices, webService{Name: name, Link: link})
   232  	}
   233  
   234  	sort.Slice(webServices, func(i, j int) bool { return webServices[i].Name < webServices[j].Name })
   235  
   236  	type templateData struct {
   237  		HasWebServices bool
   238  		WebServices    []webService
   239  	}
   240  
   241  	data := templateData{len(webServices) > 0, webServices}
   242  	s.render(w, r, html.IndexTemplate, data)
   243  }
   244  
   245  func (s *srv) loginHandler(w http.ResponseWriter, req *http.Request) {
   246  	if req.Method == "POST" {
   247  		s.generateTokenHandler(w, req)
   248  		return
   249  	}
   250  
   251  	t, err := template.New("template").Parse(html.LoginTemplate)
   252  	if err != nil {
   253  		http.Error(w, "Error occurred:"+err.Error(), http.StatusInternalServerError)
   254  		return
   255  	}
   256  
   257  	if err := t.ExecuteTemplate(w, "basic", map[string]interface{}{
   258  		"foo": "bar",
   259  	}); err != nil {
   260  		http.Error(w, "Error occurred:"+err.Error(), http.StatusInternalServerError)
   261  	}
   262  }
   263  
   264  func (s *srv) logoutHandler(w http.ResponseWriter, req *http.Request) {
   265  	var domain string
   266  	if arr := strings.Split(req.Host, ":"); len(arr) > 0 {
   267  		domain = arr[0]
   268  	}
   269  
   270  	http.SetCookie(w, &http.Cookie{
   271  		Name:    TokenCookieName,
   272  		Value:   "",
   273  		Expires: time.Unix(0, 0),
   274  		Domain:  domain,
   275  		Secure:  true,
   276  	})
   277  
   278  	http.Redirect(w, req, "/", 302)
   279  }
   280  
   281  func (s *srv) generateTokenHandler(w http.ResponseWriter, req *http.Request) {
   282  	renderError := func(errMsg string) {
   283  		t, err := template.New("template").Parse(html.LoginTemplate)
   284  		if err != nil {
   285  			http.Error(w, "Error occurred:"+err.Error(), http.StatusInternalServerError)
   286  			return
   287  		}
   288  
   289  		if err := t.ExecuteTemplate(w, "basic", map[string]interface{}{
   290  			"error": errMsg,
   291  		}); err != nil {
   292  			http.Error(w, "Error occurred:"+err.Error(), http.StatusInternalServerError)
   293  		}
   294  	}
   295  
   296  	user := req.PostFormValue("username")
   297  	if len(user) == 0 {
   298  		renderError("Missing Username")
   299  		return
   300  	}
   301  
   302  	pass := req.PostFormValue("password")
   303  	if len(pass) == 0 {
   304  		renderError("Missing Password")
   305  		return
   306  	}
   307  
   308  	acc, err := auth.Token(
   309  		auth.WithCredentials(user, pass),
   310  		auth.WithTokenIssuer(Namespace),
   311  		auth.WithExpiry(time.Hour*24*7),
   312  	)
   313  	if err != nil {
   314  		renderError("Authentication failed: " + err.Error())
   315  		return
   316  	}
   317  
   318  	var domain string
   319  	if arr := strings.Split(req.Host, ":"); len(arr) > 0 {
   320  		domain = arr[0]
   321  	}
   322  
   323  	http.SetCookie(w, &http.Cookie{
   324  		Name:    TokenCookieName,
   325  		Value:   acc.AccessToken,
   326  		Expires: acc.Expiry,
   327  		Domain:  domain,
   328  		Secure:  true,
   329  	})
   330  
   331  	http.Redirect(w, req, "/", http.StatusFound)
   332  }
   333  
   334  func (s *srv) registryHandler(w http.ResponseWriter, r *http.Request) {
   335  	vars := mux.Vars(r)
   336  	svc := vars["name"]
   337  
   338  	domain := registry.DefaultDomain
   339  
   340  	if len(svc) > 0 {
   341  		sv, err := s.registry.GetService(svc, registry.GetDomain(domain))
   342  		if err != nil {
   343  			http.Error(w, "Error occurred:"+err.Error(), 500)
   344  			return
   345  		}
   346  
   347  		if len(sv) == 0 {
   348  			http.Error(w, "Not found", 404)
   349  			return
   350  		}
   351  
   352  		if r.Header.Get("Content-Type") == "application/json" {
   353  			b, err := json.Marshal(map[string]interface{}{
   354  				"services": s,
   355  			})
   356  			if err != nil {
   357  				http.Error(w, "Error occurred:"+err.Error(), 500)
   358  				return
   359  			}
   360  			w.Header().Set("Content-Type", "application/json")
   361  			w.Write(b)
   362  			return
   363  		}
   364  
   365  		s.render(w, r, html.ServiceTemplate, sv)
   366  		return
   367  	}
   368  
   369  	services, err := s.registry.ListServices(registry.ListDomain(domain))
   370  	if err != nil {
   371  		log.Printf("Error listing services: %v", err)
   372  	}
   373  
   374  	sort.Sort(sortedServices{services})
   375  
   376  	if r.Header.Get("Content-Type") == "application/json" {
   377  		b, err := json.Marshal(map[string]interface{}{
   378  			"services": services,
   379  		})
   380  		if err != nil {
   381  			http.Error(w, "Error occurred:"+err.Error(), 500)
   382  			return
   383  		}
   384  		w.Header().Set("Content-Type", "application/json")
   385  		w.Write(b)
   386  		return
   387  	}
   388  
   389  	s.render(w, r, html.RegistryTemplate, services)
   390  }
   391  
   392  func (s *srv) callHandler(w http.ResponseWriter, r *http.Request) {
   393  	domain := registry.DefaultDomain
   394  
   395  	services, err := s.registry.ListServices(registry.ListDomain(domain))
   396  	if err != nil {
   397  		log.Printf("Error listing services: %v", err)
   398  	}
   399  
   400  	sort.Sort(sortedServices{services})
   401  
   402  	serviceMap := make(map[string][]*registry.Endpoint)
   403  	for _, service := range services {
   404  		if len(service.Endpoints) > 0 {
   405  			serviceMap[service.Name] = service.Endpoints
   406  			continue
   407  		}
   408  		// lookup the endpoints otherwise
   409  		s, err := s.registry.GetService(service.Name, registry.GetDomain(domain))
   410  		if err != nil {
   411  			continue
   412  		}
   413  		if len(s) == 0 {
   414  			continue
   415  		}
   416  		serviceMap[service.Name] = s[0].Endpoints
   417  	}
   418  
   419  	if r.Header.Get("Content-Type") == "application/json" {
   420  		b, err := json.Marshal(map[string]interface{}{
   421  			"services": services,
   422  		})
   423  		if err != nil {
   424  			http.Error(w, "Error occurred:"+err.Error(), 500)
   425  			return
   426  		}
   427  		w.Header().Set("Content-Type", "application/json")
   428  		w.Write(b)
   429  		return
   430  	}
   431  
   432  	s.render(w, r, html.CallTemplate, serviceMap)
   433  }
   434  
   435  func (s *srv) serviceHandler(w http.ResponseWriter, r *http.Request) {
   436  	vars := mux.Vars(r)
   437  	name := vars["service"]
   438  	if len(name) == 0 {
   439  		return
   440  	}
   441  
   442  	domain := registry.DefaultDomain
   443  
   444  	services, err := s.registry.GetService(name, registry.GetDomain(domain))
   445  	if err != nil {
   446  		log.Printf("Error getting service %s: %v", name, err)
   447  	}
   448  
   449  	sort.Sort(sortedServices{services})
   450  
   451  	serviceMap := make(map[string][]*registry.Endpoint)
   452  
   453  	for _, service := range services {
   454  		if len(service.Endpoints) > 0 {
   455  			serviceMap[service.Name] = service.Endpoints
   456  			continue
   457  		}
   458  	}
   459  
   460  	if r.Header.Get("Content-Type") == "application/json" {
   461  		b, err := json.Marshal(map[string]interface{}{
   462  			"services": services,
   463  		})
   464  		if err != nil {
   465  			http.Error(w, "Error occurred:"+err.Error(), 500)
   466  			return
   467  		}
   468  		w.Header().Set("Content-Type", "application/json")
   469  		w.Write(b)
   470  		return
   471  	}
   472  
   473  	s.render(w, r, html.WebTemplate, serviceMap, templateValue{
   474  		Key:   "Name",
   475  		Value: name,
   476  	})
   477  }
   478  
   479  type templateValue struct {
   480  	Key   string
   481  	Value interface{}
   482  }
   483  
   484  func (s *srv) render(w http.ResponseWriter, r *http.Request, tmpl string, data interface{}, vals ...templateValue) {
   485  	t, err := template.New("template").Funcs(template.FuncMap{
   486  		"Split":  split,
   487  		"format": format,
   488  		"Title":  strings.Title,
   489  		"First": func(s string) string {
   490  			if len(s) == 0 {
   491  				return s
   492  			}
   493  			return strings.Title(string(s[0]))
   494  		},
   495  		"Endpoint": func(ep string) string {
   496  			return strings.Replace(ep, ".", "/", -1)
   497  		},
   498  	}).Parse(html.LayoutTemplate)
   499  	if err != nil {
   500  		http.Error(w, "Error occurred:"+err.Error(), 500)
   501  		return
   502  	}
   503  	t, err = t.Parse(tmpl)
   504  	if err != nil {
   505  		http.Error(w, "Error occurred:"+err.Error(), 500)
   506  		return
   507  	}
   508  
   509  	apiURL := API
   510  	u, err := url.Parse(apiURL)
   511  	if err != nil {
   512  		http.Error(w, "Error occurred:"+err.Error(), 500)
   513  		return
   514  	}
   515  
   516  	filepath.Join(u.Path, r.URL.Path)
   517  
   518  	// If the user is logged in, render Account instead of Login
   519  	loginTitle := "Login"
   520  	loginLink := LoginURL
   521  	user := ""
   522  	token := ""
   523  
   524  	sess, ok := r.Context().Value(session{}).(*session)
   525  	if ok {
   526  		token = sess.Token
   527  		user = sess.Account.ID
   528  		loginTitle = "Logout"
   529  		loginLink = "/logout"
   530  	}
   531  
   532  	templateData := map[string]interface{}{
   533  		"ApiURL":     apiURL,
   534  		"LoginTitle": loginTitle,
   535  		"LoginURL":   loginLink,
   536  		"Results":    data,
   537  		"User":       user,
   538  		"Token":      token,
   539  		"Namespace":  Namespace,
   540  	}
   541  
   542  	// add extra values
   543  	for _, val := range vals {
   544  		templateData[val.Key] = val.Value
   545  	}
   546  
   547  	if err := t.ExecuteTemplate(w, "layout",
   548  		templateData,
   549  	); err != nil {
   550  		http.Error(w, "Error occurred:"+err.Error(), 500)
   551  	}
   552  }
   553  
   554  func Run(ctx *cli.Context) error {
   555  	if len(ctx.String("api_address")) > 0 {
   556  		API = ctx.String("api_address")
   557  	}
   558  	if len(ctx.String("server_name")) > 0 {
   559  		Name = ctx.String("server_name")
   560  	}
   561  	if len(ctx.String("web_address")) > 0 {
   562  		Address = ctx.String("web_address")
   563  	}
   564  	if len(ctx.String("web_namespace")) > 0 {
   565  		Namespace = ctx.String("web_namespace")
   566  	}
   567  	if len(ctx.String("web_host")) > 0 {
   568  		Host = ctx.String("web_host")
   569  	}
   570  	if len(ctx.String("namespace")) > 0 {
   571  		// remove the service type from the namespace to allow for
   572  		// backwards compatability
   573  		Namespace = ctx.String("namespace")
   574  	}
   575  	// Setup auth redirect
   576  	if len(ctx.String("login_url")) > 0 {
   577  		LoginURL = ctx.String("login_url")
   578  	}
   579  
   580  	srv := &srv{
   581  		Router: mux.NewRouter(),
   582  		registry: &reg{
   583  			Registry: registry.DefaultRegistry,
   584  		},
   585  	}
   586  
   587  	htmlContent, err := fs.Sub(content, "html")
   588  	if err != nil {
   589  		log.Fatal(err)
   590  	}
   591  
   592  	// the web handler itself
   593  	srv.HandleFunc("/favicon.ico", faviconHandler)
   594  	srv.HandleFunc("/404", srv.notFoundHandler)
   595  	srv.HandleFunc("/login", srv.loginHandler)
   596  	srv.HandleFunc("/logout", srv.logoutHandler)
   597  	srv.HandleFunc("/client", srv.callHandler)
   598  	srv.HandleFunc("/services", srv.registryHandler)
   599  	srv.HandleFunc("/service/{name}", srv.registryHandler)
   600  	srv.PathPrefix("/assets/").Handler(http.FileServer(http.FS(htmlContent)))
   601  	srv.HandleFunc("/{service}", srv.serviceHandler)
   602  	srv.HandleFunc("/", srv.indexHandler)
   603  
   604  	// create new http server
   605  	server := &http.Server{
   606  		Addr:    Address,
   607  		Handler: srv,
   608  	}
   609  
   610  	if err := server.ListenAndServe(); err != nil {
   611  		log.Fatal(err)
   612  	}
   613  
   614  	return nil
   615  }
   616  
   617  var (
   618  	Flags = []cli.Flag{
   619  		&cli.StringFlag{
   620  			Name:    "api_address",
   621  			Usage:   "Set the api address to call e.g http://localhost:8080",
   622  			EnvVars: []string{"MICRO_API_ADDRESS"},
   623  		},
   624  		&cli.StringFlag{
   625  			Name:    "web_address",
   626  			Usage:   "Set the web UI address e.g 0.0.0.0:8082",
   627  			EnvVars: []string{"MICRO_WEB_ADDRESS"},
   628  		},
   629  		&cli.StringFlag{
   630  			Name:    "namespace",
   631  			Usage:   "Set the namespace used by the Web proxy e.g. com.example.web",
   632  			EnvVars: []string{"MICRO_WEB_NAMESPACE"},
   633  		},
   634  		&cli.StringFlag{
   635  			Name:    "login_url",
   636  			EnvVars: []string{"MICRO_WEB_LOGIN_URL"},
   637  			Usage:   "The relative URL where a user can login",
   638  		},
   639  	}
   640  )
   641  
   642  func reverse(s []string) {
   643  	for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
   644  		s[i], s[j] = s[j], s[i]
   645  	}
   646  }
   647  
   648  type sortedServices struct {
   649  	services []*registry.Service
   650  }
   651  
   652  func (s sortedServices) Len() int {
   653  	return len(s.services)
   654  }
   655  
   656  func (s sortedServices) Less(i, j int) bool {
   657  	return s.services[i].Name < s.services[j].Name
   658  }
   659  
   660  func (s sortedServices) Swap(i, j int) {
   661  	s.services[i], s.services[j] = s.services[j], s.services[i]
   662  }