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: ®{ 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 }