diff --git a/satellite/admin/back-office/api-docs.gen.md b/satellite/admin/back-office/api-docs.gen.md new file mode 100644 index 000000000..e8052ec1d --- /dev/null +++ b/satellite/admin/back-office/api-docs.gen.md @@ -0,0 +1,26 @@ +# API Docs + +**Description:** + +**Version:** `v1` + +

List of Endpoints

+ +* Example + * [Get examples](#example-get-examples) + +

Get examples (go to full list)

+ +Get a list with the names of the all available examples + +`GET /api/v1/example/examples` + +**Response body:** + +```typescript +[ +string +] + +``` + diff --git a/satellite/admin/back-office/gen/main.go b/satellite/admin/back-office/gen/main.go new file mode 100644 index 000000000..f5b5b9aef --- /dev/null +++ b/satellite/admin/back-office/gen/main.go @@ -0,0 +1,63 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +// Package main defines the satellite administration API through the API generator and generates +// source code of the API server handlers and clients and the documentation markdown document. +package main + +import ( + "os" + "path/filepath" + + "storj.io/storj/private/apigen" +) + +func main() { + api := &apigen.API{PackageName: "admin", Version: "v1", BasePath: "/api"} + + // This is an example and must be deleted when we define the first real endpoint. + group := api.Group("Example", "example") + + group.Get("/examples", &apigen.Endpoint{ + Name: "Get examples", + Description: "Get a list with the names of the all available examples", + GoName: "GetExamples", + TypeScriptName: "getExamples", + Response: []string{}, + ResponseMock: []string{"example-1", "example-2", "example-3"}, + NoCookieAuth: false, + NoAPIAuth: false, + }) + + modroot := findModuleRootDir() + api.MustWriteGo(filepath.Join(modroot, "satellite", "admin", "back-office", "handlers.gen.go")) + api.MustWriteTS(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client.gen.ts")) + api.MustWriteTSMock(filepath.Join(modroot, "satellite", "admin", "back-office", "ui", "src", "api", "client-mock.gen.ts")) + api.MustWriteDocs(filepath.Join(modroot, "satellite", "admin", "back-office", "api-docs.gen.md")) +} + +func findModuleRootDir() string { + dir, err := os.Getwd() + if err != nil { + panic("unable to find current working directory") + } + start := dir + + for i := 0; i < 100; i++ { + if fileExists(filepath.Join(dir, "go.mod")) { + return dir + } + next := filepath.Dir(dir) + if next == dir { + break + } + dir = next + } + + panic("unable to find go.mod starting from " + start) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/satellite/admin/back-office/handlers.gen.go b/satellite/admin/back-office/handlers.gen.go new file mode 100644 index 000000000..cb212f2e2 --- /dev/null +++ b/satellite/admin/back-office/handlers.gen.go @@ -0,0 +1,71 @@ +// AUTOGENERATED BY private/apigen +// DO NOT EDIT. + +package admin + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/spacemonkeygo/monkit/v3" + "github.com/zeebo/errs" + "go.uber.org/zap" + + "storj.io/storj/private/api" +) + +var ErrExampleAPI = errs.Class("admin example api") + +type ExampleService interface { + GetExamples(ctx context.Context) ([]string, api.HTTPError) +} + +// ExampleHandler is an api handler that implements all Example API endpoints functionality. +type ExampleHandler struct { + log *zap.Logger + mon *monkit.Scope + service ExampleService + auth api.Auth +} + +func NewExample(log *zap.Logger, mon *monkit.Scope, service ExampleService, router *mux.Router, auth api.Auth) *ExampleHandler { + handler := &ExampleHandler{ + log: log, + mon: mon, + service: service, + auth: auth, + } + + exampleRouter := router.PathPrefix("/api/v1/example").Subrouter() + exampleRouter.HandleFunc("/examples", handler.handleGetExamples).Methods("GET") + + return handler +} + +func (h *ExampleHandler) handleGetExamples(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer h.mon.Task()(&ctx)(&err) + + w.Header().Set("Content-Type", "application/json") + + ctx, err = h.auth.IsAuthenticated(ctx, r, true, true) + if err != nil { + h.auth.RemoveAuthCookie(w) + api.ServeError(h.log, w, http.StatusUnauthorized, err) + return + } + + retVal, httpErr := h.service.GetExamples(ctx) + if httpErr.Err != nil { + api.ServeError(h.log, w, httpErr.Status, httpErr.Err) + return + } + + err = json.NewEncoder(w).Encode(retVal) + if err != nil { + h.log.Debug("failed to write json GetExamples response", zap.Error(ErrExampleAPI.Wrap(err))) + } +} diff --git a/satellite/admin/back-office/server.go b/satellite/admin/back-office/server.go new file mode 100644 index 000000000..1246d3cc5 --- /dev/null +++ b/satellite/admin/back-office/server.go @@ -0,0 +1,127 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +// Package admin implements a server which serves a REST API and a web application to allow +// performing satellite administration tasks. +// +// NOTE this is work in progress and will eventually replace the current satellite administration +// server implemented in the parent package, hence this package name is the same than its parent +// because it will simplify the replace once it's ready. +package admin + +import ( + "context" + "errors" + "net" + "net/http" + + "github.com/gorilla/mux" + "github.com/zeebo/errs" + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + + "storj.io/common/errs2" + ui "storj.io/storj/satellite/admin/back-office/ui" +) + +// Error is the error class that wraps all the errors returned by this package. +var Error = errs.Class("satellite-admin") + +// Config defines configuration for the satellite administration server. +type Config struct { + StaticDir string `help:"an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""` +} + +// Server serves the API endpoints and the web application to allow preforming satellite +// administration tasks. +type Server struct { + log *zap.Logger + + listener net.Listener + server http.Server + + config Config +} + +// ParentRouter is mux.Router with its full path prefix. +type ParentRouter struct { + Router *mux.Router + PathPrefix string +} + +// NewServer creates a satellite administration server instance with the provided dependencies and +// configurations. +// +// When listener is nil, Server.Run is a noop. +// +// When parentRouter is nil it creates a new Router to attach the server endpoints, otherwise , it +// attaches them to the provided one, allowing to expose its functionality through another server. +func NewServer(log *zap.Logger, listener net.Listener, parentRouter *ParentRouter, config Config) *Server { + server := &Server{ + log: log, + listener: listener, + config: config, + } + + if parentRouter == nil { + parentRouter = &ParentRouter{} + } + + root := parentRouter.Router + if root == nil { + root = mux.NewRouter() + } + + // API endpoints. + // api := root.PathPrefix("/api/").Subrouter() + + // Static assets for the web interface. + // This handler must be the last one because it uses the root as prefix, otherwise, it will serve + // all the paths defined by the handlers set after this one. + var staticHandler http.Handler + if config.StaticDir == "" { + if parentRouter.PathPrefix != "" { + staticHandler = http.StripPrefix("/back-office/", http.FileServer(http.FS(ui.Assets))) + } else { + staticHandler = http.FileServer(http.FS(ui.Assets)) + } + } else { + if parentRouter.PathPrefix != "" { + staticHandler = http.StripPrefix("/back-office/", http.FileServer(http.Dir(config.StaticDir))) + } else { + staticHandler = http.FileServer(http.Dir(config.StaticDir)) + } + } + + root.PathPrefix("/").Handler(staticHandler).Methods("GET") + + return server +} + +// Run starts the administration HTTP server using the provided listener. +// If listener is nil, it does nothing and return nil. +func (server *Server) Run(ctx context.Context) error { + if server.listener == nil { + return nil + } + ctx, cancel := context.WithCancel(ctx) + var group errgroup.Group + group.Go(func() error { + <-ctx.Done() + return Error.Wrap(server.server.Shutdown(context.Background())) + }) + group.Go(func() error { + defer cancel() + err := server.server.Serve(server.listener) + if errs2.IsCanceled(err) || errors.Is(err, http.ErrServerClosed) { + err = nil + } + return Error.Wrap(err) + }) + return group.Wait() +} + +// Close closes server and underlying listener. +func (server *Server) Close() error { + return Error.Wrap(server.server.Close()) +} diff --git a/satellite/admin/back-office/ui/src/api/client-mock.gen.ts b/satellite/admin/back-office/ui/src/api/client-mock.gen.ts new file mode 100644 index 000000000..88e6998d6 --- /dev/null +++ b/satellite/admin/back-office/ui/src/api/client-mock.gen.ts @@ -0,0 +1,39 @@ +// AUTOGENERATED BY private/apigen +// DO NOT EDIT. + +class APIError extends Error { + constructor( + public readonly msg: string, + public readonly responseStatusCode?: number, + ) { + super(msg); + } +} + +export class ExampleHttpApiV1 { + public readonly respStatusCode: number; + + // When respStatuscode is passed, the client throws an APIError on each method call + // with respStatusCode as HTTP status code. + // respStatuscode must be equal or greater than 400 + constructor(respStatusCode?: number) { + if (typeof respStatusCode === 'undefined') { + this.respStatusCode = 0; + return; + } + + if (respStatusCode < 400) { + throw new Error('invalid response status code for API Error, it must be greater or equal than 400'); + } + + this.respStatusCode = respStatusCode; + } + + public async getExamples(): Promise { + if (this.respStatusCode !== 0) { + throw new APIError('mock error message: ' + this.respStatusCode, this.respStatusCode); + } + + return JSON.parse('["example-1","example-2","example-3"]') as string[]; + } +} diff --git a/satellite/admin/back-office/ui/src/api/client.gen.ts b/satellite/admin/back-office/ui/src/api/client.gen.ts new file mode 100644 index 000000000..5d959f0ed --- /dev/null +++ b/satellite/admin/back-office/ui/src/api/client.gen.ts @@ -0,0 +1,28 @@ +// AUTOGENERATED BY private/apigen +// DO NOT EDIT. + +import { HttpClient } from '@/utils/httpClient'; + +class APIError extends Error { + constructor( + public readonly msg: string, + public readonly responseStatusCode?: number, + ) { + super(msg); + } +} + +export class ExampleHttpApiV1 { + private readonly http: HttpClient = new HttpClient(); + private readonly ROOT_PATH: string = '/api/v1/example'; + + public async getExamples(): Promise { + const fullPath = `${this.ROOT_PATH}/examples`; + const response = await this.http.get(fullPath); + if (response.ok) { + return response.json().then((body) => body as string[]); + } + const err = await response.json(); + throw new APIError(err.error, response.status); + } +} diff --git a/satellite/admin/back-office/ui/src/api/errors/ErrorUnauthorized.ts b/satellite/admin/back-office/ui/src/api/errors/ErrorUnauthorized.ts new file mode 100644 index 000000000..8616ad03d --- /dev/null +++ b/satellite/admin/back-office/ui/src/api/errors/ErrorUnauthorized.ts @@ -0,0 +1,11 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +/** + * ErrorUnauthorized is a custom error type for performing unauthorized operations. + */ +export class ErrorUnauthorized extends Error { + public constructor(message = 'Authorization required') { + super(message); + } +} diff --git a/satellite/admin/back-office/ui/src/utils/httpClient.ts b/satellite/admin/back-office/ui/src/utils/httpClient.ts new file mode 100644 index 000000000..c43c23469 --- /dev/null +++ b/satellite/admin/back-office/ui/src/utils/httpClient.ts @@ -0,0 +1,106 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; + +/** + * HttpClient is a custom wrapper around fetch api. + * Exposes get, post and delete methods for JSON strings. + */ +export class HttpClient { + /** + * + * @param method holds http method type + * @param path + * @param body serialized JSON + */ + private async sendJSON(method: string, path: string, body: string | null): Promise { + const request: RequestInit = { + method: method, + body: body, + }; + + request.headers = { + 'Content-Type': 'application/json', + }; + + const response = await fetch(path, request); + if (response.status === 401) { + await this.handleUnauthorized(); + throw new ErrorUnauthorized(); + } + + return response; + } + + /** + * Performs POST http request with JSON body. + * @param path + * @param body serialized JSON + */ + public async post(path: string, body: string | null): Promise { + return this.sendJSON('POST', path, body); + } + + /** + * Performs PATCH http request with JSON body. + * @param path + * @param body serialized JSON + */ + public async patch(path: string, body: string | null): Promise { + return this.sendJSON('PATCH', path, body); + } + + /** + * Performs PUT http request with JSON body. + * @param path + * @param body serialized JSON + */ + public async put(path: string, body: string | null): Promise { + return this.sendJSON('PUT', path, body); + } + + /** + * Performs GET http request. + * @param path + */ + public async get(path: string): Promise { + return this.sendJSON('GET', path, null); + } + + /** + * Performs DELETE http request. + * @param path + * @param body serialized JSON + */ + public async delete(path: string, body: string | null = null): Promise { + return this.sendJSON('DELETE', path, body); + } + + /** + * Handles unauthorized actions. + * Call logout and redirect to login. + */ + private async handleUnauthorized(): Promise { + try { + const logoutPath = '/api/v0/auth/logout'; + const request: RequestInit = { + method: 'POST', + body: null, + }; + + request.headers = { + 'Content-Type': 'application/json', + }; + + await fetch(logoutPath, request); + // eslint-disable-next-line no-empty + } catch (error) {} + + setTimeout(() => { + if (!window.location.href.includes('/login')) { + window.location.href = window.location.origin + '/login'; + } + }, 2000); + } +} diff --git a/satellite/admin/server.go b/satellite/admin/server.go index 6d88d1578..cc7a40181 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -20,7 +20,7 @@ import ( "storj.io/common/errs2" "storj.io/storj/satellite/accounting" - backofficeui "storj.io/storj/satellite/admin/back-office/ui" + backoffice "storj.io/storj/satellite/admin/back-office" adminui "storj.io/storj/satellite/admin/ui" "storj.io/storj/satellite/analytics" "storj.io/storj/satellite/attribution" @@ -44,13 +44,13 @@ const ( // Config defines configuration for debug server. type Config struct { - Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""` - StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""` - StaticDirBackOffice string `help:"an alternate directory path which contains the static assets for the currently in development back-office. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""` - AllowedOauthHost string `help:"the oauth host allowed to bypass token authentication."` - Groups Groups + Address string `help:"admin peer http listening address" releaseDefault:"" devDefault:""` + StaticDir string `help:"an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets" releaseDefault:"" devDefault:""` + AllowedOauthHost string `help:"the oauth host allowed to bypass token authentication."` + Groups Groups AuthorizationToken string `internal:"true"` + BackOffice backoffice.Config } // Groups defines permission groups. @@ -178,16 +178,15 @@ func NewServer( limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET") limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") - // Temporary path until the new back-office is implemented and we can remove the current admin UI. - if config.StaticDirBackOffice == "" { - root.PathPrefix("/back-office").Handler( - http.StripPrefix("/back-office/", http.FileServer(http.FS(backofficeui.Assets))), - ).Methods("GET") - } else { - root.PathPrefix("/back-office").Handler( - http.StripPrefix("/back-office/", http.FileServer(http.Dir(config.StaticDirBackOffice))), - ).Methods("GET") - } + _ = backoffice.NewServer( + log.Named("back-office"), + nil, + &backoffice.ParentRouter{ + Router: root.PathPrefix("/back-office/").Subrouter(), + PathPrefix: "/back-office", + }, + config.BackOffice, + ) // This handler must be the last one because it uses the root as prefix, // otherwise will try to serve all the handlers set after this one. diff --git a/satellite/admin/server_test.go b/satellite/admin/server_test.go index def674128..9947f57fc 100644 --- a/satellite/admin/server_test.go +++ b/satellite/admin/server_test.go @@ -28,7 +28,7 @@ func TestBasic(t *testing.T) { Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) { config.Admin.Address = "127.0.0.1:0" config.Admin.StaticDir = "ui" - config.Admin.StaticDirBackOffice = "back-office/ui" + config.Admin.BackOffice.StaticDir = "back-office/ui" }, }, }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 81a4a90c9..779695711 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -16,15 +16,15 @@ # the oauth host allowed to bypass token authentication. # admin.allowed-oauth-host: "" +# an alternate directory path which contains the static assets for the satellite administration web app. When empty, it uses the embedded assets +# admin.back-office.static-dir: "" + # the group which is only allowed to update user and project limits and freeze and unfreeze accounts. # admin.groups.limit-update: "" # an alternate directory path which contains the static assets to serve. When empty, it uses the embedded assets # admin.static-dir: "" -# an alternate directory path which contains the static assets for the currently in development back-office. When empty, it uses the embedded assets -# admin.static-dir-back-office: "" - # enable analytics reporting # analytics.enabled: false