From e2595f007c3331bb40d57f43d17f08d68adb1a62 Mon Sep 17 00:00:00 2001 From: Ayu Date: Sun, 23 Jun 2024 14:12:04 +0100 Subject: [PATCH] feat(core): add demo mode (#38) * feat: add demo mode * ci: enable demo mode for demo website --- core/api/oas_response_encoders_gen.go | 56 +++++++++++++++++++++-- core/api/oas_schemas_gen.go | 6 ++- core/cmd/config.go | 5 ++ core/cmd/start.go | 3 +- core/migrations/0001_sqlite_schema.go | 2 +- core/model/errors.go | 2 + core/openapi.yaml | 12 ++++- core/services/users.go | 10 ++++ core/services/websites.go | 19 ++++++++ core/util/auth.go | 9 ++-- core/util/auth_test.go | 2 +- dashboard/app/api/types.d.ts | 6 ++- dashboard/app/components/layout/Error.tsx | 28 +++++++++++- dashboard/app/root.tsx | 13 +++++- dashboard/app/routes/login._index.tsx | 23 ++++++++++ dashboard/app/routes/settings.account.tsx | 2 +- fly.toml | 1 + 17 files changed, 182 insertions(+), 17 deletions(-) diff --git a/core/api/oas_response_encoders_gen.go b/core/api/oas_response_encoders_gen.go index 9010d0e7..c4b1c582 100644 --- a/core/api/oas_response_encoders_gen.go +++ b/core/api/oas_response_encoders_gen.go @@ -44,9 +44,9 @@ func encodeDeleteUserResponse(response DeleteUserRes, w http.ResponseWriter) err return nil - case *NotFoundError: + case *ForbiddenError: w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(404) + w.WriteHeader(403) e := new(jx.Encoder) response.Encode(e) @@ -56,9 +56,9 @@ func encodeDeleteUserResponse(response DeleteUserRes, w http.ResponseWriter) err return nil - case *ConflictError: + case *NotFoundError: w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(409) + w.WriteHeader(404) e := new(jx.Encoder) response.Encode(e) @@ -116,6 +116,18 @@ func encodeDeleteWebsitesIDResponse(response DeleteWebsitesIDRes, w http.Respons return nil + case *ForbiddenError: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(403) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + case *NotFoundError: w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(404) @@ -1486,6 +1498,18 @@ func encodePatchUserResponse(response PatchUserRes, w http.ResponseWriter) error return nil + case *ForbiddenError: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(403) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + case *NotFoundError: w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(404) @@ -1573,6 +1597,18 @@ func encodePatchWebsitesIDResponse(response PatchWebsitesIDRes, w http.ResponseW return nil + case *ForbiddenError: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(403) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + case *NotFoundError: w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(404) @@ -1812,6 +1848,18 @@ func encodePostWebsitesResponse(response PostWebsitesRes, w http.ResponseWriter) return nil + case *ForbiddenError: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(403) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + case *ConflictError: w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(409) diff --git a/core/api/oas_schemas_gen.go b/core/api/oas_schemas_gen.go index fffb4218..4048e754 100644 --- a/core/api/oas_schemas_gen.go +++ b/core/api/oas_schemas_gen.go @@ -114,7 +114,6 @@ func (s *ConflictError) SetError(val ConflictErrorError) { s.Error = val } -func (*ConflictError) deleteUserRes() {} func (*ConflictError) patchUserRes() {} func (*ConflictError) postWebsitesRes() {} @@ -477,6 +476,8 @@ func (s *ForbiddenError) SetError(val ForbiddenErrorError) { s.Error = val } +func (*ForbiddenError) deleteUserRes() {} +func (*ForbiddenError) deleteWebsitesIDRes() {} func (*ForbiddenError) getWebsiteIDBrowsersRes() {} func (*ForbiddenError) getWebsiteIDCampaignsRes() {} func (*ForbiddenError) getWebsiteIDCountryRes() {} @@ -486,6 +487,9 @@ func (*ForbiddenError) getWebsiteIDMediumsRes() {} func (*ForbiddenError) getWebsiteIDOsRes() {} func (*ForbiddenError) getWebsiteIDReferrersRes() {} func (*ForbiddenError) getWebsiteIDSourcesRes() {} +func (*ForbiddenError) patchUserRes() {} +func (*ForbiddenError) patchWebsitesIDRes() {} +func (*ForbiddenError) postWebsitesRes() {} type ForbiddenErrorError struct { Code int32 `json:"code"` diff --git a/core/cmd/config.go b/core/cmd/config.go index 90b6e74f..c655f174 100644 --- a/core/cmd/config.go +++ b/core/cmd/config.go @@ -26,6 +26,7 @@ type ServerConfig struct { // Misc settings. UseEnvironment bool + DemoMode bool `env:"DEMO_MODE"` } type AppDBConfig struct { @@ -55,6 +56,9 @@ const ( // Logging constants. DefaultLogger = "json" DefaultLoggerLevel = "info" + + // Misc constants. + DefaultDemoMode = false ) // NewServerConfig creates a new server config. @@ -68,6 +72,7 @@ func NewServerConfig(useEnv bool) (*ServerConfig, error) { TimeoutWrite: DefaultTimeoutWrite, TimeoutIdle: DefaultTimeoutIdle, UseEnvironment: useEnv, + DemoMode: DefaultDemoMode, } // Load config from environment variables. diff --git a/core/cmd/start.go b/core/cmd/start.go index ef437fa0..e9cc9cee 100644 --- a/core/cmd/start.go +++ b/core/cmd/start.go @@ -71,6 +71,7 @@ func (s *StartCommand) ParseFlags(args []string) error { // Misc settings. fs.BoolVar(&s.Server.UseEnvironment, "env", false, "Opt-in to allow environment variables to be used for configuration. Flags will still override environment variables.") + fs.BoolVar(&s.Server.DemoMode, "demo", s.Server.DemoMode, "Enable demo mode restricting all POST/PATCH/DELETE actions (except login).") // Handle array type flags. corsAllowedOrigins := fs.String("corsorigins", strings.Join(s.Server.CORSAllowedOrigins, ","), "Comma separated list of allowed CORS origins on API routes. Useful for external dashboards that may host the frontend on a different domain.") @@ -121,7 +122,7 @@ func (s *StartCommand) Run(ctx context.Context) error { } // Setup auth service - auth, err := util.NewAuthService(ctx) + auth, err := util.NewAuthService(ctx, s.Server.DemoMode) if err != nil { return errors.Wrap(err, "failed to create auth service") } diff --git a/core/migrations/0001_sqlite_schema.go b/core/migrations/0001_sqlite_schema.go index 1d5e7a41..6ca3e6b5 100644 --- a/core/migrations/0001_sqlite_schema.go +++ b/core/migrations/0001_sqlite_schema.go @@ -71,7 +71,7 @@ func Up0001(c *sqlite.Client) error { id := typeid.String() // Hash default password - auth, err := util.NewAuthService(context.Background()) + auth, err := util.NewAuthService(context.Background(), false) if err != nil { return err } diff --git a/core/model/errors.go b/core/model/errors.go index 0d6f9e7b..29820a5a 100644 --- a/core/model/errors.go +++ b/core/model/errors.go @@ -12,6 +12,8 @@ var ( ErrInternalServerError = errors.New("internal server error") // Authentication + // ErrDemoMode is returned when a user tries to perform an action in demo mode. + ErrDemoMode = errors.New("user in demo mode") // ErrInvalidSession is returned when a session is invalid. ErrInvalidSession = errors.New("invalid session") // ErrSessionNotFound is returned when a session is not found. diff --git a/core/openapi.yaml b/core/openapi.yaml index f985df59..c561e84b 100644 --- a/core/openapi.yaml +++ b/core/openapi.yaml @@ -222,6 +222,8 @@ paths: $ref: "#/components/responses/BadRequestError" "401": $ref: "#/components/responses/UnauthorisedError" + "403": + $ref: "#/components/responses/ForbiddenError" "404": $ref: "#/components/responses/NotFoundError" "409": @@ -248,10 +250,10 @@ paths: $ref: "#/components/responses/BadRequestError" "401": $ref: "#/components/responses/UnauthorisedError" + "403": + $ref: "#/components/responses/ForbiddenError" "404": $ref: "#/components/responses/NotFoundError" - "409": - $ref: "#/components/responses/ConflictError" "500": $ref: "#/components/responses/InternalServerError" servers: @@ -327,6 +329,8 @@ paths: $ref: "#/components/responses/BadRequestError" "401": $ref: "#/components/responses/UnauthorisedError" + "403": + $ref: "#/components/responses/ForbiddenError" "409": $ref: "#/components/responses/ConflictError" "500": @@ -393,6 +397,8 @@ paths: $ref: "#/components/responses/BadRequestError" "401": $ref: "#/components/responses/UnauthorisedError" + "403": + $ref: "#/components/responses/ForbiddenError" "404": $ref: "#/components/responses/NotFoundError" "500": @@ -418,6 +424,8 @@ paths: $ref: "#/components/responses/BadRequestError" "401": $ref: "#/components/responses/UnauthorisedError" + "403": + $ref: "#/components/responses/ForbiddenError" "404": $ref: "#/components/responses/NotFoundError" "500": diff --git a/core/services/users.go b/core/services/users.go index 97037afb..8ea97f86 100644 --- a/core/services/users.go +++ b/core/services/users.go @@ -40,6 +40,11 @@ func (h *Handler) GetUser(ctx context.Context, params api.GetUserParams) (api.Ge func (h *Handler) PatchUser(ctx context.Context, req *api.UserPatch, params api.PatchUserParams) (api.PatchUserRes, error) { log := logger.Get() + if h.auth.IsDemoMode { + log.Debug().Msg("patch user rejected in demo mode") + return ErrForbidden(model.ErrDemoMode), nil + } + // Get user id from request context and check if user exists userId, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { @@ -108,6 +113,11 @@ func (h *Handler) PatchUser(ctx context.Context, req *api.UserPatch, params api. func (h *Handler) DeleteUser(ctx context.Context, params api.DeleteUserParams) (api.DeleteUserRes, error) { log := logger.Get() + if h.auth.IsDemoMode { + log.Debug().Msg("delete user rejected in demo mode") + return ErrForbidden(model.ErrDemoMode), nil + } + // Get user id from request context and check if user exists userId, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { diff --git a/core/services/websites.go b/core/services/websites.go index b56238f9..1243f1c4 100644 --- a/core/services/websites.go +++ b/core/services/websites.go @@ -7,9 +7,16 @@ import ( "github.com/go-faster/errors" "github.com/medama-io/medama/api" "github.com/medama-io/medama/model" + "github.com/medama-io/medama/util/logger" ) func (h *Handler) DeleteWebsitesID(ctx context.Context, params api.DeleteWebsitesIDParams) (api.DeleteWebsitesIDRes, error) { + log := logger.Get() + if h.auth.IsDemoMode { + log.Debug().Msg("delete website rejected in demo mode") + return ErrForbidden(model.ErrDemoMode), nil + } + // Check if user owns website userId, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { @@ -138,6 +145,12 @@ func (h *Handler) GetWebsitesID(ctx context.Context, params api.GetWebsitesIDPar } func (h *Handler) PatchWebsitesID(ctx context.Context, req *api.WebsitePatch, params api.PatchWebsitesIDParams) (api.PatchWebsitesIDRes, error) { + log := logger.Get() + if h.auth.IsDemoMode { + log.Debug().Msg("patch website rejected in demo mode") + return ErrForbidden(model.ErrDemoMode), nil + } + // Get user ID from context userId, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { @@ -187,6 +200,12 @@ func (h *Handler) PatchWebsitesID(ctx context.Context, req *api.WebsitePatch, pa } func (h *Handler) PostWebsites(ctx context.Context, req *api.WebsiteCreate) (api.PostWebsitesRes, error) { + log := logger.Get() + if h.auth.IsDemoMode { + log.Debug().Msg("post website rejected in demo mode") + return ErrForbidden(model.ErrDemoMode), nil + } + // Get user ID from context userId, ok := ctx.Value(model.ContextKeyUserID).(string) if !ok { diff --git a/core/util/auth.go b/core/util/auth.go index de30eae1..2b440f62 100644 --- a/core/util/auth.go +++ b/core/util/auth.go @@ -28,10 +28,12 @@ type AuthService struct { Cache *Cache // Key used to encrypt session tokens. aes32Key []byte + // Demo mode flag. + IsDemoMode bool } // NewAuthService returns a new instance of AuthService. -func NewAuthService(ctx context.Context) (*AuthService, error) { +func NewAuthService(ctx context.Context, isDemoMode bool) (*AuthService, error) { // Generate a new random key for encrypting session tokens. // Since we store sessions in an in-memory cache, it doesn't // matter if the key doesn't persist as sessions will be @@ -43,8 +45,9 @@ func NewAuthService(ctx context.Context) (*AuthService, error) { } return &AuthService{ - aes32Key: key, - Cache: NewCache(ctx, model.SessionDuration), + aes32Key: key, + Cache: NewCache(ctx, model.SessionDuration), + IsDemoMode: isDemoMode, }, nil } diff --git a/core/util/auth_test.go b/core/util/auth_test.go index 2dc00a3f..34a6471f 100644 --- a/core/util/auth_test.go +++ b/core/util/auth_test.go @@ -18,7 +18,7 @@ func SetupAuthTest(t *testing.T) (*assert.Assertions, *require.Assertions, conte require := require.New(t) ctx := context.Background() - auth, err := util.NewAuthService(ctx) + auth, err := util.NewAuthService(ctx, false) require.NoError(err) assert.NotNil(auth) diff --git a/dashboard/app/api/types.d.ts b/dashboard/app/api/types.d.ts index ad9fdee0..0db8c014 100644 --- a/dashboard/app/api/types.d.ts +++ b/dashboard/app/api/types.d.ts @@ -789,8 +789,8 @@ export interface operations { }; 400: components["responses"]["BadRequestError"]; 401: components["responses"]["UnauthorisedError"]; + 403: components["responses"]["ForbiddenError"]; 404: components["responses"]["NotFoundError"]; - 409: components["responses"]["ConflictError"]; 500: components["responses"]["InternalServerError"]; }; }; @@ -819,6 +819,7 @@ export interface operations { }; 400: components["responses"]["BadRequestError"]; 401: components["responses"]["UnauthorisedError"]; + 403: components["responses"]["ForbiddenError"]; 404: components["responses"]["NotFoundError"]; 409: components["responses"]["ConflictError"]; 500: components["responses"]["InternalServerError"]; @@ -869,6 +870,7 @@ export interface operations { }; 400: components["responses"]["BadRequestError"]; 401: components["responses"]["UnauthorisedError"]; + 403: components["responses"]["ForbiddenError"]; 409: components["responses"]["ConflictError"]; 500: components["responses"]["InternalServerError"]; }; @@ -919,6 +921,7 @@ export interface operations { }; 400: components["responses"]["BadRequestError"]; 401: components["responses"]["UnauthorisedError"]; + 403: components["responses"]["ForbiddenError"]; 404: components["responses"]["NotFoundError"]; 500: components["responses"]["InternalServerError"]; }; @@ -951,6 +954,7 @@ export interface operations { }; 400: components["responses"]["BadRequestError"]; 401: components["responses"]["UnauthorisedError"]; + 403: components["responses"]["ForbiddenError"]; 404: components["responses"]["NotFoundError"]; 500: components["responses"]["InternalServerError"]; }; diff --git a/dashboard/app/components/layout/Error.tsx b/dashboard/app/components/layout/Error.tsx index 516a0846..9d453eff 100644 --- a/dashboard/app/components/layout/Error.tsx +++ b/dashboard/app/components/layout/Error.tsx @@ -25,6 +25,32 @@ const ErrorPage = ({ label, title, description }: ErrorPageProps) => { ); }; +const ForbiddenError = () => { + // Check if hostname is demo.medama.io or medama.fly.dev to display a different message. + const hostname = window.location.hostname; + const isDemo = hostname === 'demo.medama.io' || hostname === 'medama.fly.dev'; + + const description = isDemo ? ( + <> + You are currently in demo mode. You can't access this page or perform this + action. + + ) : ( + <> + You don't have permission to view this page or perform this action. Please + contact your administrator if you believe this is an error. + + ); + + return ( + + ); +}; + const NotFoundError = () => ( ( /> ); -export { NotFoundError, InternalServerError }; +export { NotFoundError, ForbiddenError, InternalServerError }; diff --git a/dashboard/app/root.tsx b/dashboard/app/root.tsx index a074c8cc..4fbf5b68 100644 --- a/dashboard/app/root.tsx +++ b/dashboard/app/root.tsx @@ -61,7 +61,11 @@ import { import { API_BASE } from '@/api/client'; import { AppShell } from '@/components/layout/AppShell'; -import { InternalServerError, NotFoundError } from '@/components/layout/Error'; +import { + ForbiddenError, + InternalServerError, + NotFoundError, +} from '@/components/layout/Error'; import theme from '@/styles/theme'; import { hasSession } from '@/utils/cookies'; @@ -171,6 +175,13 @@ export const ErrorBoundary = () => { if (isRouteErrorResponse(error)) { switch (error.status) { + case 403: { + return ( + + + + ); + } case 404: { return ( diff --git a/dashboard/app/routes/login._index.tsx b/dashboard/app/routes/login._index.tsx index 9357f63b..b580b329 100644 --- a/dashboard/app/routes/login._index.tsx +++ b/dashboard/app/routes/login._index.tsx @@ -20,6 +20,29 @@ export const meta: MetaFunction = () => { }; export const clientLoader = async () => { + // If the user is in demo mode (hostname matches demo.medama.io or medama.fly.dev), automatically + // log them into the demo account. + const hostname = window.location.hostname; + const isDemo = hostname === 'demo.medama.io' || hostname === 'medama.fly.dev'; + if (isDemo) { + const { res } = await authLogin({ + body: { + username: 'admin', + password: 'CHANGE_ME_ON_FIRST_LOGIN', + }, + noThrow: true, + }); + + if (!res.ok) { + throw new Error('Failed to login to demo account.'); + } + + // Set logged in cookie + document.cookie = LOGGED_IN_COOKIE; + + return redirect('/'); + } + // If the user is already logged in, redirect them to the dashboard. if (hasSession()) { // Check if session hasn't been revoked diff --git a/dashboard/app/routes/settings.account.tsx b/dashboard/app/routes/settings.account.tsx index 7918f9b1..72b30fd9 100644 --- a/dashboard/app/routes/settings.account.tsx +++ b/dashboard/app/routes/settings.account.tsx @@ -99,7 +99,7 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => { } if (!res.ok) { - throw new Response(await res.text(), { + throw new Response(res.statusText, { status: res.status, }); } diff --git a/fly.toml b/fly.toml index 57ac9f76..5fee43c9 100644 --- a/fly.toml +++ b/fly.toml @@ -12,6 +12,7 @@ ANALYTICS_DATABASE_HOST = '/db/analytics.db' APP_DATABASE_HOST = '/db/app.db' PORT = '8080' LEVEL = 'debug' +DEMO_MODE = 'true' [[mounts]] source = 'medama_db'