diff --git a/backend/embed/api_docs/api.swagger.json b/backend/embed/api_docs/api.swagger.json index 7aa7197..04957aa 100644 --- a/backend/embed/api_docs/api.swagger.json +++ b/backend/embed/api_docs/api.swagger.json @@ -163,6 +163,11 @@ "$ref": "file://./paths/tokens/post.json" } }, + "/tokens/sse": { + "post": { + "$ref": "file://./paths/tokens/sse/post.json" + } + }, "/upstreams": { "get": { "$ref": "file://./paths/upstreams/get.json" diff --git a/backend/embed/api_docs/paths/tokens/get.json b/backend/embed/api_docs/paths/tokens/get.json index 601697a..902478e 100644 --- a/backend/embed/api_docs/paths/tokens/get.json +++ b/backend/embed/api_docs/paths/tokens/get.json @@ -1,21 +1,17 @@ { "operationId": "refreshToken", "summary": "Refresh your access token", - "tags": [ - "Tokens" - ], + "tags": ["Tokens"], "responses": { "200": { "description": "200 response", "content": { "application/json": { "schema": { - "required": [ - "result" - ], + "required": ["result"], "properties": { "result": { - "$ref": "#/components/schemas/StreamObject" + "$ref": "#/components/schemas/TokenObject" } } }, @@ -34,4 +30,4 @@ } } } -} \ No newline at end of file +} diff --git a/backend/embed/api_docs/paths/tokens/post.json b/backend/embed/api_docs/paths/tokens/post.json index 44b95b2..55d76ec 100644 --- a/backend/embed/api_docs/paths/tokens/post.json +++ b/backend/embed/api_docs/paths/tokens/post.json @@ -1,9 +1,7 @@ { "operationId": "requestToken", "summary": "Request a new access token from credentials", - "tags": [ - "Tokens" - ], + "tags": ["Tokens"], "requestBody": { "description": "Credentials Payload", "required": true, @@ -19,12 +17,10 @@ "content": { "application/json": { "schema": { - "required": [ - "result" - ], + "required": ["result"], "properties": { "result": { - "$ref": "#/components/schemas/StreamObject" + "$ref": "#/components/schemas/TokenObject" } } }, @@ -49,9 +45,7 @@ "schema": { "type": "object", "additionalProperties": false, - "required": [ - "error" - ], + "required": ["error"], "properties": { "result": { "nullable": true @@ -76,4 +70,4 @@ } } } -} \ No newline at end of file +} diff --git a/backend/embed/api_docs/paths/tokens/sse/post.json b/backend/embed/api_docs/paths/tokens/sse/post.json new file mode 100644 index 0000000..8e547a0 --- /dev/null +++ b/backend/embed/api_docs/paths/tokens/sse/post.json @@ -0,0 +1,33 @@ +{ + "operationId": "requestSSEToken", + "summary": "Request a new SSE token", + "tags": ["Tokens"], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "required": ["result"], + "properties": { + "result": { + "$ref": "#/components/schemas/TokenObject" + } + } + }, + "examples": { + "default": { + "value": { + "result": { + "expires": 1566540510, + "token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4", + "scope": "user" + } + } + } + } + } + } + } + } +} diff --git a/backend/go.mod b/backend/go.mod index d3eb712..ceab1db 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/go-chi/chi v4.1.2+incompatible github.com/go-chi/cors v1.2.1 github.com/go-chi/jwtauth v4.0.4+incompatible + github.com/jc21/go-sse v0.0.0-20230307041911-8ea9bdc44a58 github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760 github.com/jmoiron/sqlx v1.3.5 github.com/mattn/go-sqlite3 v1.14.16 diff --git a/backend/go.sum b/backend/go.sum index 10f07f7..5600a8a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -27,6 +27,12 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/jc21/go-sse v0.0.0-20230307004720-e0a2266806a8 h1:3zrxixsRpzrMXd/c6vUHaoIi9OqirEPxPe4Ydxn3jNU= +github.com/jc21/go-sse v0.0.0-20230307004720-e0a2266806a8/go.mod h1:4v5Xmm0eYuaWqKJ63XUV5YfQPoxtId3DgDytbnWhi+s= +github.com/jc21/go-sse v0.0.0-20230307015818-b2783ddda573 h1:aaRu9mFSjxNfbXWVe7MlarmuB0vcdTShXFbxjzHAseA= +github.com/jc21/go-sse v0.0.0-20230307015818-b2783ddda573/go.mod h1:4v5Xmm0eYuaWqKJ63XUV5YfQPoxtId3DgDytbnWhi+s= +github.com/jc21/go-sse v0.0.0-20230307041911-8ea9bdc44a58 h1:WSD0YdEuFPZHIe8hkAjxoAEWZnzieAiLg3zw28EVf80= +github.com/jc21/go-sse v0.0.0-20230307041911-8ea9bdc44a58/go.mod h1:4v5Xmm0eYuaWqKJ63XUV5YfQPoxtId3DgDytbnWhi+s= github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760 h1:7wxq2DIgtO36KLrFz1RldysO0WVvcYsD49G9tyAs01k= github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760/go.mod h1:yIq2t51OJgVsdRlPY68NAnyVdBH0kYXxDTFtUxOap80= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= diff --git a/backend/internal/api/handler/certificates.go b/backend/internal/api/handler/certificates.go index f27d96a..5bba55b 100644 --- a/backend/internal/api/handler/certificates.go +++ b/backend/internal/api/handler/certificates.go @@ -39,23 +39,11 @@ func GetCertificates() func(http.ResponseWriter, *http.Request) { // Route: GET /certificates/{certificateID} func GetCertificate() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - var err error - var certificateID int - if certificateID, err = getURLParamInt(r, "certificateID"); err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) - return - } - - item, err := certificate.GetByID(certificateID) - switch err { - case sql.ErrNoRows: - h.NotFound(w, r) - case nil: + logger.Debug("here") + if item := getCertificateFromRequest(w, r); item != nil { // nolint: errcheck,gosec item.Expand(getExpandFromContext(r)) h.ResultResponseJSON(w, r, http.StatusOK, item) - default: - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) } } } @@ -64,27 +52,20 @@ func GetCertificate() func(http.ResponseWriter, *http.Request) { // Route: POST /certificates func CreateCertificate() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) + var item certificate.Model + if fillObjectFromBody(w, r, "", &item) { + // Get userID from token + userID, _ := r.Context().Value(c.UserIDCtxKey).(int) + item.UserID = userID - var newCertificate certificate.Model - err := json.Unmarshal(bodyBytes, &newCertificate) - if err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) - return + if err := item.Save(); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Certificate: %s", err.Error()), nil) + return + } + + configureCertificate(item) + h.ResultResponseJSON(w, r, http.StatusOK, item) } - - // Get userID from token - userID, _ := r.Context().Value(c.UserIDCtxKey).(int) - newCertificate.UserID = userID - - if err = newCertificate.Save(); err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Certificate: %s", err.Error()), nil) - return - } - - configureCertificate(newCertificate) - - h.ResultResponseJSON(w, r, http.StatusOK, newCertificate) } } @@ -92,49 +73,19 @@ func CreateCertificate() func(http.ResponseWriter, *http.Request) { // Route: PUT /certificates/{certificateID} func UpdateCertificate() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - var err error - var certificateID int - if certificateID, err = getURLParamInt(r, "certificateID"); err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) - return - } - - certificateObject, err := certificate.GetByID(certificateID) - switch err { - case sql.ErrNoRows: - h.NotFound(w, r) - case nil: + if item := getCertificateFromRequest(w, r); item != nil { // This is a special endpoint, as it needs to verify the schema payload // based on the certificate type, without being given a type in the payload. // The middleware would normally handle this. - bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) - schemaErrors, jsonErr := middleware.CheckRequestSchema(r.Context(), schema.UpdateCertificate(certificateObject.Type), bodyBytes) - if jsonErr != nil { - h.ResultErrorJSON(w, r, http.StatusInternalServerError, fmt.Sprintf("Schema Fatal: %v", jsonErr), nil) - return + if fillObjectFromBody(w, r, schema.UpdateCertificate(item.Type), item) { + if err := item.Save(); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + // configureCertificate(*item, item.Request) + h.ResultResponseJSON(w, r, http.StatusOK, item) } - - if len(schemaErrors) > 0 { - h.ResultSchemaErrorJSON(w, r, schemaErrors) - return - } - - err := json.Unmarshal(bodyBytes, &certificateObject) - if err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) - return - } - - if err = certificateObject.Save(); err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) - return - } - - configureCertificate(certificateObject) - - h.ResultResponseJSON(w, r, http.StatusOK, certificateObject) - default: - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) } } } @@ -143,31 +94,87 @@ func UpdateCertificate() func(http.ResponseWriter, *http.Request) { // Route: DELETE /certificates/{certificateID} func DeleteCertificate() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - var err error - var certificateID int - if certificateID, err = getURLParamInt(r, "certificateID"); err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) - return - } - - item, err := certificate.GetByID(certificateID) - switch err { - case sql.ErrNoRows: - h.NotFound(w, r) - case nil: - // Ensure that this upstream isn't in use by a host - cnt := host.GetCertificateUseCount(certificateID) + if item := getCertificateFromRequest(w, r); item != nil { + cnt := host.GetCertificateUseCount(item.ID) if cnt > 0 { h.ResultErrorJSON(w, r, http.StatusBadRequest, "Cannot delete certificate that is in use by at least 1 host", nil) return } h.ResultResponseJSON(w, r, http.StatusOK, item.Delete()) - default: - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) } } } +// RenewCertificate is self explanatory +// Route: PUT /certificates/{certificateID}/renew +func RenewCertificate() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if item := getCertificateFromRequest(w, r); item != nil { + configureCertificate(*item) + h.ResultResponseJSON(w, r, http.StatusOK, true) + } + } +} + +// DownloadCertificate is self explanatory +// Route: PUT /certificates/{certificateID}/download +func DownloadCertificate() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if item := getCertificateFromRequest(w, r); item != nil { + // todo + h.ResultResponseJSON(w, r, http.StatusOK, "ok") + } + } +} + +// getCertificateFromRequest has some reusable code for all endpoints that +// have a certificate id in the url. it will write errors to the output. +func getCertificateFromRequest(w http.ResponseWriter, r *http.Request) *certificate.Model { + var err error + var certificateID int + if certificateID, err = getURLParamInt(r, "certificateID"); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return nil + } + + certificateObject, err := certificate.GetByID(certificateID) + switch err { + case sql.ErrNoRows: + h.NotFound(w, r) + case nil: + return &certificateObject + default: + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + } + return nil +} + +// getCertificateFromRequest has some reusable code for all endpoints that +// have a certificate id in the url. it will write errors to the output. +func fillObjectFromBody(w http.ResponseWriter, r *http.Request, validationSchema string, o interface{}) bool { + bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) + + if validationSchema != "" { + schemaErrors, jsonErr := middleware.CheckRequestSchema(r.Context(), validationSchema, bodyBytes) + if jsonErr != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, fmt.Sprintf("Schema Fatal: %v", jsonErr), nil) + return false + } + if len(schemaErrors) > 0 { + h.ResultSchemaErrorJSON(w, r, schemaErrors) + return false + } + } + + err := json.Unmarshal(bodyBytes, o) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) + return false + } + + return true +} + func configureCertificate(c certificate.Model) { err := jobqueue.AddJob(jobqueue.Job{ Name: "RequestCertificate", diff --git a/backend/internal/api/handler/sse_notification.go b/backend/internal/api/handler/sse_notification.go new file mode 100644 index 0000000..998af5f --- /dev/null +++ b/backend/internal/api/handler/sse_notification.go @@ -0,0 +1,28 @@ +package handler + +import ( + "encoding/json" + "net/http" + + c "npm/internal/api/context" + h "npm/internal/api/http" + "npm/internal/serverevents" +) + +// TestSSENotification specifically fires of a SSE message for testing purposes +// Route: POST /sse-notification +func TestSSENotification() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) + + var msg serverevents.Message + err := json.Unmarshal(bodyBytes, &msg) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) + return + } + + serverevents.Send(msg, "") + h.ResultResponseJSON(w, r, http.StatusOK, true) + } +} diff --git a/backend/internal/api/handler/tokens.go b/backend/internal/api/handler/tokens.go index 2f317a9..1c8e889 100644 --- a/backend/internal/api/handler/tokens.go +++ b/backend/internal/api/handler/tokens.go @@ -65,7 +65,7 @@ func NewToken() func(http.ResponseWriter, *http.Request) { return } - if response, err := njwt.Generate(&userObj); err != nil { + if response, err := njwt.Generate(&userObj, false); err != nil { h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) } else { h.ResultResponseJSON(w, r, http.StatusOK, response) @@ -80,7 +80,34 @@ func RefreshToken() func(http.ResponseWriter, *http.Request) { // TODO: Use your own methods to verify an existing user is // able to refresh their token and then give them a new one userObj, _ := user.GetByEmail("jc@jc21.com") - if response, err := njwt.Generate(&userObj); err != nil { + if response, err := njwt.Generate(&userObj, false); err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, response) + } + } +} + +// NewSSEToken will generate and return a very short lived token for +// use by the /sse/* endpoint. It requires an app token to generate this +// Route: POST /tokens/sse +func NewSSEToken() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + userID := r.Context().Value(c.UserIDCtxKey).(int) + + // Find user + userObj, userErr := user.GetByID(userID) + if userErr != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) + return + } + + if userObj.IsDisabled { + h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil) + return + } + + if response, err := njwt.Generate(&userObj, true); err != nil { h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) } else { h.ResultResponseJSON(w, r, http.StatusOK, response) diff --git a/backend/internal/api/middleware/sse_auth.go b/backend/internal/api/middleware/sse_auth.go new file mode 100644 index 0000000..023e660 --- /dev/null +++ b/backend/internal/api/middleware/sse_auth.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "net/http" + + h "npm/internal/api/http" + "npm/internal/entity/user" + + "github.com/go-chi/jwtauth" +) + +// SSEAuth will validate that the jwt token provided to get this far is a SSE token +// and that the user is enabled +func SSEAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + token, claims, err := jwtauth.FromContext(ctx) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusUnauthorized, err.Error(), nil) + return + } + + userID := int(claims["uid"].(float64)) + _, enabled := user.IsEnabled(userID) + if token == nil || !token.Valid || !enabled || !claims.VerifyIssuer("sse", true) { + h.ResultErrorJSON(w, r, http.StatusUnauthorized, "Unauthorised", nil) + return + } + + // Should be all good now + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 3ea5bfc..5d0a17c 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -19,6 +19,7 @@ import ( "npm/internal/entity/upstream" "npm/internal/entity/user" "npm/internal/logger" + "npm/internal/serverevents" "github.com/go-chi/chi" chiMiddleware "github.com/go-chi/chi/middleware" @@ -45,7 +46,6 @@ func NewRouter() http.Handler { chiMiddleware.RealIP, chiMiddleware.Recoverer, chiMiddleware.Throttle(5), - chiMiddleware.Timeout(30*time.Second), middleware.PrettyPrint, middleware.Expansion, middleware.DecodeAuth(), @@ -61,8 +61,14 @@ func applyRoutes(r chi.Router) chi.Router { r.NotFound(handler.NotFound()) r.MethodNotAllowed(handler.NotAllowed()) + // SSE - requires a sse token as the `jwt` get parameter + // Exists inside /api but it's here so that we can skip the Timeout middleware + // that applies to other endpoints. + r.With(middleware.EnforceSetup(true), middleware.SSEAuth). + Mount("/api/sse", serverevents.Get()) + // API - r.Route("/api", func(r chi.Router) { + r.With(chiMiddleware.Timeout(30*time.Second)).Route("/api", func(r chi.Router) { r.Get("/", handler.Health()) r.Get("/schema", handler.Schema()) r.With(middleware.EnforceSetup(true), middleware.Enforce("")). @@ -74,34 +80,54 @@ func applyRoutes(r chi.Router) chi.Router { Post("/", handler.NewToken()) r.With(middleware.Enforce("")). Get("/", handler.RefreshToken()) + r.With(middleware.Enforce("")). + Post("/sse", handler.NewSSEToken()) }) // Users r.Route("/users", func(r chi.Router) { - r.With(middleware.EnforceSetup(true), middleware.Enforce("")).Get("/{userID:(?:me)}", handler.GetUser()) - r.With(middleware.EnforceSetup(true), middleware.Enforce(user.CapabilityUsersManage)).Get("/{userID:(?:[0-9]+)}", handler.GetUser()) - - r.With(middleware.EnforceSetup(true), middleware.Enforce(user.CapabilityUsersManage)).Delete("/{userID:(?:[0-9]+|me)}", handler.DeleteUser()) - r.With(middleware.EnforceSetup(true), middleware.Enforce(user.CapabilityUsersManage)).With(middleware.Filters(user.GetFilterSchema())). - Get("/", handler.GetUsers()) - r.With(middleware.EnforceRequestSchema(schema.CreateUser()), middleware.Enforce(user.CapabilityUsersManage)). + // Create - can be done in Setup stage as well + r.With(middleware.Enforce(user.CapabilityUsersManage), middleware.EnforceRequestSchema(schema.CreateUser())). Post("/", handler.CreateUser()) - r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.UpdateUser()), middleware.Enforce("")). - Put("/{userID:(?:me)}", handler.UpdateUser()) - r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.UpdateUser()), middleware.Enforce(user.CapabilityUsersManage)). - Put("/{userID:(?:[0-9]+)}", handler.UpdateUser()) + // Requires Setup stage to be completed + r.With(middleware.EnforceSetup(true)).Route("/", func(r chi.Router) { + // Get yourself, requires a login but no other permissions + r.With(middleware.Enforce("")). + Get("/{userID:(?:me)}", handler.GetUser()) - // Auth - r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.SetAuth()), middleware.Enforce("")). - Post("/{userID:(?:me)}/auth", handler.SetAuth()) - r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.SetAuth()), middleware.Enforce(user.CapabilityUsersManage)). - Post("/{userID:(?:[0-9]+)}/auth", handler.SetAuth()) + // Update yourself, requires a login but no other permissions + r.With(middleware.Enforce(""), middleware.EnforceRequestSchema(schema.UpdateUser())). + Put("/{userID:(?:me)}", handler.UpdateUser()) + + r.With(middleware.Enforce(user.CapabilityUsersManage)).Route("/", func(r chi.Router) { + // List + r.With(middleware.Enforce(user.CapabilityUsersManage), middleware.Filters(user.GetFilterSchema())). + Get("/", handler.GetUsers()) + + // Specific Item + r.Get("/{userID:(?:[0-9]+)}", handler.GetUser()) + r.Delete("/{userID:(?:[0-9]+|me)}", handler.DeleteUser()) + + // Update another user + r.With(middleware.EnforceRequestSchema(schema.UpdateUser())). + Put("/{userID:(?:[0-9]+)}", handler.UpdateUser()) + }) + + // Auth - sets passwords + r.With(middleware.Enforce(""), middleware.EnforceRequestSchema(schema.SetAuth())). + Post("/{userID:(?:me)}/auth", handler.SetAuth()) + r.With(middleware.Enforce(user.CapabilityUsersManage), middleware.EnforceRequestSchema(schema.SetAuth())). + Post("/{userID:(?:[0-9]+)}/auth", handler.SetAuth()) + }) }) - // Only available in debug mode: delete users without auth + // Only available in debug mode if config.GetLogLevel() == logger.DebugLevel { + // delete users without auth r.Delete("/users", handler.DeleteUsers()) + // SSE test endpoints + r.Post("/sse-notification", handler.TestSSENotification()) } // Settings @@ -117,27 +143,48 @@ func applyRoutes(r chi.Router) chi.Router { // Access Lists r.With(middleware.EnforceSetup(true)).Route("/access-lists", func(r chi.Router) { + // List r.With(middleware.Filters(accesslist.GetFilterSchema()), middleware.Enforce(user.CapabilityAccessListsView)). Get("/", handler.GetAccessLists()) - r.With(middleware.Enforce(user.CapabilityAccessListsView)).Get("/{accessListID:[0-9]+}", handler.GetAccessList()) - r.With(middleware.Enforce(user.CapabilityAccessListsManage)).Delete("/{accessListID:[0-9]+}", handler.DeleteAccessList()) - r.With(middleware.Enforce(user.CapabilityAccessListsManage)).With(middleware.EnforceRequestSchema(schema.CreateAccessList())). + + // Create + r.With(middleware.Enforce(user.CapabilityAccessListsManage), middleware.EnforceRequestSchema(schema.CreateAccessList())). Post("/", handler.CreateAccessList()) - r.With(middleware.Enforce(user.CapabilityAccessListsManage)).With(middleware.EnforceRequestSchema(schema.UpdateAccessList())). - Put("/{accessListID:[0-9]+}", handler.UpdateAccessList()) + + // Specific Item + r.Route("/{accessListID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityAccessListsView)). + Get("/", handler.GetAccessList()) + r.With(middleware.Enforce(user.CapabilityAccessListsManage)).Route("/", func(r chi.Router) { + r.Delete("/{accessListID:[0-9]+}", handler.DeleteAccessList()) + r.With(middleware.EnforceRequestSchema(schema.UpdateAccessList())). + Put("/{accessListID:[0-9]+}", handler.UpdateAccessList()) + }) + }) }) // DNS Providers r.With(middleware.EnforceSetup(true)).Route("/dns-providers", func(r chi.Router) { - r.With(middleware.Filters(dnsprovider.GetFilterSchema()), middleware.Enforce(user.CapabilityDNSProvidersView)). + // List + r.With(middleware.Enforce(user.CapabilityDNSProvidersView), middleware.Filters(dnsprovider.GetFilterSchema())). Get("/", handler.GetDNSProviders()) - r.With(middleware.Enforce(user.CapabilityDNSProvidersView)).Get("/{providerID:[0-9]+}", handler.GetDNSProvider()) - r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).Delete("/{providerID:[0-9]+}", handler.DeleteDNSProvider()) - r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).With(middleware.EnforceRequestSchema(schema.CreateDNSProvider())). - Post("/", handler.CreateDNSProvider()) - r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).With(middleware.EnforceRequestSchema(schema.UpdateDNSProvider())). - Put("/{providerID:[0-9]+}", handler.UpdateDNSProvider()) + // Create + r.With(middleware.Enforce(user.CapabilityDNSProvidersManage), middleware.EnforceRequestSchema(schema.CreateDNSProvider())). + Post("/", handler.CreateDNSProvider()) + + // Specific Item + r.Route("/{providerID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityDNSProvidersView)). + Get("/{providerID:[0-9]+}", handler.GetDNSProvider()) + r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).Route("/", func(r chi.Router) { + r.Delete("/", handler.DeleteDNSProvider()) + r.With(middleware.EnforceRequestSchema(schema.UpdateDNSProvider())). + Put("/{providerID:[0-9]+}", handler.UpdateDNSProvider()) + }) + }) + + // List Acme DNS Providers r.With(middleware.Enforce(user.CapabilityDNSProvidersView)).Route("/acmesh", func(r chi.Router) { r.Get("/{acmeshID:[a-z0-9_]+}", handler.GetAcmeshProvider()) r.Get("/", handler.GetAcmeshProviders()) @@ -146,81 +193,141 @@ func applyRoutes(r chi.Router) chi.Router { // Certificate Authorities r.With(middleware.EnforceSetup(true)).Route("/certificate-authorities", func(r chi.Router) { + // List r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesView), middleware.Filters(certificateauthority.GetFilterSchema())). Get("/", handler.GetCertificateAuthorities()) - r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesView)).Get("/{caID:[0-9]+}", handler.GetCertificateAuthority()) - r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).Delete("/{caID:[0-9]+}", handler.DeleteCertificateAuthority()) - r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).With(middleware.EnforceRequestSchema(schema.CreateCertificateAuthority())). + + // Create + r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage), middleware.EnforceRequestSchema(schema.CreateCertificateAuthority())). Post("/", handler.CreateCertificateAuthority()) - r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).With(middleware.EnforceRequestSchema(schema.UpdateCertificateAuthority())). - Put("/{caID:[0-9]+}", handler.UpdateCertificateAuthority()) + + // Specific Item + r.Route("/{caID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesView)). + Get("/", handler.GetCertificateAuthority()) + r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).Route("/", func(r chi.Router) { + r.Delete("/{caID:[0-9]+}", handler.DeleteCertificateAuthority()) + r.With(middleware.EnforceRequestSchema(schema.UpdateCertificateAuthority())). + Put("/{caID:[0-9]+}", handler.UpdateCertificateAuthority()) + }) + }) }) // Certificates r.With(middleware.EnforceSetup(true)).Route("/certificates", func(r chi.Router) { + // List r.With(middleware.Enforce(user.CapabilityCertificatesView), middleware.Filters(certificate.GetFilterSchema())). Get("/", handler.GetCertificates()) - r.With(middleware.Enforce(user.CapabilityCertificatesView)).Get("/{certificateID:[0-9]+}", handler.GetCertificate()) - r.With(middleware.Enforce(user.CapabilityCertificatesManage)).Delete("/{certificateID:[0-9]+}", handler.DeleteCertificate()) - r.With(middleware.Enforce(user.CapabilityCertificatesManage)).With(middleware.EnforceRequestSchema(schema.CreateCertificate())). + + // Create + r.With(middleware.Enforce(user.CapabilityCertificatesManage), middleware.EnforceRequestSchema(schema.CreateCertificate())). Post("/", handler.CreateCertificate()) - /* - r.With(middleware.EnforceRequestSchema(schema.UpdateCertificate())). - Put("/{certificateID:[0-9]+}", handler.UpdateCertificate()) - */ - r.With(middleware.Enforce(user.CapabilityCertificatesManage)).Put("/{certificateID:[0-9]+}", handler.UpdateCertificate()) + + // Specific Item + r.Route("/{certificateID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityCertificatesView)). + Get("/", handler.GetCertificate()) + r.With(middleware.Enforce(user.CapabilityCertificatesManage)).Route("/", func(r chi.Router) { + r.Delete("/", handler.DeleteCertificate()) + r.Put("/", handler.UpdateCertificate()) + // r.With(middleware.EnforceRequestSchema(schema.UpdateCertificate())). + // Put("/", handler.UpdateCertificate()) + r.Post("/renew", handler.RenewCertificate()) + r.Get("/download", handler.DownloadCertificate()) + }) + }) }) // Hosts r.With(middleware.EnforceSetup(true)).Route("/hosts", func(r chi.Router) { + // List r.With(middleware.Enforce(user.CapabilityHostsView), middleware.Filters(host.GetFilterSchema())). Get("/", handler.GetHosts()) - r.With(middleware.Enforce(user.CapabilityHostsView)).Get("/{hostID:[0-9]+}", handler.GetHost()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).Delete("/{hostID:[0-9]+}", handler.DeleteHost()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.CreateHost())). + + // Create + r.With(middleware.Enforce(user.CapabilityHostsManage), middleware.EnforceRequestSchema(schema.CreateHost())). Post("/", handler.CreateHost()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.UpdateHost())). - Put("/{hostID:[0-9]+}", handler.UpdateHost()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{hostID:[0-9]+}/nginx-config", handler.GetHostNginxConfig("json")) - r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{hostID:[0-9]+}/nginx-config.txt", handler.GetHostNginxConfig("text")) + + // Specific Item + r.Route("/{hostID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityHostsView)). + Get("/", handler.GetHost()) + r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) { + r.Delete("/", handler.DeleteHost()) + r.With(middleware.EnforceRequestSchema(schema.UpdateHost())). + Put("/", handler.UpdateHost()) + r.Get("/nginx-config", handler.GetHostNginxConfig("json")) + r.Get("/nginx-config.txt", handler.GetHostNginxConfig("text")) + }) + }) }) // Nginx Templates r.With(middleware.EnforceSetup(true)).Route("/nginx-templates", func(r chi.Router) { + // List r.With(middleware.Enforce(user.CapabilityNginxTemplatesView), middleware.Filters(nginxtemplate.GetFilterSchema())). Get("/", handler.GetNginxTemplates()) - r.With(middleware.Enforce(user.CapabilityNginxTemplatesView)).Get("/{templateID:[0-9]+}", handler.GetNginxTemplates()) - r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage)).Delete("/{templateID:[0-9]+}", handler.DeleteNginxTemplate()) - r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage)).With(middleware.EnforceRequestSchema(schema.CreateNginxTemplate())). + + // Create + r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage), middleware.EnforceRequestSchema(schema.CreateNginxTemplate())). Post("/", handler.CreateNginxTemplate()) - r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage)).With(middleware.EnforceRequestSchema(schema.UpdateNginxTemplate())). - Put("/{templateID:[0-9]+}", handler.UpdateNginxTemplate()) + + // Specific Item + r.Route("/{templateID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityNginxTemplatesView)). + Get("/", handler.GetNginxTemplates()) + r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) { + r.Delete("/", handler.DeleteNginxTemplate()) + r.With(middleware.EnforceRequestSchema(schema.UpdateNginxTemplate())). + Put("/", handler.UpdateNginxTemplate()) + }) + }) }) // Streams r.With(middleware.EnforceSetup(true)).Route("/streams", func(r chi.Router) { + // List r.With(middleware.Enforce(user.CapabilityStreamsView), middleware.Filters(stream.GetFilterSchema())). Get("/", handler.GetStreams()) - r.With(middleware.Enforce(user.CapabilityStreamsView)).Get("/{hostID:[0-9]+}", handler.GetStream()) - r.With(middleware.Enforce(user.CapabilityStreamsManage)).Delete("/{hostID:[0-9]+}", handler.DeleteStream()) - r.With(middleware.Enforce(user.CapabilityStreamsManage)).With(middleware.EnforceRequestSchema(schema.CreateStream())). + + // Create + r.With(middleware.Enforce(user.CapabilityStreamsManage), middleware.EnforceRequestSchema(schema.CreateStream())). Post("/", handler.CreateStream()) - r.With(middleware.Enforce(user.CapabilityStreamsManage)).With(middleware.EnforceRequestSchema(schema.UpdateStream())). - Put("/{hostID:[0-9]+}", handler.UpdateStream()) + + // Specific Item + r.Route("/{hostID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityStreamsView)). + Get("/", handler.GetStream()) + r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) { + r.Delete("/", handler.DeleteStream()) + r.With(middleware.EnforceRequestSchema(schema.UpdateStream())). + Put("/", handler.UpdateStream()) + }) + }) }) // Upstreams r.With(middleware.EnforceSetup(true)).Route("/upstreams", func(r chi.Router) { + // List r.With(middleware.Enforce(user.CapabilityHostsView), middleware.Filters(upstream.GetFilterSchema())). Get("/", handler.GetUpstreams()) - r.With(middleware.Enforce(user.CapabilityHostsView)).Get("/{upstreamID:[0-9]+}", handler.GetUpstream()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).Delete("/{upstreamID:[0-9]+}", handler.DeleteUpstream()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.CreateUpstream())). + + // Create + r.With(middleware.Enforce(user.CapabilityHostsManage), middleware.EnforceRequestSchema(schema.CreateUpstream())). Post("/", handler.CreateUpstream()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.UpdateUpstream())). - Put("/{upstreamID:[0-9]+}", handler.UpdateUpstream()) - r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{upstreamID:[0-9]+}/nginx-config", handler.GetUpstreamNginxConfig("json")) - r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{upstreamID:[0-9]+}/nginx-config.txt", handler.GetUpstreamNginxConfig("text")) + + // Specific Item + r.Route("/{upstreamID:[0-9]+}", func(r chi.Router) { + r.With(middleware.Enforce(user.CapabilityHostsView)). + Get("/", handler.GetUpstream()) + r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) { + r.Delete("/", handler.DeleteUpstream()) + r.With(middleware.EnforceRequestSchema(schema.UpdateUpstream())). + Put("/", handler.UpdateUpstream()) + r.Get("/nginx-config", handler.GetUpstreamNginxConfig("json")) + r.Get("/nginx-config.txt", handler.GetUpstreamNginxConfig("text")) + }) + }) }) }) diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 7c39aa3..f6593df 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -6,6 +6,7 @@ import ( "time" "npm/internal/logger" + "npm/internal/serverevents" ) const httpPort = 3000 @@ -20,6 +21,8 @@ func StartServer() { ReadHeaderTimeout: 3 * time.Second, } + defer serverevents.Shutdown() + err := server.ListenAndServe() if err != nil { logger.Error("HttpListenError", err) diff --git a/backend/internal/entity/certificate/model.go b/backend/internal/entity/certificate/model.go index 06e8d4a..597458e 100644 --- a/backend/internal/entity/certificate/model.go +++ b/backend/internal/entity/certificate/model.go @@ -14,6 +14,7 @@ import ( "npm/internal/entity/dnsprovider" "npm/internal/entity/user" "npm/internal/logger" + "npm/internal/serverevents" "npm/internal/types" "npm/internal/util" @@ -123,6 +124,9 @@ func (m *Model) Delete() bool { if err := m.Save(); err != nil { return false } + + // todo: delete from acme.sh as well + return true } @@ -239,6 +243,7 @@ func (m *Model) GetCertificateLocations() (string, string, string) { // Request makes a certificate request func (m *Model) Request() error { logger.Info("Requesting certificate for: #%d %v", m.ID, m.Name) + serverevents.SendChange("certificates") // nolint: errcheck, gosec m.Expand([]string{"certificate-authority", "dns-provider"}) @@ -283,6 +288,7 @@ func (m *Model) Request() error { return err } + serverevents.SendChange("certificates") logger.Info("Request for certificate for: #%d %v was completed", m.ID, m.Name) return nil } diff --git a/backend/internal/jwt/jwt.go b/backend/internal/jwt/jwt.go index 95b1101..bd45f16 100644 --- a/backend/internal/jwt/jwt.go +++ b/backend/internal/jwt/jwt.go @@ -24,11 +24,16 @@ type GeneratedResponse struct { } // Generate will create a JWT -func Generate(userObj *user.Model) (GeneratedResponse, error) { +func Generate(userObj *user.Model, forSSE bool) (GeneratedResponse, error) { var response GeneratedResponse key, _ := GetPrivateKey() expires := time.Now().AddDate(0, 0, 1) // 1 day + issuer := "api" + + if forSSE { + issuer = "sse" + } // Create the Claims claims := UserJWTClaims{ @@ -37,7 +42,7 @@ func Generate(userObj *user.Model) (GeneratedResponse, error) { jwt.StandardClaims{ IssuedAt: time.Now().Unix(), ExpiresAt: expires.Unix(), - Issuer: "api", + Issuer: issuer, }, } diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go index 2cbadc4..adb95b3 100644 --- a/backend/internal/logger/logger.go +++ b/backend/internal/logger/logger.go @@ -92,6 +92,11 @@ func Error(errorClass string, err error) { logger.Error(errorClass, err) } +// Get returns the logger +func Get() *Logger { + return logger +} + // Configure logger and will return error if missing required fields. func (l *Logger) Configure(c *Config) error { // ensure updates to the config are atomic diff --git a/backend/internal/serverevents/sse.go b/backend/internal/serverevents/sse.go new file mode 100644 index 0000000..4d5d9d2 --- /dev/null +++ b/backend/internal/serverevents/sse.go @@ -0,0 +1,74 @@ +package serverevents + +import ( + "encoding/json" + "net/http" + "npm/internal/logger" + + "github.com/jc21/go-sse" +) + +var instance *sse.Server + +const defaultChannel = "changes" + +// Message is how we're going to send the data +type Message struct { + Lang string `json:"lang,omitempty"` + LangParams map[string]string `json:"lang_params,omitempty"` + Type string `json:"type,omitempty"` + Affects string `json:"affects,omitempty"` +} + +// Get will return a sse server +func Get() *sse.Server { + if instance == nil { + instance = sse.NewServer(&sse.Options{ + Logger: logger.Get(), + ChannelNameFunc: func(request *http.Request) string { + return defaultChannel // This is the channel for all updates regardless of visibility + }, + }) + } + return instance +} + +// Shutdown will shutdown the server +func Shutdown() { + if instance != nil { + instance.Shutdown() + } +} + +// SendChange will send a specific change +func SendChange(affects string) { + Send(Message{Affects: affects}, "") +} + +// SendMessage will construct a message for the UI +func SendMessage(typ, lang string, langParams map[string]string) { + Send(Message{ + Type: typ, + Lang: lang, + LangParams: langParams, + }, "") +} + +// Send will send a message +func Send(msg Message, channel string) { + if channel == "" { + channel = defaultChannel + } + logger.Debug("SSE Sending: %+v", msg) + if data, err := json.Marshal(msg); err != nil { + logger.Error("SSEError", err) + } else { + Get().SendMessage(channel, sse.SimpleMessage(string(data))) + } +} + +// TODO: if we end up implementing user visibility, +// then we'll have to subscribe people to their own +// channels and publish to all or some depending on visibility. +// This means using a specific ChannelNameFunc that revolves +// around the user and their visibility. diff --git a/docker/rootfs/etc/nginx/conf.d/dev.conf b/docker/rootfs/etc/nginx/conf.d/dev.conf index ce8c1da..75e79b2 100644 --- a/docker/rootfs/etc/nginx/conf.d/dev.conf +++ b/docker/rootfs/etc/nginx/conf.d/dev.conf @@ -7,12 +7,18 @@ server { } location /api/ { - add_header X-Served-By $host; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Scheme $scheme; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_pass http://127.0.0.1:3000/api/; + add_header X-Served-By $host; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ''; + proxy_set_header X-Forwarded-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Accel-Buffering no; + proxy_pass http://127.0.0.1:3000/api/; } location ~ .html { diff --git a/docker/rootfs/etc/nginx/conf.d/production.conf b/docker/rootfs/etc/nginx/conf.d/production.conf index 325cb8c..2881604 100644 --- a/docker/rootfs/etc/nginx/conf.d/production.conf +++ b/docker/rootfs/etc/nginx/conf.d/production.conf @@ -4,11 +4,17 @@ server { server_name nginxproxymanager; location / { - add_header X-Served-By $host; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Scheme $scheme; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_pass http://localhost:3000/; + add_header X-Served-By $host; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ''; + proxy_set_header X-Forwarded-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Accel-Buffering no; + proxy_pass http://localhost:3000/; } } diff --git a/frontend/src/api/npm/getSSEToken.ts b/frontend/src/api/npm/getSSEToken.ts new file mode 100644 index 0000000..a0f9037 --- /dev/null +++ b/frontend/src/api/npm/getSSEToken.ts @@ -0,0 +1,14 @@ +import * as api from "./base"; +import { TokenResponse } from "./responseTypes"; + +export async function getSSEToken( + abortController?: AbortController, +): Promise { + const { result } = await api.post( + { + url: "/tokens/sse", + }, + abortController, + ); + return result; +} diff --git a/frontend/src/api/npm/index.ts b/frontend/src/api/npm/index.ts index cd8d970..23250e5 100644 --- a/frontend/src/api/npm/index.ts +++ b/frontend/src/api/npm/index.ts @@ -14,6 +14,7 @@ export * from "./getHealth"; export * from "./getHosts"; export * from "./getNginxTemplates"; export * from "./getSettings"; +export * from "./getSSEToken"; export * from "./getToken"; export * from "./getUpstreamNginxConfig"; export * from "./getUpstreams"; @@ -22,6 +23,7 @@ export * from "./getUsers"; export * from "./helpers"; export * from "./models"; export * from "./refreshToken"; +export * from "./renewCertificate"; export * from "./responseTypes"; export * from "./setAuth"; export * from "./setCertificate"; diff --git a/frontend/src/api/npm/models.ts b/frontend/src/api/npm/models.ts index 1237e1c..d960ba2 100644 --- a/frontend/src/api/npm/models.ts +++ b/frontend/src/api/npm/models.ts @@ -165,3 +165,10 @@ export interface Upstream { advancedConfig: string; isDisabled: boolean; } + +export interface SSEMessage { + lang?: string; + langParams?: string; + type?: "info" | "warning" | "success" | "error" | "loading"; + affects?: string | string[]; +} diff --git a/frontend/src/api/npm/renewCertificate.ts b/frontend/src/api/npm/renewCertificate.ts new file mode 100644 index 0000000..a7b513c --- /dev/null +++ b/frontend/src/api/npm/renewCertificate.ts @@ -0,0 +1,15 @@ +import * as api from "./base"; +import { Certificate } from "./models"; + +export async function renewCertificate( + id: number, + abortController?: AbortController, +): Promise { + const { result } = await api.post( + { + url: `/certificates/${id}/renew`, + }, + abortController, + ); + return result; +} diff --git a/frontend/src/components/SiteWrapper.tsx b/frontend/src/components/SiteWrapper.tsx index d451934..149e046 100644 --- a/frontend/src/components/SiteWrapper.tsx +++ b/frontend/src/components/SiteWrapper.tsx @@ -1,12 +1,56 @@ -import { ReactNode } from "react"; +import { useEffect, ReactNode } from "react"; -import { Box, Container } from "@chakra-ui/react"; +import { Box, Container, useToast } from "@chakra-ui/react"; +import { getSSEToken, SSEMessage } from "api/npm"; import { Footer, Navigation } from "components"; +import { intl } from "locale"; +import AuthStore from "modules/AuthStore"; +import { useQueryClient } from "react-query"; interface Props { children?: ReactNode; } function SiteWrapper({ children }: Props) { + const queryClient = useQueryClient(); + const toast = useToast(); + + // TODO: fix bug where this will fail if the browser is kept open longer + // than the expiry of the sse token + useEffect(() => { + async function fetchData() { + const response = await getSSEToken(); + const eventSource = new EventSource( + `/api/sse/changes?jwt=${response.token}`, + ); + eventSource.onmessage = (e: any) => { + const j: SSEMessage = JSON.parse(e.data); + if (j) { + if (j.affects) { + queryClient.invalidateQueries(j.affects); + } + if (j.type) { + toast({ + description: intl.formatMessage({ id: j.lang }), + status: j.type || "info", + position: "top", + duration: 3000, + isClosable: true, + }); + } + } + }; + eventSource.onerror = (e) => { + console.error("SSE EventSource failed:", e); + }; + return () => { + eventSource.close(); + }; + } + if (AuthStore.token) { + fetchData(); + } + }, [queryClient, toast]); + return ( diff --git a/frontend/src/locale/src/de.json b/frontend/src/locale/src/de.json index b70a72a..a26cfa9 100644 --- a/frontend/src/locale/src/de.json +++ b/frontend/src/locale/src/de.json @@ -71,6 +71,9 @@ "certificate.create": { "defaultMessage": "Zertifikat erstellen" }, + "certificate.renewal-requested": { + "defaultMessage": "Renewal has been queued" + }, "certificates.title": { "defaultMessage": "Zertifikate" }, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 45dbc13..a9daa41 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -347,6 +347,9 @@ "certificate.create": { "defaultMessage": "Create Certificate" }, + "certificate.renewal-requested": { + "defaultMessage": "Renewal has been queued" + }, "certificates.title": { "defaultMessage": "Certificates" }, diff --git a/frontend/src/locale/src/fa.json b/frontend/src/locale/src/fa.json index a9ec09e..c71f3c2 100644 --- a/frontend/src/locale/src/fa.json +++ b/frontend/src/locale/src/fa.json @@ -71,6 +71,9 @@ "certificate.create": { "defaultMessage": "ایجاد گواهی" }, + "certificate.renewal-requested": { + "defaultMessage": "Renewal has been queued" + }, "certificates.title": { "defaultMessage": "گواهینامه ها" }, diff --git a/frontend/src/pages/Certificates/Table.tsx b/frontend/src/pages/Certificates/Table.tsx index 39b0b69..59b8da0 100644 --- a/frontend/src/pages/Certificates/Table.tsx +++ b/frontend/src/pages/Certificates/Table.tsx @@ -25,6 +25,7 @@ export interface TableProps { sortBy: TableSortBy[]; filters: TableFilter[]; onTableEvent: any; + onRenewal: (id: number) => void; } function Table({ data, @@ -32,6 +33,7 @@ function Table({ onTableEvent, sortBy, filters, + onRenewal, }: TableProps) { const [editId, setEditId] = useState(0); const [columns, tableData] = useMemo(() => { @@ -85,7 +87,7 @@ function Table({ title: intl.formatMessage({ id: "action.renew", }), - onClick: (e: any, { id }: any) => alert(id), + onClick: (e: any, { id }: any) => onRenewal(id), icon: , disabled: (data: any) => data.type !== "dns" && data.type !== "http", @@ -110,7 +112,7 @@ function Table({ }, ]; return [columns, data]; - }, [data]); + }, [data, onRenewal]); const tableInstance = useTable( { diff --git a/frontend/src/pages/Certificates/TableWrapper.tsx b/frontend/src/pages/Certificates/TableWrapper.tsx index e585da2..5741ab1 100644 --- a/frontend/src/pages/Certificates/TableWrapper.tsx +++ b/frontend/src/pages/Certificates/TableWrapper.tsx @@ -1,9 +1,11 @@ import { useEffect, useReducer, useState } from "react"; -import { Alert, AlertIcon } from "@chakra-ui/react"; +import { Alert, AlertIcon, useToast } from "@chakra-ui/react"; +import { renewCertificate } from "api/npm"; import { EmptyList, SpinnerPage, tableEventReducer } from "components"; import { useCertificates } from "hooks"; import { intl } from "locale"; +import { useQueryClient } from "react-query"; import Table from "./Table"; @@ -19,6 +21,9 @@ const initialState = { filters: [], }; function TableWrapper() { + const toast = useToast(); + const queryClient = useQueryClient(); + const [{ offset, limit, sortBy, filters }, dispatch] = useReducer( tableEventReducer, initialState, @@ -36,6 +41,32 @@ function TableWrapper() { setTableData(data as any); }, [data]); + const renewCert = async (id: number) => { + try { + await renewCertificate(id); + toast({ + description: intl.formatMessage({ + id: `certificate.renewal-requested`, + }), + status: "info", + position: "top", + duration: 3000, + isClosable: true, + }); + setTimeout(() => { + queryClient.invalidateQueries("certificates"); + }, 500); + } catch (err: any) { + toast({ + description: err.message, + status: "error", + position: "top", + duration: 3000, + isClosable: true, + }); + } + }; + if (isFetching || isLoading || !tableData) { return ; } @@ -76,6 +107,7 @@ function TableWrapper() { sortBy={sortBy} filters={filters} onTableEvent={dispatch} + onRenewal={renewCert} /> ); } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index f69696f..6e4ed56 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -25,4 +25,4 @@ "include": [ "src" ] -} \ No newline at end of file +}