// Copyright (C) 2019 Storj Labs, Inc. // See LICENSE for copying information. package consoleweb import ( "context" "encoding/json" "net" "net/http" "path/filepath" "strconv" "strings" "github.com/graphql-go/graphql" "github.com/zeebo/errs" "go.uber.org/zap" "golang.org/x/sync/errgroup" "storj.io/storj/pkg/auth" "storj.io/storj/satellite/console" "storj.io/storj/satellite/console/consoleweb/consoleql" "storj.io/storj/satellite/mailservice" ) const ( authorization = "Authorization" contentType = "Content-Type" authorizationBearer = "Bearer " applicationJSON = "application/json" applicationGraphql = "application/graphql" ) // Error is satellite console error type var Error = errs.Class("satellite console error") // Config contains configuration for console web server type Config struct { Address string `help:"server address of the graphql api gateway and frontend app" default:"127.0.0.1:8081"` StaticDir string `help:"path to static resources" default:""` ExternalAddress string `help:"external endpoint of the satellite if hosted" default:""` // TODO: remove after Vanguard release AuthToken string `help:"auth token needed for access to registration token creation endpoint" default:""` PasswordCost int `internal:"true" help:"password hashing cost (0=automatic)" default:"0"` } // Server represents console web server type Server struct { log *zap.Logger config Config service *console.Service mailService *mailservice.Service listener net.Listener server http.Server schema graphql.Schema } // NewServer creates new instance of console server func NewServer(logger *zap.Logger, config Config, service *console.Service, mailService *mailservice.Service, listener net.Listener) *Server { server := Server{ log: logger, config: config, listener: listener, service: service, mailService: mailService, } logger.Debug("Starting Satellite UI...") if server.config.ExternalAddress != "" { if !strings.HasSuffix(server.config.ExternalAddress, "/") { server.config.ExternalAddress = server.config.ExternalAddress + "/" } } else { server.config.ExternalAddress = "http://" + server.listener.Addr().String() + "/" } mux := http.NewServeMux() fs := http.FileServer(http.Dir(server.config.StaticDir)) mux.Handle("/api/graphql/v0", http.HandlerFunc(server.grapqlHandler)) if server.config.StaticDir != "" { mux.Handle("/activation/", http.HandlerFunc(server.accountActivationHandler)) mux.Handle("/registrationToken/", http.HandlerFunc(server.createRegistrationTokenHandler)) mux.Handle("/static/", http.StripPrefix("/static", fs)) mux.Handle("/", http.HandlerFunc(server.appHandler)) } server.server = http.Server{ Handler: mux, } return &server } // appHandler is web app http handler function func (s *Server) appHandler(w http.ResponseWriter, req *http.Request) { http.ServeFile(w, req, filepath.Join(s.config.StaticDir, "dist", "public", "index.html")) } // accountActivationHandler is web app http handler function func (s *Server) createRegistrationTokenHandler(w http.ResponseWriter, req *http.Request) { w.Header().Set(contentType, applicationJSON) var response struct { Secret string `json:"secret"` Error string `json:"error,omitempty"` } defer func() { err := json.NewEncoder(w).Encode(&response) if err != nil { s.log.Error(err.Error()) } }() authToken := req.Header.Get("Authorization") if authToken != s.config.AuthToken { w.WriteHeader(401) response.Error = "unauthorized" return } projectsLimitInput := req.URL.Query().Get("projectsLimit") projectsLimit, err := strconv.Atoi(projectsLimitInput) if err != nil { response.Error = err.Error() return } token, err := s.service.CreateRegToken(context.Background(), projectsLimit) if err != nil { response.Error = err.Error() return } response.Secret = token.Secret.String() } // accountActivationHandler is web app http handler function func (s *Server) accountActivationHandler(w http.ResponseWriter, req *http.Request) { activationToken := req.URL.Query().Get("token") err := s.service.ActivateAccount(context.Background(), activationToken) if err != nil { s.log.Error("activation: failed to activate account", zap.String("token", activationToken), zap.Error(err)) http.ServeFile(w, req, filepath.Join(s.config.StaticDir, "static", "errors", "404.html")) return } http.ServeFile(w, req, filepath.Join(s.config.StaticDir, "static", "activation", "success.html")) } // grapqlHandler is graphql endpoint http handler function func (s *Server) grapqlHandler(w http.ResponseWriter, req *http.Request) { w.Header().Set(contentType, applicationJSON) token := getToken(req) query, err := getQuery(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } ctx := auth.WithAPIKey(context.Background(), []byte(token)) auth, err := s.service.Authorize(ctx) if err != nil { ctx = console.WithAuthFailure(ctx, err) } else { ctx = console.WithAuth(ctx, auth) } rootObject := make(map[string]interface{}) rootObject["origin"] = s.config.ExternalAddress rootObject[consoleql.ActivationPath] = "activation/?token=" rootObject[consoleql.SignInPath] = "login" result := graphql.Do(graphql.Params{ Schema: s.schema, Context: ctx, RequestString: query.Query, VariableValues: query.Variables, OperationName: query.OperationName, RootObject: rootObject, }) err = json.NewEncoder(w).Encode(result) if err != nil { s.log.Error(err.Error()) return } sugar := s.log.Sugar() sugar.Debug(result) } // Run starts the server that host webapp and api endpoint func (s *Server) Run(ctx context.Context) error { var err error s.schema, err = consoleql.CreateSchema(s.log, s.service, s.mailService) if err != nil { return Error.Wrap(err) } ctx, cancel := context.WithCancel(ctx) var group errgroup.Group group.Go(func() error { <-ctx.Done() return s.server.Shutdown(nil) }) group.Go(func() error { defer cancel() return s.server.Serve(s.listener) }) return group.Wait() } // Close closes server and underlying listener func (s *Server) Close() error { return s.server.Close() }