diff --git a/.gitignore b/.gitignore index 43ba549a0d7d3b7c68b4dd3d02cd7047e214da94..769ac3a97b38b31a4f131eeb5f6894009dbd26ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -pb_data -just_registry \ No newline at end of file +packages +registry \ No newline at end of file diff --git a/Makefile b/Makefile index ecbc1c4ac5f70cdbed352749e0a575593662645b..6cb6b476bb8ed24c127534c6ba2314ea4f7f86c9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ clean: - rm just_registry + rm registry build: - go build registry/main.go && mv main just_registry + go build . run: - go run registry/main.go serve + go run . serve diff --git a/apis/admin.go b/apis/admin.go deleted file mode 100644 index cf8354a3ade6bc8d5bcdaef3f0a16644b6c6f667..0000000000000000000000000000000000000000 --- a/apis/admin.go +++ /dev/null @@ -1,268 +0,0 @@ -package apis - -import ( - "log" - "net/http" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tokens" - "github.com/pocketbase/pocketbase/tools/routine" - "github.com/pocketbase/pocketbase/tools/search" -) - -// bindAdminApi registers the admin api endpoints and the corresponding handlers. -func bindAdminApi(app core.App, rg *echo.Group) { - api := adminApi{app: app} - - subGroup := rg.Group("/admins", ActivityLogger(app)) - subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly()) - subGroup.POST("/request-password-reset", api.requestPasswordReset) - subGroup.POST("/confirm-password-reset", api.confirmPasswordReset) - subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth()) - subGroup.GET("", api.list, RequireAdminAuth()) - subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app)) - subGroup.GET("/:id", api.view, RequireAdminAuth()) - subGroup.PATCH("/:id", api.update, RequireAdminAuth()) - subGroup.DELETE("/:id", api.delete, RequireAdminAuth()) -} - -type adminApi struct { - app core.App -} - -func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error { - token, tokenErr := tokens.NewAdminAuthToken(api.app, admin) - if tokenErr != nil { - return NewBadRequestError("Failed to create auth token.", tokenErr) - } - - event := &core.AdminAuthEvent{ - HttpContext: c, - Admin: admin, - Token: token, - } - - return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error { - return e.HttpContext.JSON(200, map[string]any{ - "token": e.Token, - "admin": e.Admin, - }) - }) -} - -func (api *adminApi) authRefresh(c echo.Context) error { - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin == nil { - return NewNotFoundError("Missing auth admin context.", nil) - } - - return api.authResponse(c, admin) -} - -func (api *adminApi) authWithPassword(c echo.Context) error { - form := forms.NewAdminLogin(api.app) - if readErr := c.Bind(form); readErr != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", readErr) - } - - admin, submitErr := form.Submit() - if submitErr != nil { - return NewBadRequestError("Failed to authenticate.", submitErr) - } - - return api.authResponse(c, admin) -} - -func (api *adminApi) requestPasswordReset(c echo.Context) error { - form := forms.NewAdminPasswordResetRequest(api.app) - if err := c.Bind(form); err != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", err) - } - - if err := form.Validate(); err != nil { - return NewBadRequestError("An error occurred while validating the form.", err) - } - - // run in background because we don't need to show the result - // (prevents admins enumeration) - routine.FireAndForget(func() { - if err := form.Submit(); err != nil && api.app.IsDebug() { - log.Println(err) - } - }) - - return c.NoContent(http.StatusNoContent) -} - -func (api *adminApi) confirmPasswordReset(c echo.Context) error { - form := forms.NewAdminPasswordResetConfirm(api.app) - if readErr := c.Bind(form); readErr != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", readErr) - } - - _, submitErr := form.Submit() - if submitErr != nil { - return NewBadRequestError("Failed to set new password.", submitErr) - } - - return c.NoContent(http.StatusNoContent) -} - -func (api *adminApi) list(c echo.Context) error { - fieldResolver := search.NewSimpleFieldResolver( - "id", "created", "updated", "name", "email", - ) - - admins := []*models.Admin{} - - result, err := search.NewProvider(fieldResolver). - Query(api.app.Dao().AdminQuery()). - ParseAndExec(c.QueryString(), &admins) - - if err != nil { - return NewBadRequestError("", err) - } - - event := &core.AdminsListEvent{ - HttpContext: c, - Admins: admins, - Result: result, - } - - return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error { - return e.HttpContext.JSON(http.StatusOK, e.Result) - }) -} - -func (api *adminApi) view(c echo.Context) error { - id := c.PathParam("id") - if id == "" { - return NewNotFoundError("", nil) - } - - admin, err := api.app.Dao().FindAdminById(id) - if err != nil || admin == nil { - return NewNotFoundError("", err) - } - - event := &core.AdminViewEvent{ - HttpContext: c, - Admin: admin, - } - - return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error { - return e.HttpContext.JSON(http.StatusOK, e.Admin) - }) -} - -func (api *adminApi) create(c echo.Context) error { - admin := &models.Admin{} - - form := forms.NewAdminUpsert(api.app, admin) - - // load request - if err := c.Bind(form); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - event := &core.AdminCreateEvent{ - HttpContext: c, - Admin: admin, - } - - // create the admin - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error { - if err := next(); err != nil { - return NewBadRequestError("Failed to create admin.", err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Admin) - }) - } - }) - - if submitErr == nil { - api.app.OnAdminAfterCreateRequest().Trigger(event) - } - - return submitErr -} - -func (api *adminApi) update(c echo.Context) error { - id := c.PathParam("id") - if id == "" { - return NewNotFoundError("", nil) - } - - admin, err := api.app.Dao().FindAdminById(id) - if err != nil || admin == nil { - return NewNotFoundError("", err) - } - - form := forms.NewAdminUpsert(api.app, admin) - - // load request - if err := c.Bind(form); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - event := &core.AdminUpdateEvent{ - HttpContext: c, - Admin: admin, - } - - // update the admin - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error { - if err := next(); err != nil { - return NewBadRequestError("Failed to update admin.", err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Admin) - }) - } - }) - - if submitErr == nil { - api.app.OnAdminAfterUpdateRequest().Trigger(event) - } - - return submitErr -} - -func (api *adminApi) delete(c echo.Context) error { - id := c.PathParam("id") - if id == "" { - return NewNotFoundError("", nil) - } - - admin, err := api.app.Dao().FindAdminById(id) - if err != nil || admin == nil { - return NewNotFoundError("", err) - } - - event := &core.AdminDeleteEvent{ - HttpContext: c, - Admin: admin, - } - - handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error { - if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil { - return NewBadRequestError("Failed to delete admin.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - - if handlerErr == nil { - api.app.OnAdminAfterDeleteRequest().Trigger(event) - } - - return handlerErr -} diff --git a/apis/admin_test.go b/apis/admin_test.go deleted file mode 100644 index ce74571891d38015015c20dbf986242b7a510bbd..0000000000000000000000000000000000000000 --- a/apis/admin_test.go +++ /dev/null @@ -1,745 +0,0 @@ -package apis_test - -import ( - "net/http" - "strings" - "testing" - "time" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/types" -) - -func TestAdminAuthWithEmail(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/admins/auth-with-password", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"identity":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`}, - }, - { - Name: "invalid data", - Method: http.MethodPost, - Url: "/api/admins/auth-with-password", - Body: strings.NewReader(`{`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "wrong email", - Method: http.MethodPost, - Url: "/api/admins/auth-with-password", - Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "wrong password", - Method: http.MethodPost, - Url: "/api/admins/auth-with-password", - Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid email/password (already authorized)", - Method: http.MethodPost, - Url: "/api/admins/auth-with-password", - Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`}, - }, - { - Name: "valid email/password (guest)", - Method: http.MethodPost, - Url: "/api/admins/auth-with-password", - Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"admin":{"id":"sywbhecnh46rhm0"`, - `"token":`, - }, - ExpectedEvents: map[string]int{ - "OnAdminAuthRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminRequestPasswordReset(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/admins/request-password-reset", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`}, - }, - { - Name: "invalid data", - Method: http.MethodPost, - Url: "/api/admins/request-password-reset", - Body: strings.NewReader(`{"email`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "missing admin", - Method: http.MethodPost, - Url: "/api/admins/request-password-reset", - Body: strings.NewReader(`{"email":"missing@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - }, - { - Name: "existing admin", - Method: http.MethodPost, - Url: "/api/admins/request-password-reset", - Body: strings.NewReader(`{"email":"test@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnMailerBeforeAdminResetPasswordSend": 1, - "OnMailerAfterAdminResetPasswordSend": 1, - }, - }, - { - Name: "existing admin (after already sent)", - Method: http.MethodPost, - Url: "/api/admins/request-password-reset", - Body: strings.NewReader(`{"email":"test@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - // simulate recent password request - admin, err := app.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - admin.LastResetSentAt = types.NowDateTime() - dao := daos.New(app.Dao().DB()) // new dao to ignore hooks - if err := dao.Save(admin); err != nil { - t.Fatal(err) - } - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminConfirmPasswordReset(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/admins/confirm-password-reset", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"password":{"code":"validation_required","message":"Cannot be blank."},"passwordConfirm":{"code":"validation_required","message":"Cannot be blank."},"token":{"code":"validation_required","message":"Cannot be blank."}}`}, - }, - { - Name: "invalid data", - Method: http.MethodPost, - Url: "/api/admins/confirm-password-reset", - Body: strings.NewReader(`{"password`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired token", - Method: http.MethodPost, - Url: "/api/admins/confirm-password-reset", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg", - "password":"1234567890", - "passwordConfirm":"1234567890" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`}, - }, - { - Name: "valid token + invalid password", - Method: http.MethodPost, - Url: "/api/admins/confirm-password-reset", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc", - "password":"123456", - "passwordConfirm":"123456" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"password":{"code":"validation_length_out_of_range"`}, - }, - { - Name: "valid token + valid password", - Method: http.MethodPost, - Url: "/api/admins/confirm-password-reset", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc", - "password":"1234567891", - "passwordConfirm":"1234567891" - }`), - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminRefresh(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPost, - Url: "/api/admins/auth-refresh", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodPost, - Url: "/api/admins/auth-refresh", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin (expired token)", - Method: http.MethodPost, - Url: "/api/admins/auth-refresh", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin (valid token)", - Method: http.MethodPost, - Url: "/api/admins/auth-refresh", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"admin":{"id":"sywbhecnh46rhm0"`, - `"token":`, - }, - ExpectedEvents: map[string]int{ - "OnAdminAuthRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminsList(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/admins", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodGet, - Url: "/api/admins", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin", - Method: http.MethodGet, - Url: "/api/admins", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":3`, - `"items":[{`, - `"id":"sywbhecnh46rhm0"`, - `"id":"sbmbsdb40jyxf7h"`, - `"id":"9q2trqumvlyr3bd"`, - }, - ExpectedEvents: map[string]int{ - "OnAdminsListRequest": 1, - }, - }, - { - Name: "authorized as admin + paging and sorting", - Method: http.MethodGet, - Url: "/api/admins?page=2&perPage=1&sort=-created", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":2`, - `"perPage":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"sbmbsdb40jyxf7h"`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnAdminsListRequest": 1, - }, - }, - { - Name: "authorized as admin + invalid filter", - Method: http.MethodGet, - Url: "/api/admins?filter=invalidfield~'test2'", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + valid filter", - Method: http.MethodGet, - Url: "/api/admins?filter=email~'test3'", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":1`, - `"items":[{`, - `"id":"9q2trqumvlyr3bd"`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnAdminsListRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminView(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/admins/sbmbsdb40jyxf7h", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodGet, - Url: "/api/admins/sbmbsdb40jyxf7h", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + nonexisting admin id", - Method: http.MethodGet, - Url: "/api/admins/nonexisting", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + existing admin id", - Method: http.MethodGet, - Url: "/api/admins/sbmbsdb40jyxf7h", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"sbmbsdb40jyxf7h"`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnAdminViewRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminDelete(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodDelete, - Url: "/api/admins/sbmbsdb40jyxf7h", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodDelete, - Url: "/api/admins/sbmbsdb40jyxf7h", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + missing admin id", - Method: http.MethodDelete, - Url: "/api/admins/missing", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + existing admin id", - Method: http.MethodDelete, - Url: "/api/admins/sbmbsdb40jyxf7h", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeDelete": 1, - "OnModelAfterDelete": 1, - "OnAdminBeforeDeleteRequest": 1, - "OnAdminAfterDeleteRequest": 1, - }, - }, - { - Name: "authorized as admin - try to delete the only remaining admin", - Method: http.MethodDelete, - Url: "/api/admins/sywbhecnh46rhm0", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - // delete all admins except the authorized one - adminModel := &models.Admin{} - _, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{ - "id": "sywbhecnh46rhm0", - })).Execute() - if err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - ExpectedEvents: map[string]int{ - "OnAdminBeforeDeleteRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminCreate(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized (while having at least 1 existing admin)", - Method: http.MethodPost, - Url: "/api/admins", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "unauthorized (while having 0 existing admins)", - Method: http.MethodPost, - Url: "/api/admins", - Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`), - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - // delete all admins - _, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute() - if err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"email":"testnew@example.com"`, - `"avatar":3`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - "OnAdminBeforeCreateRequest": 1, - "OnAdminAfterCreateRequest": 1, - }, - }, - { - Name: "authorized as user", - Method: http.MethodPost, - Url: "/api/admins", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + empty data", - Method: http.MethodPost, - Url: "/api/admins", - Body: strings.NewReader(``), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`}, - }, - { - Name: "authorized as admin + invalid data format", - Method: http.MethodPost, - Url: "/api/admins", - Body: strings.NewReader(`{`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + invalid data", - Method: http.MethodPost, - Url: "/api/admins", - Body: strings.NewReader(`{ - "email":"test@example.com", - "password":"1234", - "passwordConfirm":"4321", - "avatar":99 - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"avatar":{"code":"validation_max_less_equal_than_required"`, - `"email":{"code":"validation_admin_email_exists"`, - `"password":{"code":"validation_length_out_of_range"`, - `"passwordConfirm":{"code":"validation_values_mismatch"`, - }, - }, - { - Name: "authorized as admin + valid data", - Method: http.MethodPost, - Url: "/api/admins", - Body: strings.NewReader(`{ - "email":"testnew@example.com", - "password":"1234567890", - "passwordConfirm":"1234567890", - "avatar":3 - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"email":"testnew@example.com"`, - `"avatar":3`, - }, - NotExpectedContent: []string{ - `"password"`, - `"passwordConfirm"`, - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - "OnAdminBeforeCreateRequest": 1, - "OnAdminAfterCreateRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestAdminUpdate(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPatch, - Url: "/api/admins/sbmbsdb40jyxf7h", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodPatch, - Url: "/api/admins/sbmbsdb40jyxf7h", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + missing admin", - Method: http.MethodPatch, - Url: "/api/admins/missing", - Body: strings.NewReader(``), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + empty data", - Method: http.MethodPatch, - Url: "/api/admins/sbmbsdb40jyxf7h", - Body: strings.NewReader(``), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"sbmbsdb40jyxf7h"`, - `"email":"test2@example.com"`, - `"avatar":2`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnAdminBeforeUpdateRequest": 1, - "OnAdminAfterUpdateRequest": 1, - }, - }, - { - Name: "authorized as admin + invalid formatted data", - Method: http.MethodPatch, - Url: "/api/admins/sbmbsdb40jyxf7h", - Body: strings.NewReader(`{`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + invalid data", - Method: http.MethodPatch, - Url: "/api/admins/sbmbsdb40jyxf7h", - Body: strings.NewReader(`{ - "email":"test@example.com", - "password":"1234", - "passwordConfirm":"4321", - "avatar":99 - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"avatar":{"code":"validation_max_less_equal_than_required"`, - `"email":{"code":"validation_admin_email_exists"`, - `"password":{"code":"validation_length_out_of_range"`, - `"passwordConfirm":{"code":"validation_values_mismatch"`, - }, - }, - { - Method: http.MethodPatch, - Url: "/api/admins/sbmbsdb40jyxf7h", - Body: strings.NewReader(`{ - "email":"testnew@example.com", - "password":"1234567891", - "passwordConfirm":"1234567891", - "avatar":5 - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"sbmbsdb40jyxf7h"`, - `"email":"testnew@example.com"`, - `"avatar":5`, - }, - NotExpectedContent: []string{ - `"password"`, - `"passwordConfirm"`, - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnAdminBeforeUpdateRequest": 1, - "OnAdminAfterUpdateRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/api_error.go b/apis/api_error.go deleted file mode 100644 index f5f8239131bbd39332e25077891088e0ec1f8699..0000000000000000000000000000000000000000 --- a/apis/api_error.go +++ /dev/null @@ -1,108 +0,0 @@ -package apis - -import ( - "net/http" - "strings" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/tools/inflector" -) - -// ApiError defines the struct for a basic api error response. -type ApiError struct { - Code int `json:"code"` - Message string `json:"message"` - Data map[string]any `json:"data"` - - // stores unformatted error data (could be an internal error, text, etc.) - rawData any -} - -// Error makes it compatible with the `error` interface. -func (e *ApiError) Error() string { - return e.Message -} - -// RawData returns the unformatted error data (could be an internal error, text, etc.) -func (e *ApiError) RawData() any { - return e.rawData -} - -// NewNotFoundError creates and returns 404 `ApiError`. -func NewNotFoundError(message string, data any) *ApiError { - if message == "" { - message = "The requested resource wasn't found." - } - - return NewApiError(http.StatusNotFound, message, data) -} - -// NewBadRequestError creates and returns 400 `ApiError`. -func NewBadRequestError(message string, data any) *ApiError { - if message == "" { - message = "Something went wrong while processing your request." - } - - return NewApiError(http.StatusBadRequest, message, data) -} - -// NewForbiddenError creates and returns 403 `ApiError`. -func NewForbiddenError(message string, data any) *ApiError { - if message == "" { - message = "You are not allowed to perform this request." - } - - return NewApiError(http.StatusForbidden, message, data) -} - -// NewUnauthorizedError creates and returns 401 `ApiError`. -func NewUnauthorizedError(message string, data any) *ApiError { - if message == "" { - message = "Missing or invalid authentication token." - } - - return NewApiError(http.StatusUnauthorized, message, data) -} - -// NewApiError creates and returns new normalized `ApiError` instance. -func NewApiError(status int, message string, data any) *ApiError { - message = inflector.Sentenize(message) - - formattedData := map[string]any{} - - if v, ok := data.(validation.Errors); ok { - formattedData = resolveValidationErrors(v) - } - - return &ApiError{ - rawData: data, - Data: formattedData, - Code: status, - Message: strings.TrimSpace(message), - } -} - -func resolveValidationErrors(validationErrors validation.Errors) map[string]any { - result := map[string]any{} - - // extract from each validation error its error code and message. - for name, err := range validationErrors { - // check for nested errors - if nestedErrs, ok := err.(validation.Errors); ok { - result[name] = resolveValidationErrors(nestedErrs) - continue - } - - errCode := "validation_invalid_value" // default - if errObj, ok := err.(validation.ErrorObject); ok { - errCode = errObj.Code() - } - - result[name] = map[string]string{ - "code": errCode, - "message": inflector.Sentenize(err.Error()), - } - } - - return result -} diff --git a/apis/api_error_test.go b/apis/api_error_test.go deleted file mode 100644 index c9744f4b935c531f206e83f4fa259c2da039908a..0000000000000000000000000000000000000000 --- a/apis/api_error_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package apis_test - -import ( - "encoding/json" - "errors" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/apis" -) - -func TestNewApiErrorWithRawData(t *testing.T) { - e := apis.NewApiError( - 300, - "message_test", - "rawData_test", - ) - - result, _ := json.Marshal(e) - expected := `{"code":300,"message":"Message_test.","data":{}}` - - if string(result) != expected { - t.Errorf("Expected %v, got %v", expected, string(result)) - } - - if e.Error() != "Message_test." { - t.Errorf("Expected %q, got %q", "Message_test.", e.Error()) - } - - if e.RawData() != "rawData_test" { - t.Errorf("Expected rawData %v, got %v", "rawData_test", e.RawData()) - } -} - -func TestNewApiErrorWithValidationData(t *testing.T) { - e := apis.NewApiError( - 300, - "message_test", - validation.Errors{ - "err1": errors.New("test error"), - "err2": validation.ErrRequired, - "err3": validation.Errors{ - "sub1": errors.New("test error"), - "sub2": validation.ErrRequired, - "sub3": validation.Errors{ - "sub11": validation.ErrRequired, - }, - }, - }, - ) - - result, _ := json.Marshal(e) - expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}` - - if string(result) != expected { - t.Errorf("Expected %v, got %v", expected, string(result)) - } - - if e.Error() != "Message_test." { - t.Errorf("Expected %q, got %q", "Message_test.", e.Error()) - } - - if e.RawData() == nil { - t.Error("Expected non-nil rawData") - } -} - -func TestNewNotFoundError(t *testing.T) { - scenarios := []struct { - message string - data any - expected string - }{ - {"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`}, - {"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`}, - {"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`}, - } - - for i, scenario := range scenarios { - e := apis.NewNotFoundError(scenario.message, scenario.data) - result, _ := json.Marshal(e) - - if string(result) != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result)) - } - } -} - -func TestNewBadRequestError(t *testing.T) { - scenarios := []struct { - message string - data any - expected string - }{ - {"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`}, - {"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`}, - {"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`}, - } - - for i, scenario := range scenarios { - e := apis.NewBadRequestError(scenario.message, scenario.data) - result, _ := json.Marshal(e) - - if string(result) != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result)) - } - } -} - -func TestNewForbiddenError(t *testing.T) { - scenarios := []struct { - message string - data any - expected string - }{ - {"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`}, - {"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`}, - {"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`}, - } - - for i, scenario := range scenarios { - e := apis.NewForbiddenError(scenario.message, scenario.data) - result, _ := json.Marshal(e) - - if string(result) != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result)) - } - } -} - -func TestNewUnauthorizedError(t *testing.T) { - scenarios := []struct { - message string - data any - expected string - }{ - {"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`}, - {"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`}, - {"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`}, - } - - for i, scenario := range scenarios { - e := apis.NewUnauthorizedError(scenario.message, scenario.data) - result, _ := json.Marshal(e) - - if string(result) != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result)) - } - } -} diff --git a/apis/base.go b/apis/base.go deleted file mode 100644 index 4e2e12c10c7f4927a3e24a3b466beff5fd4c06b7..0000000000000000000000000000000000000000 --- a/apis/base.go +++ /dev/null @@ -1,231 +0,0 @@ -// Package apis implements the default PocketBase api services and middlewares. -package apis - -import ( - "errors" - "fmt" - "io/fs" - "log" - "net/http" - "net/url" - "path/filepath" - "strings" - - "github.com/labstack/echo/v5" - "github.com/labstack/echo/v5/middleware" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/ui" - "github.com/spf13/cast" -) - -const trailedAdminPath = "/_/" - -// InitApi creates a configured echo instance with registered -// system and app specific routes and middlewares. -func InitApi(app core.App) (*echo.Echo, error) { - e := echo.New() - e.Debug = app.IsDebug() - - // default middlewares - e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{ - Skipper: func(c echo.Context) bool { - // ignore Admin UI route(s) - return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath) - }, - })) - e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{DisablePrintStack: true})) - e.Use(middleware.Secure()) - e.Use(LoadAuthContext(app)) - - // custom error handler - e.HTTPErrorHandler = func(c echo.Context, err error) { - if c.Response().Committed { - return - } - - var apiErr *ApiError - - switch v := err.(type) { - case *echo.HTTPError: - if v.Internal != nil && app.IsDebug() { - log.Println(v.Internal) - } - msg := fmt.Sprintf("%v", v.Message) - apiErr = NewApiError(v.Code, msg, v) - case *ApiError: - if app.IsDebug() && v.RawData() != nil { - log.Println(v.RawData()) - } - apiErr = v - default: - if err != nil && app.IsDebug() { - log.Println(err) - } - apiErr = NewBadRequestError("", err) - } - - event := &core.ApiErrorEvent{ - HttpContext: c, - Error: apiErr, - } - - // send error response - hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error { - // @see https://github.com/labstack/echo/issues/608 - if e.HttpContext.Request().Method == http.MethodHead { - return e.HttpContext.NoContent(apiErr.Code) - } - - return e.HttpContext.JSON(apiErr.Code, apiErr) - }) - - // truly rare case; eg. client already disconnected - if hookErr != nil && app.IsDebug() { - log.Println(hookErr) - } - - app.OnAfterApiError().Trigger(event) - } - - // admin ui routes - bindStaticAdminUI(app, e) - - // default routes - api := e.Group("/api") - bindSettingsApi(app, api) - bindAdminApi(app, api) - bindCollectionApi(app, api) - bindRecordCrudApi(app, api) - bindRecordAuthApi(app, api) - bindFileApi(app, api) - bindRealtimeApi(app, api) - bindLogsApi(app, api) - - // trigger the custom BeforeServe hook for the created api router - // allowing users to further adjust its options or register new routes - serveEvent := &core.ServeEvent{ - App: app, - Router: e, - } - if err := app.OnBeforeServe().Trigger(serveEvent); err != nil { - return nil, err - } - - // catch all any route - api.Any("/*", func(c echo.Context) error { - return echo.ErrNotFound - }, ActivityLogger(app)) - - return e, nil -} - -// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler` -// but without the directory redirect which conflicts with RemoveTrailingSlash middleware. -// -// If a file resource is missing and indexFallback is set, the request -// will be forwarded to the base index.html (useful also for SPA). -// -// @see https://github.com/labstack/echo/issues/2211 -func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc { - return func(c echo.Context) error { - p := c.PathParam("*") - - // escape url path - tmpPath, err := url.PathUnescape(p) - if err != nil { - return fmt.Errorf("failed to unescape path variable: %w", err) - } - p = tmpPath - - // fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid - name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/"))) - - fileErr := c.FileFS(name, fileSystem) - - if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) { - return c.FileFS("index.html", fileSystem) - } - - return fileErr - } -} - -// bindStaticAdminUI registers the endpoints that serves the static admin UI. -func bindStaticAdminUI(app core.App, e *echo.Echo) error { - // redirect to trailing slash to ensure that relative urls will still work properly - e.GET( - strings.TrimRight(trailedAdminPath, "/"), - func(c echo.Context) error { - return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath) - }, - ) - - // serves static files from the /ui/dist directory - // (similar to echo.StaticFS but with gzip middleware enabled) - e.GET( - trailedAdminPath+"*", - echo.StaticDirectoryHandler(ui.DistDirFS, false), - installerRedirect(app), - middleware.Gzip(), - ) - - return nil -} - -const totalAdminsCacheKey = "totalAdmins" - -func updateTotalAdminsCache(app core.App) error { - total, err := app.Dao().TotalAdmins() - if err != nil { - return err - } - - app.Cache().Set(totalAdminsCacheKey, total) - - return nil -} - -// installerRedirect redirects the user to the installer admin UI page -// when the application needs some preliminary configurations to be done. -func installerRedirect(app core.App) echo.MiddlewareFunc { - // keep totalAdminsCacheKey value up-to-date - app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error { - return updateTotalAdminsCache(app) - }) - app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error { - return updateTotalAdminsCache(app) - }) - - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - // skip redirect checks for non-root level index.html requests - path := c.Request().URL.Path - if path != trailedAdminPath && path != trailedAdminPath+"index.html" { - return next(c) - } - - // load into cache (if not already) - if !app.Cache().Has(totalAdminsCacheKey) { - if err := updateTotalAdminsCache(app); err != nil { - return err - } - } - - totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey)) - - _, hasInstallerParam := c.Request().URL.Query()["installer"] - - if totalAdmins == 0 && !hasInstallerParam { - // redirect to the installer page - return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#") - } - - if totalAdmins != 0 && hasInstallerParam { - // redirect to the home page - return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/") - } - - return next(c) - } - } -} diff --git a/apis/base_test.go b/apis/base_test.go deleted file mode 100644 index b676b65942842ebf6806917677b9996018336a5e..0000000000000000000000000000000000000000 --- a/apis/base_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package apis_test - -import ( - "errors" - "net/http" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/tests" -) - -func Test404(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Method: http.MethodGet, - Url: "/api/missing", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Method: http.MethodPost, - Url: "/api/missing", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Method: http.MethodPatch, - Url: "/api/missing", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Method: http.MethodDelete, - Url: "/api/missing", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Method: http.MethodHead, - Url: "/api/missing", - ExpectedStatus: 404, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestCustomRoutesAndErrorsHandling(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "custom route", - Method: http.MethodGet, - Url: "/custom", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/custom", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "route with HTTPError", - Method: http.MethodGet, - Url: "/http-error", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/http-error", - Handler: func(c echo.Context) error { - return echo.ErrBadRequest - }, - }) - }, - ExpectedStatus: 400, - ExpectedContent: []string{`{"code":400,"message":"Bad Request.","data":{}}`}, - }, - { - Name: "route with api error", - Method: http.MethodGet, - Url: "/api-error", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/api-error", - Handler: func(c echo.Context) error { - return apis.NewApiError(500, "test message", errors.New("internal_test")) - }, - }) - }, - ExpectedStatus: 500, - ExpectedContent: []string{`{"code":500,"message":"Test message.","data":{}}`}, - }, - { - Name: "route with plain error", - Method: http.MethodGet, - Url: "/plain-error", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/plain-error", - Handler: func(c echo.Context) error { - return errors.New("Test error") - }, - }) - }, - ExpectedStatus: 400, - ExpectedContent: []string{`{"code":400,"message":"Something went wrong while processing your request.","data":{}}`}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/collection.go b/apis/collection.go deleted file mode 100644 index 861de01b03a05973bf459ac7b0ba4c701888505b..0000000000000000000000000000000000000000 --- a/apis/collection.go +++ /dev/null @@ -1,204 +0,0 @@ -package apis - -import ( - "net/http" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/search" -) - -// bindCollectionApi registers the collection api endpoints and the corresponding handlers. -func bindCollectionApi(app core.App, rg *echo.Group) { - api := collectionApi{app: app} - - subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth()) - subGroup.GET("", api.list) - subGroup.POST("", api.create) - subGroup.GET("/:collection", api.view) - subGroup.PATCH("/:collection", api.update) - subGroup.DELETE("/:collection", api.delete) - subGroup.PUT("/import", api.bulkImport) -} - -type collectionApi struct { - app core.App -} - -func (api *collectionApi) list(c echo.Context) error { - fieldResolver := search.NewSimpleFieldResolver( - "id", "created", "updated", "name", "system", "type", - ) - - collections := []*models.Collection{} - - result, err := search.NewProvider(fieldResolver). - Query(api.app.Dao().CollectionQuery()). - ParseAndExec(c.QueryString(), &collections) - - if err != nil { - return NewBadRequestError("", err) - } - - event := &core.CollectionsListEvent{ - HttpContext: c, - Collections: collections, - Result: result, - } - - return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error { - return e.HttpContext.JSON(http.StatusOK, e.Result) - }) -} - -func (api *collectionApi) view(c echo.Context) error { - collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) - if err != nil || collection == nil { - return NewNotFoundError("", err) - } - - event := &core.CollectionViewEvent{ - HttpContext: c, - Collection: collection, - } - - return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error { - return e.HttpContext.JSON(http.StatusOK, e.Collection) - }) -} - -func (api *collectionApi) create(c echo.Context) error { - collection := &models.Collection{} - - form := forms.NewCollectionUpsert(api.app, collection) - - // load request - if err := c.Bind(form); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - event := &core.CollectionCreateEvent{ - HttpContext: c, - Collection: collection, - } - - // create the collection - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error { - if err := next(); err != nil { - return NewBadRequestError("Failed to create the collection.", err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Collection) - }) - } - }) - - if submitErr == nil { - api.app.OnCollectionAfterCreateRequest().Trigger(event) - } - - return submitErr -} - -func (api *collectionApi) update(c echo.Context) error { - collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) - if err != nil || collection == nil { - return NewNotFoundError("", err) - } - - form := forms.NewCollectionUpsert(api.app, collection) - - // load request - if err := c.Bind(form); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - event := &core.CollectionUpdateEvent{ - HttpContext: c, - Collection: collection, - } - - // update the collection - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error { - if err := next(); err != nil { - return NewBadRequestError("Failed to update the collection.", err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Collection) - }) - } - }) - - if submitErr == nil { - api.app.OnCollectionAfterUpdateRequest().Trigger(event) - } - - return submitErr -} - -func (api *collectionApi) delete(c echo.Context) error { - collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) - if err != nil || collection == nil { - return NewNotFoundError("", err) - } - - event := &core.CollectionDeleteEvent{ - HttpContext: c, - Collection: collection, - } - - handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error { - if err := api.app.Dao().DeleteCollection(e.Collection); err != nil { - return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - - if handlerErr == nil { - api.app.OnCollectionAfterDeleteRequest().Trigger(event) - } - - return handlerErr -} - -func (api *collectionApi) bulkImport(c echo.Context) error { - form := forms.NewCollectionsImport(api.app) - - // load request data - if err := c.Bind(form); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - event := &core.CollectionsImportEvent{ - HttpContext: c, - Collections: form.Collections, - } - - // import collections - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error { - form.Collections = e.Collections // ensures that the form always has the latest changes - - if err := next(); err != nil { - return NewBadRequestError("Failed to import the submitted collections.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - } - }) - - if submitErr == nil { - api.app.OnCollectionsAfterImportRequest().Trigger(event) - } - - return submitErr -} diff --git a/apis/collection_test.go b/apis/collection_test.go deleted file mode 100644 index 1ac9b49e2315402a5e4899f3e66a53a084083387..0000000000000000000000000000000000000000 --- a/apis/collection_test.go +++ /dev/null @@ -1,1022 +0,0 @@ -package apis_test - -import ( - "net/http" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestCollectionsList(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/collections", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodGet, - Url: "/api/collections", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin", - Method: http.MethodGet, - Url: "/api/collections", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":7`, - `"items":[{`, - `"id":"_pb_users_auth_"`, - `"id":"v851q4r790rhknl"`, - `"id":"kpv709sk2lqbqk8"`, - `"id":"wsmn24bux7wo113"`, - `"id":"sz5l5z67tg7gku0"`, - `"id":"wzlqyes4orhoygb"`, - `"id":"4d1blo5cuycfaca"`, - `"type":"auth"`, - `"type":"base"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionsListRequest": 1, - }, - }, - { - Name: "authorized as admin + paging and sorting", - Method: http.MethodGet, - Url: "/api/collections?page=2&perPage=2&sort=-created", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":2`, - `"perPage":2`, - `"totalItems":7`, - `"items":[{`, - `"id":"4d1blo5cuycfaca"`, - `"id":"wzlqyes4orhoygb"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionsListRequest": 1, - }, - }, - { - Name: "authorized as admin + invalid filter", - Method: http.MethodGet, - Url: "/api/collections?filter=invalidfield~'demo2'", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + valid filter", - Method: http.MethodGet, - Url: "/api/collections?filter=name~'demo'", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":4`, - `"items":[{`, - `"id":"wsmn24bux7wo113"`, - `"id":"sz5l5z67tg7gku0"`, - `"id":"wzlqyes4orhoygb"`, - `"id":"4d1blo5cuycfaca"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionsListRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestCollectionView(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/collections/demo1", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodGet, - Url: "/api/collections/demo1", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + nonexisting collection identifier", - Method: http.MethodGet, - Url: "/api/collections/missing", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + using the collection name", - Method: http.MethodGet, - Url: "/api/collections/demo1", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"wsmn24bux7wo113"`, - `"name":"demo1"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionViewRequest": 1, - }, - }, - { - Name: "authorized as admin + using the collection id", - Method: http.MethodGet, - Url: "/api/collections/wsmn24bux7wo113", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"wsmn24bux7wo113"`, - `"name":"demo1"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionViewRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestCollectionDelete(t *testing.T) { - ensureDeletedFiles := func(app *tests.TestApp, collectionId string) { - storageDir := filepath.Join(app.DataDir(), "storage", collectionId) - - entries, _ := os.ReadDir(storageDir) - if len(entries) != 0 { - t.Errorf("Expected empty/deleted dir, found %d", len(entries)) - } - } - - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodDelete, - Url: "/api/collections/demo1", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodDelete, - Url: "/api/collections/demo1", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + nonexisting collection identifier", - Method: http.MethodDelete, - Url: "/api/collections/missing", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + using the collection name", - Method: http.MethodDelete, - Url: "/api/collections/demo1", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeDelete": 1, - "OnModelAfterDelete": 1, - "OnCollectionBeforeDeleteRequest": 1, - "OnCollectionAfterDeleteRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - ensureDeletedFiles(app, "wsmn24bux7wo113") - }, - }, - { - Name: "authorized as admin + using the collection id", - Method: http.MethodDelete, - Url: "/api/collections/wsmn24bux7wo113", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeDelete": 1, - "OnModelAfterDelete": 1, - "OnCollectionBeforeDeleteRequest": 1, - "OnCollectionAfterDeleteRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - ensureDeletedFiles(app, "wsmn24bux7wo113") - }, - }, - { - Name: "authorized as admin + trying to delete a system collection", - Method: http.MethodDelete, - Url: "/api/collections/nologin", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - ExpectedEvents: map[string]int{ - "OnCollectionBeforeDeleteRequest": 1, - }, - }, - { - Name: "authorized as admin + trying to delete a referenced collection", - Method: http.MethodDelete, - Url: "/api/collections/demo2", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - ExpectedEvents: map[string]int{ - "OnCollectionBeforeDeleteRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestCollectionCreate(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPost, - Url: "/api/collections", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodPost, - Url: "/api/collections", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + empty data", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(``), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"name":{"code":"validation_required"`, - `"schema":{"code":"validation_required"`, - }, - }, - { - Name: "authorized as admin + invalid data (eg. existing name)", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(`{"name":"demo1","type":"base","schema":[{"type":"text","name":""}]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"name":{"code":"validation_collection_name_exists"`, - `"schema":{"0":{"name":{"code":"validation_required"`, - }, - }, - { - Name: "authorized as admin + valid data", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"name":"new"`, - `"type":"base"`, - `"system":false`, - `"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`, - `"options":{}`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - "OnCollectionBeforeCreateRequest": 1, - "OnCollectionAfterCreateRequest": 1, - }, - }, - { - Name: "creating auth collection without specified options", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(`{"name":"new","type":"auth","schema":[{"type":"text","id":"12345789","name":"test"}]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"name":"new"`, - `"type":"auth"`, - `"system":false`, - `"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`, - `"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - "OnCollectionBeforeCreateRequest": 1, - "OnCollectionAfterCreateRequest": 1, - }, - }, - { - Name: "trying to create auth collection with reserved auth fields", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(`{ - "name":"new", - "type":"auth", - "schema":[ - {"type":"text","name":"email"}, - {"type":"text","name":"username"}, - {"type":"text","name":"verified"}, - {"type":"text","name":"emailVisibility"}, - {"type":"text","name":"lastResetSentAt"}, - {"type":"text","name":"lastVerificationSentAt"}, - {"type":"text","name":"tokenKey"}, - {"type":"text","name":"passwordHash"}, - {"type":"text","name":"password"}, - {"type":"text","name":"passwordConfirm"}, - {"type":"text","name":"oldPassword"} - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{"schema":{`, - `"0":{"name":{"code":"validation_reserved_auth_field_name"`, - `"1":{"name":{"code":"validation_reserved_auth_field_name"`, - `"2":{"name":{"code":"validation_reserved_auth_field_name"`, - `"3":{"name":{"code":"validation_reserved_auth_field_name"`, - `"4":{"name":{"code":"validation_reserved_auth_field_name"`, - `"5":{"name":{"code":"validation_reserved_auth_field_name"`, - `"6":{"name":{"code":"validation_reserved_auth_field_name"`, - `"7":{"name":{"code":"validation_reserved_auth_field_name"`, - `"8":{"name":{"code":"validation_reserved_auth_field_name"`, - }, - }, - { - Name: "creating base collection with reserved auth fields", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(`{ - "name":"new", - "type":"base", - "schema":[ - {"type":"text","name":"email"}, - {"type":"text","name":"username"}, - {"type":"text","name":"verified"}, - {"type":"text","name":"emailVisibility"}, - {"type":"text","name":"lastResetSentAt"}, - {"type":"text","name":"lastVerificationSentAt"}, - {"type":"text","name":"tokenKey"}, - {"type":"text","name":"passwordHash"}, - {"type":"text","name":"password"}, - {"type":"text","name":"passwordConfirm"}, - {"type":"text","name":"oldPassword"} - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"name":"new"`, - `"type":"base"`, - `"schema":[{`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - "OnCollectionBeforeCreateRequest": 1, - "OnCollectionAfterCreateRequest": 1, - }, - }, - { - Name: "trying to create base collection with reserved base fields", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(`{ - "name":"new", - "type":"base", - "schema":[ - {"type":"text","name":"id"}, - {"type":"text","name":"created"}, - {"type":"text","name":"updated"}, - {"type":"text","name":"expand"}, - {"type":"text","name":"collectionId"}, - {"type":"text","name":"collectionName"} - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{"schema":{`, - `"0":{"name":{"code":"validation_not_in_invalid`, - `"1":{"name":{"code":"validation_not_in_invalid`, - `"2":{"name":{"code":"validation_not_in_invalid`, - `"3":{"name":{"code":"validation_not_in_invalid`, - `"4":{"name":{"code":"validation_not_in_invalid`, - `"5":{"name":{"code":"validation_not_in_invalid`, - }, - }, - { - Name: "trying to create auth collection with invalid options", - Method: http.MethodPost, - Url: "/api/collections", - Body: strings.NewReader(`{ - "name":"new", - "type":"auth", - "schema":[{"type":"text","id":"12345789","name":"test"}], - "options":{"allowUsernameAuth": true} - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"options":{"minPasswordLength":{"code":"validation_required"`, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestCollectionUpdate(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPatch, - Url: "/api/collections/demo1", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodPatch, - Url: "/api/collections/demo1", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + missing collection", - Method: http.MethodPatch, - Url: "/api/collections/missing", - Body: strings.NewReader(`{}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + empty body", - Method: http.MethodPatch, - Url: "/api/collections/demo1", - Body: strings.NewReader(`{}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"wsmn24bux7wo113"`, - `"name":"demo1"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionAfterUpdateRequest": 1, - "OnCollectionBeforeUpdateRequest": 1, - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - }, - }, - { - Name: "authorized as admin + invalid data (eg. existing name)", - Method: http.MethodPatch, - Url: "/api/collections/demo1", - Body: strings.NewReader(`{ - "name":"demo2", - "type":"auth" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"name":{"code":"validation_collection_name_exists"`, - `"type":{"code":"validation_collection_type_change"`, - }, - }, - { - Name: "authorized as admin + valid data", - Method: http.MethodPatch, - Url: "/api/collections/demo1", - Body: strings.NewReader(`{"name":"new"}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"name":"new"`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnCollectionBeforeUpdateRequest": 1, - "OnCollectionAfterUpdateRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - // check if the record table was renamed - if !app.Dao().HasTable("new") { - t.Fatal("Couldn't find record table 'new'.") - } - }, - }, - { - Name: "trying to update auth collection with reserved auth fields", - Method: http.MethodPatch, - Url: "/api/collections/users", - Body: strings.NewReader(`{ - "schema":[ - {"type":"text","name":"email"}, - {"type":"text","name":"username"}, - {"type":"text","name":"verified"}, - {"type":"text","name":"emailVisibility"}, - {"type":"text","name":"lastResetSentAt"}, - {"type":"text","name":"lastVerificationSentAt"}, - {"type":"text","name":"tokenKey"}, - {"type":"text","name":"passwordHash"}, - {"type":"text","name":"password"}, - {"type":"text","name":"passwordConfirm"}, - {"type":"text","name":"oldPassword"} - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{"schema":{`, - `"0":{"name":{"code":"validation_reserved_auth_field_name"`, - `"1":{"name":{"code":"validation_reserved_auth_field_name"`, - `"2":{"name":{"code":"validation_reserved_auth_field_name"`, - `"3":{"name":{"code":"validation_reserved_auth_field_name"`, - `"4":{"name":{"code":"validation_reserved_auth_field_name"`, - `"5":{"name":{"code":"validation_reserved_auth_field_name"`, - `"6":{"name":{"code":"validation_reserved_auth_field_name"`, - `"7":{"name":{"code":"validation_reserved_auth_field_name"`, - `"8":{"name":{"code":"validation_reserved_auth_field_name"`, - }, - }, - { - Name: "updating base collection with reserved auth fields", - Method: http.MethodPatch, - Url: "/api/collections/demo1", - Body: strings.NewReader(`{ - "schema":[ - {"type":"text","name":"email"}, - {"type":"text","name":"username"}, - {"type":"text","name":"verified"}, - {"type":"text","name":"emailVisibility"}, - {"type":"text","name":"lastResetSentAt"}, - {"type":"text","name":"lastVerificationSentAt"}, - {"type":"text","name":"tokenKey"}, - {"type":"text","name":"passwordHash"}, - {"type":"text","name":"password"}, - {"type":"text","name":"passwordConfirm"}, - {"type":"text","name":"oldPassword"} - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"name":"demo1"`, - `"type":"base"`, - `"schema":[{`, - `"email"`, - `"username"`, - `"verified"`, - `"emailVisibility"`, - `"lastResetSentAt"`, - `"lastVerificationSentAt"`, - `"tokenKey"`, - `"passwordHash"`, - `"password"`, - `"passwordConfirm"`, - `"oldPassword"`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnCollectionBeforeUpdateRequest": 1, - "OnCollectionAfterUpdateRequest": 1, - }, - }, - { - Name: "trying to update base collection with reserved base fields", - Method: http.MethodPatch, - Url: "/api/collections/demo1", - Body: strings.NewReader(`{ - "name":"new", - "type":"base", - "schema":[ - {"type":"text","name":"id"}, - {"type":"text","name":"created"}, - {"type":"text","name":"updated"}, - {"type":"text","name":"expand"}, - {"type":"text","name":"collectionId"}, - {"type":"text","name":"collectionName"} - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{"schema":{`, - `"0":{"name":{"code":"validation_not_in_invalid`, - `"1":{"name":{"code":"validation_not_in_invalid`, - `"2":{"name":{"code":"validation_not_in_invalid`, - `"3":{"name":{"code":"validation_not_in_invalid`, - `"4":{"name":{"code":"validation_not_in_invalid`, - `"5":{"name":{"code":"validation_not_in_invalid`, - }, - }, - { - Name: "trying to update auth collection with invalid options", - Method: http.MethodPatch, - Url: "/api/collections/users", - Body: strings.NewReader(`{ - "options":{"minPasswordLength": 4} - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"options":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required"`, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestCollectionImport(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPut, - Url: "/api/collections/import", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as user", - Method: http.MethodPut, - Url: "/api/collections/import", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin + empty collections", - Method: http.MethodPut, - Url: "/api/collections/import", - Body: strings.NewReader(`{"collections":[]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"collections":{"code":"validation_required"`, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - collections := []*models.Collection{} - if err := app.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - expected := 7 - if len(collections) != expected { - t.Fatalf("Expected %d collections, got %d", expected, len(collections)) - } - }, - }, - { - Name: "authorized as admin + trying to delete system collections", - Method: http.MethodPut, - Url: "/api/collections/import", - Body: strings.NewReader(`{"deleteMissing": true, "collections":[{"name": "test123"}]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"collections":{"code":"collections_import_failure"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionsBeforeImportRequest": 1, - "OnModelBeforeDelete": 6, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - collections := []*models.Collection{} - if err := app.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - expected := 7 - if len(collections) != expected { - t.Fatalf("Expected %d collections, got %d", expected, len(collections)) - } - }, - }, - { - Name: "authorized as admin + collections validator failure", - Method: http.MethodPut, - Url: "/api/collections/import", - Body: strings.NewReader(`{ - "collections":[ - { - "name": "import1", - "schema": [ - { - "id": "koih1lqx", - "name": "test", - "type": "text" - } - ] - }, - {"name": "import2"} - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"collections":{"code":"collections_import_validate_failure"`, - }, - ExpectedEvents: map[string]int{ - "OnCollectionsBeforeImportRequest": 1, - "OnModelBeforeCreate": 2, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - collections := []*models.Collection{} - if err := app.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - expected := 7 - if len(collections) != expected { - t.Fatalf("Expected %d collections, got %d", expected, len(collections)) - } - }, - }, - { - Name: "authorized as admin + successful collections save", - Method: http.MethodPut, - Url: "/api/collections/import", - Body: strings.NewReader(`{ - "collections":[ - { - "name": "import1", - "schema": [ - { - "id": "koih1lqx", - "name": "test", - "type": "text" - } - ] - }, - { - "name": "import2", - "schema": [ - { - "id": "koih1lqx", - "name": "test", - "type": "text" - } - ] - }, - { - "name": "auth_without_schema", - "type": "auth" - } - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnCollectionsBeforeImportRequest": 1, - "OnCollectionsAfterImportRequest": 1, - "OnModelBeforeCreate": 3, - "OnModelAfterCreate": 3, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - collections := []*models.Collection{} - if err := app.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - expected := 10 - if len(collections) != expected { - t.Fatalf("Expected %d collections, got %d", expected, len(collections)) - } - }, - }, - { - Name: "authorized as admin + successful collections save and old non-system collections deletion", - Method: http.MethodPut, - Url: "/api/collections/import", - Body: strings.NewReader(`{ - "deleteMissing": true, - "collections":[ - { - "name": "new_import", - "schema": [ - { - "id": "koih1lqx", - "name": "test", - "type": "text" - } - ] - }, - { - "id": "kpv709sk2lqbqk8", - "system": true, - "name": "nologin", - "type": "auth", - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": [], - "manageRule": "@request.auth.collectionName = 'users'", - "minPasswordLength": 8, - "onlyEmailDomains": [], - "requireEmail": true - }, - "listRule": "", - "viewRule": "", - "createRule": "", - "updateRule": "", - "deleteRule": "", - "schema": [ - { - "id": "x8zzktwe", - "name": "name", - "type": "text", - "system": false, - "required": false, - "unique": false, - "options": { - "min": null, - "max": null, - "pattern": "" - } - } - ] - }, - { - "id":"wsmn24bux7wo113", - "name":"demo1", - "schema":[ - { - "id":"_2hlxbmp", - "name":"title", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - } - ] - } - ] - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnCollectionsAfterImportRequest": 1, - "OnCollectionsBeforeImportRequest": 1, - "OnModelBeforeDelete": 5, - "OnModelAfterDelete": 5, - "OnModelBeforeUpdate": 2, - "OnModelAfterUpdate": 2, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - collections := []*models.Collection{} - if err := app.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - expected := 3 - if len(collections) != expected { - t.Fatalf("Expected %d collections, got %d", expected, len(collections)) - } - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/file.go b/apis/file.go deleted file mode 100644 index dd18107427bb2fd7d13d0cc5ab5cb7ee101cb410..0000000000000000000000000000000000000000 --- a/apis/file.go +++ /dev/null @@ -1,105 +0,0 @@ -package apis - -import ( - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/list" -) - -var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"} -var defaultThumbSizes = []string{"100x100"} - -// bindFileApi registers the file api endpoints and the corresponding handlers. -func bindFileApi(app core.App, rg *echo.Group) { - api := fileApi{app: app} - - subGroup := rg.Group("/files", ActivityLogger(app)) - subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app)) -} - -type fileApi struct { - app core.App -} - -func (api *fileApi) download(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("", nil) - } - - recordId := c.PathParam("recordId") - if recordId == "" { - return NewNotFoundError("", nil) - } - - record, err := api.app.Dao().FindRecordById(collection.Id, recordId) - if err != nil { - return NewNotFoundError("", err) - } - - filename := c.PathParam("filename") - - fileField := record.FindFileFieldByFile(filename) - if fileField == nil { - return NewNotFoundError("", nil) - } - options, _ := fileField.Options.(*schema.FileOptions) - - fs, err := api.app.NewFilesystem() - if err != nil { - return NewBadRequestError("Filesystem initialization failure.", err) - } - defer fs.Close() - - originalPath := record.BaseFilesPath() + "/" + filename - servedPath := originalPath - servedName := filename - - // check for valid thumb size param - thumbSize := c.QueryParam("thumb") - if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) { - // extract the original file meta attributes and check it existence - oAttrs, oAttrsErr := fs.Attributes(originalPath) - if oAttrsErr != nil { - return NewNotFoundError("", err) - } - - // check if it is an image - if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) { - // add thumb size as file suffix - servedName = thumbSize + "_" + filename - servedPath = record.BaseFilesPath() + "/thumbs_" + filename + "/" + servedName - - // check if the thumb exists: - // - if doesn't exist - create a new thumb with the specified thumb size - // - if exists - compare last modified dates to determine whether the thumb should be recreated - tAttrs, tAttrsErr := fs.Attributes(servedPath) - if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) { - if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil { - servedPath = originalPath // fallback to the original - } - } - } - } - - event := &core.FileDownloadEvent{ - HttpContext: c, - Record: record, - Collection: collection, - FileField: fileField, - ServedPath: servedPath, - ServedName: servedName, - } - - return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error { - res := e.HttpContext.Response() - req := e.HttpContext.Request() - if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil { - return NewNotFoundError("", err) - } - - return nil - }) -} diff --git a/apis/file_test.go b/apis/file_test.go deleted file mode 100644 index a2f10735a6216a6d894e67e655eba3e37dbfd5e6..0000000000000000000000000000000000000000 --- a/apis/file_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package apis_test - -import ( - "net/http" - "os" - "path" - "path/filepath" - "runtime" - "testing" - - "github.com/pocketbase/pocketbase/tests" -) - -func TestFileDownload(t *testing.T) { - _, currentFile, _, _ := runtime.Caller(0) - dataDirRelPath := "../tests/data/" - - testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt") - testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png") - testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png") - testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png") - testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png") - testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png") - testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png") - testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png") - - testFile, fileErr := os.ReadFile(testFilePath) - if fileErr != nil { - t.Fatal(fileErr) - } - - testImg, imgErr := os.ReadFile(testImgPath) - if imgErr != nil { - t.Fatal(imgErr) - } - - testThumbCropCenter, thumbErr := os.ReadFile(testThumbCropCenterPath) - if thumbErr != nil { - t.Fatal(thumbErr) - } - - testThumbCropTop, thumbErr := os.ReadFile(testThumbCropTopPath) - if thumbErr != nil { - t.Fatal(thumbErr) - } - - testThumbCropBottom, thumbErr := os.ReadFile(testThumbCropBottomPath) - if thumbErr != nil { - t.Fatal(thumbErr) - } - - testThumbFit, thumbErr := os.ReadFile(testThumbFitPath) - if thumbErr != nil { - t.Fatal(thumbErr) - } - - testThumbZeroWidth, thumbErr := os.ReadFile(testThumbZeroWidthPath) - if thumbErr != nil { - t.Fatal(thumbErr) - } - - testThumbZeroHeight, thumbErr := os.ReadFile(testThumbZeroHeightPath) - if thumbErr != nil { - t.Fatal(thumbErr) - } - - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodGet, - Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "missing record", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "missing file", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "existing image", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png", - ExpectedStatus: 200, - ExpectedContent: []string{string(testImg)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing image - missing thumb (should fallback to the original)", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999", - ExpectedStatus: 200, - ExpectedContent: []string{string(testImg)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing image - existing thumb (crop center)", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50", - ExpectedStatus: 200, - ExpectedContent: []string{string(testThumbCropCenter)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing image - existing thumb (crop top)", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t", - ExpectedStatus: 200, - ExpectedContent: []string{string(testThumbCropTop)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing image - existing thumb (crop bottom)", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b", - ExpectedStatus: 200, - ExpectedContent: []string{string(testThumbCropBottom)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing image - existing thumb (fit)", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f", - ExpectedStatus: 200, - ExpectedContent: []string{string(testThumbFit)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing image - existing thumb (zero width)", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50", - ExpectedStatus: 200, - ExpectedContent: []string{string(testThumbZeroWidth)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing image - existing thumb (zero height)", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0", - ExpectedStatus: 200, - ExpectedContent: []string{string(testThumbZeroHeight)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - { - Name: "existing non image file - thumb parameter should be ignored", - Method: http.MethodGet, - Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100", - ExpectedStatus: 200, - ExpectedContent: []string{string(testFile)}, - ExpectedEvents: map[string]int{ - "OnFileDownloadRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/logs.go b/apis/logs.go deleted file mode 100644 index 7452fd656b1ed3f7681c8cd9afffb6f6795027c9..0000000000000000000000000000000000000000 --- a/apis/logs.go +++ /dev/null @@ -1,81 +0,0 @@ -package apis - -import ( - "net/http" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/search" -) - -// bindLogsApi registers the request logs api endpoints. -func bindLogsApi(app core.App, rg *echo.Group) { - api := logsApi{app: app} - - subGroup := rg.Group("/logs", RequireAdminAuth()) - subGroup.GET("/requests", api.requestsList) - subGroup.GET("/requests/stats", api.requestsStats) - subGroup.GET("/requests/:id", api.requestView) -} - -type logsApi struct { - app core.App -} - -var requestFilterFields = []string{ - "rowid", "id", "created", "updated", - "url", "method", "status", "auth", - "remoteIp", "userIp", "referer", "userAgent", -} - -func (api *logsApi) requestsList(c echo.Context) error { - fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...) - - result, err := search.NewProvider(fieldResolver). - Query(api.app.LogsDao().RequestQuery()). - ParseAndExec(c.QueryString(), &[]*models.Request{}) - - if err != nil { - return NewBadRequestError("", err) - } - - return c.JSON(http.StatusOK, result) -} - -func (api *logsApi) requestsStats(c echo.Context) error { - fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...) - - filter := c.QueryParam(search.FilterQueryParam) - - var expr dbx.Expression - if filter != "" { - var err error - expr, err = search.FilterData(filter).BuildExpr(fieldResolver) - if err != nil { - return NewBadRequestError("Invalid filter format.", err) - } - } - - stats, err := api.app.LogsDao().RequestsStats(expr) - if err != nil { - return NewBadRequestError("Failed to generate requests stats.", err) - } - - return c.JSON(http.StatusOK, stats) -} - -func (api *logsApi) requestView(c echo.Context) error { - id := c.PathParam("id") - if id == "" { - return NewNotFoundError("", nil) - } - - request, err := api.app.LogsDao().FindRequestById(id) - if err != nil || request == nil { - return NewNotFoundError("", err) - } - - return c.JSON(http.StatusOK, request) -} diff --git a/apis/logs_test.go b/apis/logs_test.go deleted file mode 100644 index 648fb0e2ed7517824e735584071c258067a0dceb..0000000000000000000000000000000000000000 --- a/apis/logs_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package apis_test - -import ( - "net/http" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/tests" -) - -func TestRequestsList(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/logs/requests", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as auth record", - Method: http.MethodGet, - Url: "/api/logs/requests", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin", - Method: http.MethodGet, - Url: "/api/logs/requests", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if err := tests.MockRequestLogsData(app); err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":2`, - `"items":[{`, - `"id":"873f2133-9f38-44fb-bf82-c8f53b310d91"`, - `"id":"f2133873-44fb-9f38-bf82-c918f53b310d"`, - }, - }, - { - Name: "authorized as admin + filter", - Method: http.MethodGet, - Url: "/api/logs/requests?filter=status>200", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if err := tests.MockRequestLogsData(app); err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":1`, - `"items":[{`, - `"id":"f2133873-44fb-9f38-bf82-c918f53b310d"`, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequestView(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as auth record", - Method: http.MethodGet, - Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin (nonexisting request log)", - Method: http.MethodGet, - Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if err := tests.MockRequestLogsData(app); err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin (existing request log)", - Method: http.MethodGet, - Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if err := tests.MockRequestLogsData(app); err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"873f2133-9f38-44fb-bf82-c8f53b310d91"`, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequestsStats(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/logs/requests/stats", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as auth record", - Method: http.MethodGet, - Url: "/api/logs/requests/stats", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin", - Method: http.MethodGet, - Url: "/api/logs/requests/stats", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if err := tests.MockRequestLogsData(app); err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`, - }, - }, - { - Name: "authorized as admin + filter", - Method: http.MethodGet, - Url: "/api/logs/requests/stats?filter=status>200", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if err := tests.MockRequestLogsData(app); err != nil { - t.Fatal(err) - } - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/middlewares.go b/apis/middlewares.go deleted file mode 100644 index 542ce751b25164841901c4d779d7a7eac2acf248..0000000000000000000000000000000000000000 --- a/apis/middlewares.go +++ /dev/null @@ -1,396 +0,0 @@ -package apis - -import ( - "fmt" - "log" - "net" - "net/http" - "strings" - "time" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tokens" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/routine" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/pocketbase/pocketbase/tools/types" - "github.com/spf13/cast" -) - -// Common request context keys used by the middlewares and api handlers. -const ( - ContextAdminKey string = "admin" - ContextAuthRecordKey string = "authRecord" - ContextCollectionKey string = "collection" -) - -// RequireGuestOnly middleware requires a request to NOT have a valid -// Authorization header. -// -// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()]. -func RequireGuestOnly() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - err := NewBadRequestError("The request can be accessed only by guests.", nil) - - record, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if record != nil { - return err - } - - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin != nil { - return err - } - - return next(c) - } - } -} - -// RequireRecordAuth middleware requires a request to have -// a valid record auth Authorization header. -// -// The auth record could be from any collection. -// -// You can further filter the allowed record auth collections by -// specifying their names. -// -// Example: -// apis.RequireRecordAuth() -// Or: -// apis.RequireRecordAuth("users", "supervisors") -// -// To restrict the auth record only to the loaded context collection, -// use [apis.RequireSameContextRecordAuth()] instead. -func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - record, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if record == nil { - return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil) - } - - // check record collection name - if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) { - return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil) - } - - return next(c) - } - } -} - -// -// RequireSameContextRecordAuth middleware requires a request to have -// a valid record Authorization header. -// -// The auth record must be from the same collection already loaded in the context. -func RequireSameContextRecordAuth() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - record, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if record == nil { - return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil) - } - - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil || record.Collection().Id != collection.Id { - return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil) - } - - return next(c) - } - } -} - -// RequireAdminAuth middleware requires a request to have -// a valid admin Authorization header. -func RequireAdminAuth() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin == nil { - return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil) - } - - return next(c) - } - } -} - -// RequireAdminAuthOnlyIfAny middleware requires a request to have -// a valid admin Authorization header ONLY if the application has -// at least 1 existing Admin model. -func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - totalAdmins, err := app.Dao().TotalAdmins() - if err != nil { - return NewBadRequestError("Failed to fetch admins info.", err) - } - - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - - if admin != nil || totalAdmins == 0 { - return next(c) - } - - return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil) - } - } -} - -// RequireAdminOrRecordAuth middleware requires a request to have -// a valid admin or record Authorization header set. -// -// You can further filter the allowed auth record collections by providing their names. -// -// This middleware is the opposite of [apis.RequireGuestOnly()]. -func RequireAdminOrRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - record, _ := c.Get(ContextAuthRecordKey).(*models.Record) - - if admin == nil && record == nil { - return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil) - } - - if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) { - return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil) - } - - return next(c) - } - } -} - -// RequireAdminOrOwnerAuth middleware requires a request to have -// a valid admin or auth record owner Authorization header set. -// -// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but -// for the auth record token expects to have the same id as the path -// parameter ownerIdParam (default to "id" if empty). -func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin != nil { - return next(c) - } - - record, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if record == nil { - return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil) - } - - if ownerIdParam == "" { - ownerIdParam = "id" - } - ownerId := c.PathParam(ownerIdParam) - - // note: it is "safe" to compare only the record id since the auth - // record ids are treated as unique across all auth collections - if record.Id != ownerId { - return NewForbiddenError("You are not allowed to perform this request.", nil) - } - - return next(c) - } - } -} - -// LoadAuthContext middleware reads the Authorization request header -// and loads the token related record or admin instance into the -// request's context. -// -// This middleware is expected to be already registered by default for all routes. -func LoadAuthContext(app core.App) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - token := c.Request().Header.Get("Authorization") - if token == "" { - return next(c) - } - - // the schema is not required and it is only for - // compatibility with the defaults of some HTTP clients - token = strings.TrimPrefix(token, "Bearer ") - - claims, _ := security.ParseUnverifiedJWT(token) - tokenType := cast.ToString(claims["type"]) - - switch tokenType { - case tokens.TypeAdmin: - admin, err := app.Dao().FindAdminByToken( - token, - app.Settings().AdminAuthToken.Secret, - ) - if err == nil && admin != nil { - c.Set(ContextAdminKey, admin) - } - case tokens.TypeAuthRecord: - record, err := app.Dao().FindAuthRecordByToken( - token, - app.Settings().RecordAuthToken.Secret, - ) - if err == nil && record != nil { - c.Set(ContextAuthRecordKey, record) - } - } - - return next(c) - } - } -} - -// LoadCollectionContext middleware finds the collection with related -// path identifier and loads it into the request context. -// -// Set optCollectionTypes to further filter the found collection by its type. -func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - if param := c.PathParam("collection"); param != "" { - collection, err := app.Dao().FindCollectionByNameOrId(param) - if err != nil || collection == nil { - return NewNotFoundError("", err) - } - - if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) { - return NewBadRequestError("Invalid collection type.", nil) - } - - c.Set(ContextCollectionKey, collection) - } - - return next(c) - } - } -} - -// ActivityLogger middleware takes care to save the request information -// into the logs database. -// -// The middleware does nothing if the app logs retention period is zero -// (aka. app.Settings().Logs.MaxDays = 0). -func ActivityLogger(app core.App) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - err := next(c) - - // no logs retention - if app.Settings().Logs.MaxDays == 0 { - return err - } - - httpRequest := c.Request() - httpResponse := c.Response() - status := httpResponse.Status - meta := types.JsonMap{} - - if err != nil { - switch v := err.(type) { - case *echo.HTTPError: - status = v.Code - meta["errorMessage"] = v.Message - meta["errorDetails"] = fmt.Sprint(v.Internal) - case *ApiError: - status = v.Code - meta["errorMessage"] = v.Message - meta["errorDetails"] = fmt.Sprint(v.RawData()) - default: - status = http.StatusBadRequest - meta["errorMessage"] = v.Error() - } - } - - requestAuth := models.RequestAuthGuest - if c.Get(ContextAuthRecordKey) != nil { - requestAuth = models.RequestAuthRecord - } else if c.Get(ContextAdminKey) != nil { - requestAuth = models.RequestAuthAdmin - } - - ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr) - - model := &models.Request{ - Url: httpRequest.URL.RequestURI(), - Method: strings.ToLower(httpRequest.Method), - Status: status, - Auth: requestAuth, - UserIp: realUserIp(httpRequest, ip), - RemoteIp: ip, - Referer: httpRequest.Referer(), - UserAgent: httpRequest.UserAgent(), - Meta: meta, - } - // set timestamp fields before firing a new go routine - model.RefreshCreated() - model.RefreshUpdated() - - routine.FireAndForget(func() { - attempts := 1 - - BeginSave: - logErr := app.LogsDao().SaveRequest(model) - if logErr != nil { - // try one more time after 10s in case of SQLITE_BUSY or "database is locked" error - if attempts <= 2 { - attempts++ - time.Sleep(10 * time.Second) - goto BeginSave - } else if app.IsDebug() { - log.Println("Log save failed:", logErr) - } - } - - // Delete old request logs - // --- - now := time.Now() - lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt")) - daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24 - - if daysDiff > float64(app.Settings().Logs.MaxDays) { - deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays)) - if deleteErr == nil { - app.Cache().Set("lastLogsDeletedAt", now) - } else if app.IsDebug() { - log.Println("Logs delete failed:", deleteErr) - } - } - }) - - return err - } - } -} - -// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found). -// -// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy! -func realUserIp(r *http.Request, fallbackIp string) string { - if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { - return ip - } - - if ip := r.Header.Get("X-Real-IP"); ip != "" { - return ip - } - - if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" { - ips := strings.Split(ipsList, ",") - // extract the rightmost ip - for i := len(ips) - 1; i >= 0; i-- { - ip := strings.TrimSpace(ips[i]) - if ip != "" { - return ip - } - } - } - - return fallbackIp -} diff --git a/apis/middlewares_test.go b/apis/middlewares_test.go deleted file mode 100644 index 6dd81fdd6a85be60f00595035ceea849edc2c246..0000000000000000000000000000000000000000 --- a/apis/middlewares_test.go +++ /dev/null @@ -1,998 +0,0 @@ -package apis_test - -import ( - "net/http" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/tests" -) - -func TestRequireGuestOnly(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "valid record token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireGuestOnly(), - }, - }) - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireGuestOnly(), - }, - }) - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired/invalid token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireGuestOnly(), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "guest", - Method: http.MethodGet, - Url: "/my/test", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireGuestOnly(), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequireRecordAuth(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "guest", - Method: http.MethodGet, - Url: "/my/test", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired/invalid token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireRecordAuth(), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "valid record token with collection not in the restricted list", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireRecordAuth("demo1", "demo2"), - }, - }) - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token with collection in the restricted list", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireRecordAuth("demo1", "demo2", "users"), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequireSameContextRecordAuth(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "guest", - Method: http.MethodGet, - Url: "/my/users/test", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireSameContextRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired/invalid token", - Method: http.MethodGet, - Url: "/my/users/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireSameContextRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/users/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireSameContextRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token but from different collection", - Method: http.MethodGet, - Url: "/my/users/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireSameContextRecordAuth(), - }, - }) - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireRecordAuth(), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequireAdminAuth(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "guest", - Method: http.MethodGet, - Url: "/my/test", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired/invalid token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuth(), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequireAdminAuthOnlyIfAny(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "guest (while having at least 1 existing admin)", - Method: http.MethodGet, - Url: "/my/test", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuthOnlyIfAny(app), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "guest (while having 0 existing admins)", - Method: http.MethodGet, - Url: "/my/test", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - // delete all admins - _, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute() - if err != nil { - t.Fatal(err) - } - - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuthOnlyIfAny(app), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "expired/invalid token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuthOnlyIfAny(app), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuthOnlyIfAny(app), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminAuthOnlyIfAny(app), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequireAdminOrRecordAuth(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "guest", - Method: http.MethodGet, - Url: "/my/test", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired/invalid token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrRecordAuth(), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrRecordAuth(), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "valid record token with collection not in the restricted list", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrRecordAuth("demo1", "demo2", "clients"), - }, - }) - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token with collection in the restricted list", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrRecordAuth("demo1", "demo2", "users"), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrRecordAuth(), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "valid admin token + restricted collections list (should be ignored)", - Method: http.MethodGet, - Url: "/my/test", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrRecordAuth("demo1", "demo2"), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRequireAdminOrOwnerAuth(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "guest", - Method: http.MethodGet, - Url: "/my/test/4q1xlclmfloku33", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test/:id", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrOwnerAuth(""), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired/invalid token", - Method: http.MethodGet, - Url: "/my/test/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test/:id", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrOwnerAuth(""), - }, - }) - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token (different user)", - Method: http.MethodGet, - Url: "/my/test/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test/:id", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrOwnerAuth(""), - }, - }) - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token (different collection)", - Method: http.MethodGet, - Url: "/my/test/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test/:id", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrOwnerAuth(""), - }, - }) - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "valid record token (owner)", - Method: http.MethodGet, - Url: "/my/test/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test/:id", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrOwnerAuth(""), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/test/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/test/:custom", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.RequireAdminOrOwnerAuth("custom"), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestLoadCollectionContext(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodGet, - Url: "/my/missing", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.LoadCollectionContext(app), - }, - }) - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "guest", - Method: http.MethodGet, - Url: "/my/demo1", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.LoadCollectionContext(app), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "valid record token", - Method: http.MethodGet, - Url: "/my/demo1", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.LoadCollectionContext(app), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "valid admin token", - Method: http.MethodGet, - Url: "/my/demo1", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.LoadCollectionContext(app), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - { - Name: "mismatched type", - Method: http.MethodGet, - Url: "/my/demo1", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.LoadCollectionContext(app, "auth"), - }, - }) - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "matched type", - Method: http.MethodGet, - Url: "/my/users", - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - e.AddRoute(echo.Route{ - Method: http.MethodGet, - Path: "/my/:collection", - Handler: func(c echo.Context) error { - return c.String(200, "test123") - }, - Middlewares: []echo.MiddlewareFunc{ - apis.LoadCollectionContext(app, "auth"), - }, - }) - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test123"}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/realtime.go b/apis/realtime.go deleted file mode 100644 index 6b235427620488b862ec08eaa0b47e6a24107cc7..0000000000000000000000000000000000000000 --- a/apis/realtime.go +++ /dev/null @@ -1,424 +0,0 @@ -package apis - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "net/http" - "time" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/resolvers" - "github.com/pocketbase/pocketbase/tools/search" - "github.com/pocketbase/pocketbase/tools/subscriptions" -) - -// bindRealtimeApi registers the realtime api endpoints. -func bindRealtimeApi(app core.App, rg *echo.Group) { - api := realtimeApi{app: app} - - subGroup := rg.Group("/realtime", ActivityLogger(app)) - subGroup.GET("", api.connect) - subGroup.POST("", api.setSubscriptions) - - api.bindEvents() -} - -type realtimeApi struct { - app core.App -} - -func (api *realtimeApi) connect(c echo.Context) error { - cancelCtx, cancelRequest := context.WithCancel(c.Request().Context()) - defer cancelRequest() - c.SetRequest(c.Request().Clone(cancelCtx)) - - // register new subscription client - client := subscriptions.NewDefaultClient() - api.app.SubscriptionsBroker().Register(client) - defer func() { - api.app.OnRealtimeDisconnectRequest().Trigger(&core.RealtimeDisconnectEvent{ - HttpContext: c, - Client: client, - }) - - api.app.SubscriptionsBroker().Unregister(client.Id()) - }() - - c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8") - c.Response().Header().Set("Cache-Control", "no-store") - c.Response().Header().Set("Connection", "keep-alive") - // https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640 - // https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering - c.Response().Header().Set("X-Accel-Buffering", "no") - - connectEvent := &core.RealtimeConnectEvent{ - HttpContext: c, - Client: client, - } - - if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil { - return err - } - - if api.app.IsDebug() { - log.Printf("Realtime connection established: %s\n", client.Id()) - } - - // signalize established connection (aka. fire "connect" message) - connectMsgEvent := &core.RealtimeMessageEvent{ - HttpContext: c, - Client: client, - Message: &subscriptions.Message{ - Name: "PB_CONNECT", - Data: `{"clientId":"` + client.Id() + `"}`, - }, - } - connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error { - w := e.HttpContext.Response() - fmt.Fprint(w, "id:"+client.Id()+"\n") - fmt.Fprint(w, "event:"+e.Message.Name+"\n") - fmt.Fprint(w, "data:"+e.Message.Data+"\n\n") - w.Flush() - return nil - }) - if connectMsgErr != nil { - if api.app.IsDebug() { - log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr) - } - return nil - } - if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() { - log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err) - } - - // start an idle timer to keep track of inactive/forgotten connections - idleDuration := 5 * time.Minute - idleTimer := time.NewTimer(idleDuration) - defer idleTimer.Stop() - - for { - select { - case <-idleTimer.C: - cancelRequest() - case msg, ok := <-client.Channel(): - if !ok { - // channel is closed - if api.app.IsDebug() { - log.Println("Realtime connection closed (closed channel):", client.Id()) - } - return nil - } - - msgEvent := &core.RealtimeMessageEvent{ - HttpContext: c, - Client: client, - Message: &msg, - } - msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error { - w := e.HttpContext.Response() - fmt.Fprint(w, "id:"+e.Client.Id()+"\n") - fmt.Fprint(w, "event:"+e.Message.Name+"\n") - fmt.Fprint(w, "data:"+e.Message.Data+"\n\n") - w.Flush() - return nil - }) - if msgErr != nil { - if api.app.IsDebug() { - log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr) - } - return nil - } - - if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() { - log.Println("OnRealtimeAfterMessageSend error:", err) - } - - idleTimer.Stop() - idleTimer.Reset(idleDuration) - case <-c.Request().Context().Done(): - // connection is closed - if api.app.IsDebug() { - log.Println("Realtime connection closed (cancelled request):", client.Id()) - } - return nil - } - } -} - -// note: in case of reconnect, clients will have to resubmit all subscriptions again -func (api *realtimeApi) setSubscriptions(c echo.Context) error { - form := forms.NewRealtimeSubscribe() - - // read request data - if err := c.Bind(form); err != nil { - return NewBadRequestError("", err) - } - - // validate request data - if err := form.Validate(); err != nil { - return NewBadRequestError("", err) - } - - // find subscription client - client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId) - if err != nil { - return NewNotFoundError("Missing or invalid client id.", err) - } - - // check if the previous request was authorized - oldAuthId := extractAuthIdFromGetter(client) - newAuthId := extractAuthIdFromGetter(c) - if oldAuthId != "" && oldAuthId != newAuthId { - return NewForbiddenError("The current and the previous request authorization don't match.", nil) - } - - event := &core.RealtimeSubscribeEvent{ - HttpContext: c, - Client: client, - Subscriptions: form.Subscriptions, - } - - handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error { - // update auth state - e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey)) - e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey)) - - // unsubscribe from any previous existing subscriptions - e.Client.Unsubscribe() - - // subscribe to the new subscriptions - e.Client.Subscribe(e.Subscriptions...) - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - - if handlerErr == nil { - api.app.OnRealtimeAfterSubscribeRequest().Trigger(event) - } - - return handlerErr -} - -// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID). -func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error { - for _, client := range api.app.SubscriptionsBroker().Clients() { - clientModel, _ := client.Get(contextKey).(models.Model) - if clientModel != nil && clientModel.GetId() == newModel.GetId() { - client.Set(contextKey, newModel) - } - } - - return nil -} - -// unregisterClientsByAuthModel unregister all clients that has the provided auth model. -func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error { - for _, client := range api.app.SubscriptionsBroker().Clients() { - clientModel, _ := client.Get(contextKey).(models.Model) - if clientModel != nil && clientModel.GetId() == model.GetId() { - api.app.SubscriptionsBroker().Unregister(client.Id()) - } - } - - return nil -} - -func (api *realtimeApi) bindEvents() { - // update the clients that has admin or auth record association - api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error { - if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() { - return api.updateClientsAuthModel(ContextAuthRecordKey, record) - } - - if admin, ok := e.Model.(*models.Admin); ok && admin != nil { - return api.updateClientsAuthModel(ContextAdminKey, admin) - } - - return nil - }) - - // remove the client(s) associated to the deleted admin or auth record - api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error { - if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() { - return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record) - } - - if admin, ok := e.Model.(*models.Admin); ok && admin != nil { - return api.unregisterClientsByAuthModel(ContextAdminKey, admin) - } - - return nil - }) - - api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error { - if record, ok := e.Model.(*models.Record); ok { - api.broadcastRecord("create", record) - } - return nil - }) - - api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error { - if record, ok := e.Model.(*models.Record); ok { - api.broadcastRecord("update", record) - } - return nil - }) - - api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error { - if record, ok := e.Model.(*models.Record); ok { - api.broadcastRecord("delete", record) - } - return nil - }) -} - -func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool { - admin, _ := client.Get(ContextAdminKey).(*models.Admin) - if admin != nil { - // admins can access everything - return true - } - - if accessRule == nil { - // only admins can access this record - return false - } - - ruleFunc := func(q *dbx.SelectQuery) error { - if *accessRule == "" { - return nil // empty public rule - } - - // emulate request data - requestData := &models.RequestData{ - Method: "GET", - } - requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record) - - resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true) - expr, err := search.FilterData(*accessRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - - return nil - } - - foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc) - if err == nil && foundRecord != nil { - return true - } - - return false -} - -type recordData struct { - Action string `json:"action"` - Record *models.Record `json:"record"` -} - -func (api *realtimeApi) broadcastRecord(action string, record *models.Record) error { - collection := record.Collection() - if collection == nil { - return errors.New("Record collection not set.") - } - - clients := api.app.SubscriptionsBroker().Clients() - if len(clients) == 0 { - return nil // no subscribers - } - - // remove the expand from the broadcasted record because we don't - // know if the clients have access to view the expanded records - cleanRecord := *record - cleanRecord.SetExpand(nil) - cleanRecord.WithUnkownData(false) - cleanRecord.IgnoreEmailVisibility(false) - - subscriptionRuleMap := map[string]*string{ - (collection.Name + "/" + cleanRecord.Id): collection.ViewRule, - (collection.Id + "/" + cleanRecord.Id): collection.ViewRule, - (collection.Name + "/*"): collection.ListRule, - (collection.Id + "/*"): collection.ListRule, - // @deprecated: the same as the wildcard topic but kept for backward compatibility - collection.Name: collection.ListRule, - collection.Id: collection.ListRule, - } - - data := &recordData{ - Action: action, - Record: &cleanRecord, - } - - dataBytes, err := json.Marshal(data) - if err != nil { - if api.app.IsDebug() { - log.Println(err) - } - return err - } - - encodedData := string(dataBytes) - - for _, client := range clients { - for subscription, rule := range subscriptionRuleMap { - if !client.HasSubscription(subscription) { - continue - } - - if !api.canAccessRecord(client, data.Record, rule) { - continue - } - - msg := subscriptions.Message{ - Name: subscription, - Data: encodedData, - } - - // ignore the auth record email visibility checks for - // auth owner, admin or manager - if collection.IsAuth() { - authId := extractAuthIdFromGetter(client) - if authId == data.Record.Id || - api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) { - data.Record.IgnoreEmailVisibility(true) // ignore - if newData, err := json.Marshal(data); err == nil { - msg.Data = string(newData) - } - data.Record.IgnoreEmailVisibility(false) // restore - } - } - - client.Channel() <- msg - } - } - - return nil -} - -type getter interface { - Get(string) any -} - -func extractAuthIdFromGetter(val getter) string { - record, _ := val.Get(ContextAuthRecordKey).(*models.Record) - if record != nil { - return record.Id - } - - admin, _ := val.Get(ContextAdminKey).(*models.Admin) - if admin != nil { - return admin.Id - } - - return "" -} diff --git a/apis/realtime_test.go b/apis/realtime_test.go deleted file mode 100644 index 037fc293ae4803d30fd140722585171eff6011bc..0000000000000000000000000000000000000000 --- a/apis/realtime_test.go +++ /dev/null @@ -1,343 +0,0 @@ -package apis_test - -import ( - "errors" - "net/http" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/hook" - "github.com/pocketbase/pocketbase/tools/subscriptions" -) - -func TestRealtimeConnect(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Method: http.MethodGet, - Url: "/api/realtime", - ExpectedStatus: 200, - ExpectedContent: []string{ - `id:`, - `event:PB_CONNECT`, - `data:{"clientId":`, - }, - ExpectedEvents: map[string]int{ - "OnRealtimeConnectRequest": 1, - "OnRealtimeBeforeMessageSend": 1, - "OnRealtimeAfterMessageSend": 1, - "OnRealtimeDisconnectRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if len(app.SubscriptionsBroker().Clients()) != 0 { - t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients())) - } - }, - }, - { - Name: "PB_CONNECT interrupt", - Method: http.MethodGet, - Url: "/api/realtime", - ExpectedStatus: 200, - ExpectedEvents: map[string]int{ - "OnRealtimeConnectRequest": 1, - "OnRealtimeBeforeMessageSend": 1, - "OnRealtimeDisconnectRequest": 1, - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error { - if e.Message.Name == "PB_CONNECT" { - return errors.New("PB_CONNECT error") - } - return nil - }) - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if len(app.SubscriptionsBroker().Clients()) != 0 { - t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients())) - } - }, - }, - { - Name: "Skipping/ignoring messages", - Method: http.MethodGet, - Url: "/api/realtime", - ExpectedStatus: 200, - ExpectedEvents: map[string]int{ - "OnRealtimeConnectRequest": 1, - "OnRealtimeBeforeMessageSend": 1, - "OnRealtimeAfterMessageSend": 1, - "OnRealtimeDisconnectRequest": 1, - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error { - return hook.StopPropagation - }) - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if len(app.SubscriptionsBroker().Clients()) != 0 { - t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients())) - } - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRealtimeSubscribe(t *testing.T) { - client := subscriptions.NewDefaultClient() - - resetClient := func() { - client.Unsubscribe() - client.Set(apis.ContextAdminKey, nil) - client.Set(apis.ContextAuthRecordKey, nil) - } - - scenarios := []tests.ApiScenario{ - { - Name: "missing client", - Method: http.MethodPost, - Url: "/api/realtime", - Body: strings.NewReader(`{"clientId":"missing","subscriptions":["test1", "test2"]}`), - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "existing client - empty subscriptions", - Method: http.MethodPost, - Url: "/api/realtime", - Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":[]}`), - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnRealtimeBeforeSubscribeRequest": 1, - "OnRealtimeAfterSubscribeRequest": 1, - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - client.Subscribe("test0") - app.SubscriptionsBroker().Register(client) - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if len(client.Subscriptions()) != 0 { - t.Errorf("Expected no subscriptions, got %v", client.Subscriptions()) - } - resetClient() - }, - }, - { - Name: "existing client - 2 new subscriptions", - Method: http.MethodPost, - Url: "/api/realtime", - Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnRealtimeBeforeSubscribeRequest": 1, - "OnRealtimeAfterSubscribeRequest": 1, - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - client.Subscribe("test0") - app.SubscriptionsBroker().Register(client) - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - expectedSubs := []string{"test1", "test2"} - if len(expectedSubs) != len(client.Subscriptions()) { - t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions()) - } - - for _, s := range expectedSubs { - if !client.HasSubscription(s) { - t.Errorf("Cannot find %q subscription in %v", s, client.Subscriptions()) - } - } - resetClient() - }, - }, - { - Name: "existing client - authorized admin", - Method: http.MethodPost, - Url: "/api/realtime", - Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnRealtimeBeforeSubscribeRequest": 1, - "OnRealtimeAfterSubscribeRequest": 1, - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - app.SubscriptionsBroker().Register(client) - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin) - if admin == nil { - t.Errorf("Expected admin auth model, got nil") - } - resetClient() - }, - }, - { - Name: "existing client - authorized record", - Method: http.MethodPost, - Url: "/api/realtime", - Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnRealtimeBeforeSubscribeRequest": 1, - "OnRealtimeAfterSubscribeRequest": 1, - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - app.SubscriptionsBroker().Register(client) - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record) - if authRecord == nil { - t.Errorf("Expected auth record model, got nil") - } - resetClient() - }, - }, - { - Name: "existing client - mismatched auth", - Method: http.MethodPost, - Url: "/api/realtime", - Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - initialAuth := &models.Record{} - initialAuth.RefreshId() - client.Set(apis.ContextAuthRecordKey, initialAuth) - - app.SubscriptionsBroker().Register(client) - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record) - if authRecord == nil { - t.Errorf("Expected auth record model, got nil") - } - resetClient() - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRealtimeAuthRecordDeleteEvent(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - apis.InitApi(testApp) - - authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") - if err != nil { - t.Fatal(err) - } - - client := subscriptions.NewDefaultClient() - client.Set(apis.ContextAuthRecordKey, authRecord) - testApp.SubscriptionsBroker().Register(client) - - testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord}) - - if len(testApp.SubscriptionsBroker().Clients()) != 0 { - t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) - } -} - -func TestRealtimeAuthRecordUpdateEvent(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - apis.InitApi(testApp) - - authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") - if err != nil { - t.Fatal(err) - } - - client := subscriptions.NewDefaultClient() - client.Set(apis.ContextAuthRecordKey, authRecord1) - testApp.SubscriptionsBroker().Register(client) - - // refetch the authRecord and change its email - authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") - if err != nil { - t.Fatal(err) - } - authRecord2.SetEmail("new@example.com") - - testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2}) - - clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record) - if clientAuthRecord.Email() != authRecord2.Email() { - t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email()) - } -} - -func TestRealtimeAdminDeleteEvent(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - apis.InitApi(testApp) - - admin, err := testApp.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - - client := subscriptions.NewDefaultClient() - client.Set(apis.ContextAdminKey, admin) - testApp.SubscriptionsBroker().Register(client) - - testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin}) - - if len(testApp.SubscriptionsBroker().Clients()) != 0 { - t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) - } -} - -func TestRealtimeAdminUpdateEvent(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - apis.InitApi(testApp) - - admin1, err := testApp.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - - client := subscriptions.NewDefaultClient() - client.Set(apis.ContextAdminKey, admin1) - testApp.SubscriptionsBroker().Register(client) - - // refetch the authRecord and change its email - admin2, err := testApp.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - admin2.Email = "new@example.com" - - testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2}) - - clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin) - if clientAdmin.Email != admin2.Email { - t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email) - } -} diff --git a/apis/record_auth.go b/apis/record_auth.go deleted file mode 100644 index dcd7a77274e08d3271c09db34c28206d0935c4bb..0000000000000000000000000000000000000000 --- a/apis/record_auth.go +++ /dev/null @@ -1,587 +0,0 @@ -package apis - -import ( - "errors" - "fmt" - "log" - "net/http" - "strings" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/resolvers" - "github.com/pocketbase/pocketbase/tokens" - "github.com/pocketbase/pocketbase/tools/auth" - "github.com/pocketbase/pocketbase/tools/routine" - "github.com/pocketbase/pocketbase/tools/search" - "github.com/pocketbase/pocketbase/tools/security" - "golang.org/x/oauth2" -) - -// bindRecordAuthApi registers the auth record api endpoints and -// the corresponding handlers. -func bindRecordAuthApi(app core.App, rg *echo.Group) { - api := recordAuthApi{app: app} - - subGroup := rg.Group( - "/collections/:collection", - ActivityLogger(app), - LoadCollectionContext(app, models.CollectionTypeAuth), - ) - - subGroup.GET("/auth-methods", api.authMethods) - subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth()) - subGroup.POST("/auth-with-oauth2", api.authWithOAuth2) // allow anyone so that we can link the OAuth2 profile with the authenticated record - subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly()) - subGroup.POST("/request-password-reset", api.requestPasswordReset) - subGroup.POST("/confirm-password-reset", api.confirmPasswordReset) - subGroup.POST("/request-verification", api.requestVerification) - subGroup.POST("/confirm-verification", api.confirmVerification) - subGroup.POST("/request-email-change", api.requestEmailChange, RequireSameContextRecordAuth()) - subGroup.POST("/confirm-email-change", api.confirmEmailChange) - subGroup.GET("/records/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id")) - subGroup.DELETE("/records/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id")) -} - -type recordAuthApi struct { - app core.App -} - -func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record, meta any) error { - token, tokenErr := tokens.NewRecordAuthToken(api.app, authRecord) - if tokenErr != nil { - return NewBadRequestError("Failed to create auth token.", tokenErr) - } - - event := &core.RecordAuthEvent{ - HttpContext: c, - Record: authRecord, - Token: token, - Meta: meta, - } - - return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error { - // allow always returning the email address of the authenticated account - e.Record.IgnoreEmailVisibility(true) - - // expand record relations - expands := strings.Split(c.QueryParam(expandQueryParam), ",") - if len(expands) > 0 { - // create a copy of the cached request data and adjust it to the current auth record - requestData := *RequestData(e.HttpContext) - requestData.Admin = nil - requestData.AuthRecord = e.Record - failed := api.app.Dao().ExpandRecord( - e.Record, - expands, - expandFetch(api.app.Dao(), &requestData), - ) - if len(failed) > 0 && api.app.IsDebug() { - log.Println("Failed to expand relations: ", failed) - } - } - - result := map[string]any{ - "token": e.Token, - "record": e.Record, - } - - if e.Meta != nil { - result["meta"] = e.Meta - } - - return e.HttpContext.JSON(http.StatusOK, result) - }) -} - -func (api *recordAuthApi) authRefresh(c echo.Context) error { - record, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if record == nil { - return NewNotFoundError("Missing auth record context.", nil) - } - - return api.authResponse(c, record, nil) -} - -type providerInfo struct { - Name string `json:"name"` - State string `json:"state"` - CodeVerifier string `json:"codeVerifier"` - CodeChallenge string `json:"codeChallenge"` - CodeChallengeMethod string `json:"codeChallengeMethod"` - AuthUrl string `json:"authUrl"` -} - -func (api *recordAuthApi) authMethods(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - authOptions := collection.AuthOptions() - - result := struct { - UsernamePassword bool `json:"usernamePassword"` - EmailPassword bool `json:"emailPassword"` - AuthProviders []providerInfo `json:"authProviders"` - }{ - UsernamePassword: authOptions.AllowUsernameAuth, - EmailPassword: authOptions.AllowEmailAuth, - AuthProviders: []providerInfo{}, - } - - if !authOptions.AllowOAuth2Auth { - return c.JSON(http.StatusOK, result) - } - - nameConfigMap := api.app.Settings().NamedAuthProviderConfigs() - for name, config := range nameConfigMap { - if !config.Enabled { - continue - } - - provider, err := auth.NewProviderByName(name) - if err != nil { - if api.app.IsDebug() { - log.Println(err) - } - continue // skip provider - } - - if err := config.SetupProvider(provider); err != nil { - if api.app.IsDebug() { - log.Println(err) - } - continue // skip provider - } - - state := security.RandomString(30) - codeVerifier := security.RandomString(43) - codeChallenge := security.S256Challenge(codeVerifier) - codeChallengeMethod := "S256" - result.AuthProviders = append(result.AuthProviders, providerInfo{ - Name: name, - State: state, - CodeVerifier: codeVerifier, - CodeChallenge: codeChallenge, - CodeChallengeMethod: codeChallengeMethod, - AuthUrl: provider.BuildAuthUrl( - state, - oauth2.SetAuthURLParam("code_challenge", codeChallenge), - oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod), - ) + "&redirect_uri=", // empty redirect_uri so that users can append their url - }) - } - - return c.JSON(http.StatusOK, result) -} - -func (api *recordAuthApi) authWithOAuth2(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - if !collection.AuthOptions().AllowOAuth2Auth { - return NewBadRequestError("The collection is not configured to allow OAuth2 authentication.", nil) - } - - var fallbackAuthRecord *models.Record - - loggedAuthRecord, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if loggedAuthRecord != nil && loggedAuthRecord.Collection().Id == collection.Id { - fallbackAuthRecord = loggedAuthRecord - } - - form := forms.NewRecordOAuth2Login(api.app, collection, fallbackAuthRecord) - if readErr := c.Bind(form); readErr != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", readErr) - } - - record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error { - return createForm.DrySubmit(func(txDao *daos.Dao) error { - requestData := RequestData(c) - requestData.Data = form.CreateData - - createRuleFunc := func(q *dbx.SelectQuery) error { - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin != nil { - return nil // either admin or the rule is empty - } - - if collection.CreateRule == nil { - return errors.New("Only admins can create new accounts with OAuth2") - } - - if *collection.CreateRule != "" { - resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true) - expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - } - - return nil - } - - if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil { - return fmt.Errorf("Failed create rule constraint: %w", err) - } - - return nil - }) - }) - if submitErr != nil { - return NewBadRequestError("Failed to authenticate.", submitErr) - } - - return api.authResponse(c, record, authData) -} - -func (api *recordAuthApi) authWithPassword(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - form := forms.NewRecordPasswordLogin(api.app, collection) - if readErr := c.Bind(form); readErr != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", readErr) - } - - record, submitErr := form.Submit() - if submitErr != nil { - return NewBadRequestError("Failed to authenticate.", submitErr) - } - - return api.authResponse(c, record, nil) -} - -func (api *recordAuthApi) requestPasswordReset(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - authOptions := collection.AuthOptions() - if !authOptions.AllowUsernameAuth && !authOptions.AllowEmailAuth { - return NewBadRequestError("The collection is not configured to allow password authentication.", nil) - } - - form := forms.NewRecordPasswordResetRequest(api.app, collection) - if err := c.Bind(form); err != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", err) - } - - if err := form.Validate(); err != nil { - return NewBadRequestError("An error occurred while validating the form.", err) - } - - event := &core.RecordRequestPasswordResetEvent{ - HttpContext: c, - } - - submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - event.Record = record - - return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error { - // run in background because we don't need to show the result to the client - routine.FireAndForget(func() { - if err := next(e.Record); err != nil && api.app.IsDebug() { - log.Println(err) - } - }) - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event) - } else if api.app.IsDebug() { - log.Println(submitErr) - } - - // don't return the response error to prevent emails enumeration - if !c.Response().Committed { - c.NoContent(http.StatusNoContent) - } - - return nil -} - -func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - form := forms.NewRecordPasswordResetConfirm(api.app, collection) - if readErr := c.Bind(form); readErr != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", readErr) - } - - event := &core.RecordConfirmPasswordResetEvent{ - HttpContext: c, - } - - _, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - event.Record = record - - return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error { - if err := next(e.Record); err != nil { - return NewBadRequestError("Failed to set new password.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event) - } - - return submitErr -} - -func (api *recordAuthApi) requestVerification(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - form := forms.NewRecordVerificationRequest(api.app, collection) - if err := c.Bind(form); err != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", err) - } - - if err := form.Validate(); err != nil { - return NewBadRequestError("An error occurred while validating the form.", err) - } - - event := &core.RecordRequestVerificationEvent{ - HttpContext: c, - } - - submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - event.Record = record - - return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error { - // run in background because we don't need to show the result to the client - routine.FireAndForget(func() { - if err := next(e.Record); err != nil && api.app.IsDebug() { - log.Println(err) - } - }) - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterRequestVerificationRequest().Trigger(event) - } else if api.app.IsDebug() { - log.Println(submitErr) - } - - // don't return the response error to prevent emails enumeration - if !c.Response().Committed { - c.NoContent(http.StatusNoContent) - } - - return nil -} - -func (api *recordAuthApi) confirmVerification(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - form := forms.NewRecordVerificationConfirm(api.app, collection) - if readErr := c.Bind(form); readErr != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", readErr) - } - - event := &core.RecordConfirmVerificationEvent{ - HttpContext: c, - } - - _, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - event.Record = record - - return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error { - if err := next(e.Record); err != nil { - return NewBadRequestError("An error occurred while submitting the form.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event) - } - - return submitErr -} - -func (api *recordAuthApi) requestEmailChange(c echo.Context) error { - record, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if record == nil { - return NewUnauthorizedError("The request requires valid auth record.", nil) - } - - form := forms.NewRecordEmailChangeRequest(api.app, record) - if err := c.Bind(form); err != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", err) - } - - event := &core.RecordRequestEmailChangeEvent{ - HttpContext: c, - Record: record, - } - - submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error { - if err := next(e.Record); err != nil { - return NewBadRequestError("Failed to request email change.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event) - } - - return submitErr -} - -func (api *recordAuthApi) confirmEmailChange(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - form := forms.NewRecordEmailChangeConfirm(api.app, collection) - if readErr := c.Bind(form); readErr != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", readErr) - } - - event := &core.RecordConfirmEmailChangeEvent{ - HttpContext: c, - } - - _, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - event.Record = record - - return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error { - if err := next(e.Record); err != nil { - return NewBadRequestError("Failed to confirm email change.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event) - } - - return submitErr -} - -func (api *recordAuthApi) listExternalAuths(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - id := c.PathParam("id") - if id == "" { - return NewNotFoundError("", nil) - } - - record, err := api.app.Dao().FindRecordById(collection.Id, id) - if err != nil || record == nil { - return NewNotFoundError("", err) - } - - externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(record) - if err != nil { - return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err) - } - - event := &core.RecordListExternalAuthsEvent{ - HttpContext: c, - Record: record, - ExternalAuths: externalAuths, - } - - return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error { - return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths) - }) -} - -func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("Missing collection context.", nil) - } - - id := c.PathParam("id") - provider := c.PathParam("provider") - if id == "" || provider == "" { - return NewNotFoundError("", nil) - } - - record, err := api.app.Dao().FindRecordById(collection.Id, id) - if err != nil || record == nil { - return NewNotFoundError("", err) - } - - externalAuth, err := api.app.Dao().FindExternalAuthByRecordAndProvider(record, provider) - if err != nil { - return NewNotFoundError("Missing external auth provider relation.", err) - } - - event := &core.RecordUnlinkExternalAuthEvent{ - HttpContext: c, - Record: record, - ExternalAuth: externalAuth, - } - - handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error { - if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil { - return NewBadRequestError("Cannot unlink the external auth provider.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - - if handlerErr == nil { - api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event) - } - - return handlerErr -} diff --git a/apis/record_auth_test.go b/apis/record_auth_test.go deleted file mode 100644 index 62cf521f67dda997e83b6ca7d69e127a11c32545..0000000000000000000000000000000000000000 --- a/apis/record_auth_test.go +++ /dev/null @@ -1,1092 +0,0 @@ -package apis_test - -import ( - "net/http" - "strings" - "testing" - "time" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/types" -) - -func TestRecordAuthMethodsList(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodGet, - Url: "/api/collections/missing/auth-methods", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "non auth collection", - Method: http.MethodGet, - Url: "/api/collections/demo1/auth-methods", - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth collection with all auth methods allowed", - Method: http.MethodGet, - Url: "/api/collections/users/auth-methods", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"usernamePassword":true`, - `"emailPassword":true`, - `"authProviders":[{`, - `"name":"gitlab"`, - `"state":`, - `"codeVerifier":`, - `"codeChallenge":`, - `"codeChallengeMethod":`, - `"authUrl":`, - `redirect_uri="`, // ensures that the redirect_uri is the last url param - }, - }, - { - Name: "auth collection with only email/password auth allowed", - Method: http.MethodGet, - Url: "/api/collections/clients/auth-methods", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"usernamePassword":false`, - `"emailPassword":true`, - `"authProviders":[]`, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthWithPassword(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "authenticated record", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authenticated admin", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "invalid body format", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{"identity`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "empty body params", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{"identity":"","password":""}`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"identity":{`, - `"password":{`, - }, - }, - - // username - { - Name: "invalid username and valid password", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{ - "identity":"invalid", - "password":"1234567890" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "valid username and invalid password", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{ - "identity":"test2_username", - "password":"invalid" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "valid username and valid password in restricted collection", - Method: http.MethodPost, - Url: "/api/collections/nologin/auth-with-password", - Body: strings.NewReader(`{ - "identity":"test_username", - "password":"1234567890" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "valid username and valid password in allowed collection", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{ - "identity":"test2_username", - "password":"1234567890" - }`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"record":{`, - `"token":"`, - `"id":"oap640cot4yru2s"`, - `"email":"test2@example.com"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordAuthRequest": 1, - }, - }, - - // email - { - Name: "invalid email and valid password", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{ - "identity":"missing@example.com", - "password":"1234567890" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "valid email and invalid password", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{ - "identity":"test@example.com", - "password":"invalid" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "valid email and valid password in restricted collection", - Method: http.MethodPost, - Url: "/api/collections/nologin/auth-with-password", - Body: strings.NewReader(`{ - "identity":"test@example.com", - "password":"1234567890" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "valid email and valid password in allowed collection", - Method: http.MethodPost, - Url: "/api/collections/users/auth-with-password", - Body: strings.NewReader(`{ - "identity":"test@example.com", - "password":"1234567890" - }`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"record":{`, - `"token":"`, - `"id":"4q1xlclmfloku33"`, - `"email":"test@example.com"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordAuthRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthRefresh(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPost, - Url: "/api/collections/users/auth-refresh", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "admin", - Method: http.MethodPost, - Url: "/api/collections/users/auth-refresh", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record + not an auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/auth-refresh", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record + different auth collection", - Method: http.MethodPost, - Url: "/api/collections/clients/auth-refresh?expand=rel,missing", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record + same auth collection as the token", - Method: http.MethodPost, - Url: "/api/collections/users/auth-refresh?expand=rel,missing", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"token":`, - `"record":`, - `"id":"4q1xlclmfloku33"`, - `"emailVisibility":false`, - `"email":"test@example.com"`, // the owner can always view their email address - `"expand":`, - `"rel":`, - `"id":"llvuca81nly1qls"`, - }, - NotExpectedContent: []string{ - `"missing":`, - }, - ExpectedEvents: map[string]int{ - "OnRecordAuthRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthRequestPasswordReset(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "not an auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/request-password-reset", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/collections/users/request-password-reset", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`}, - }, - { - Name: "invalid data", - Method: http.MethodPost, - Url: "/api/collections/users/request-password-reset", - Body: strings.NewReader(`{"email`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "missing auth record", - Method: http.MethodPost, - Url: "/api/collections/users/request-password-reset", - Body: strings.NewReader(`{"email":"missing@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - }, - { - Name: "existing auth record", - Method: http.MethodPost, - Url: "/api/collections/users/request-password-reset", - Body: strings.NewReader(`{"email":"test@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnRecordBeforeRequestPasswordResetRequest": 1, - "OnRecordAfterRequestPasswordResetRequest": 1, - "OnMailerBeforeRecordResetPasswordSend": 1, - "OnMailerAfterRecordResetPasswordSend": 1, - }, - }, - { - Name: "existing auth record (after already sent)", - Method: http.MethodPost, - Url: "/api/collections/clients/request-password-reset", - Body: strings.NewReader(`{"email":"test@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - // simulate recent password request sent - authRecord, err := app.Dao().FindFirstRecordByData("clients", "email", "test@example.com") - if err != nil { - t.Fatal(err) - } - authRecord.SetLastResetSentAt(types.NowDateTime()) - dao := daos.New(app.Dao().DB()) // new dao to ignore hooks - if err := dao.Save(authRecord); err != nil { - t.Fatal(err) - } - }, - }, - { - Name: "existing auth record in a collection with disabled password login", - Method: http.MethodPost, - Url: "/api/collections/nologin/request-password-reset", - Body: strings.NewReader(`{"email":"test@example.com"}`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthConfirmPasswordReset(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-password-reset", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"password":{"code":"validation_required"`, - `"passwordConfirm":{"code":"validation_required"`, - `"token":{"code":"validation_required"`, - }, - }, - { - Name: "invalid data format", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-password-reset", - Body: strings.NewReader(`{"password`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired token and invalid password", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-password-reset", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY", - "password":"1234567", - "passwordConfirm":"7654321" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"token":{"code":"validation_invalid_token"`, - `"password":{"code":"validation_length_out_of_range"`, - `"passwordConfirm":{"code":"validation_values_mismatch"`, - }, - }, - { - Name: "non auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/confirm-password-reset?expand=rel,missing", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", - "password":"12345678", - "passwordConfirm":"12345678" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "different auth collection", - Method: http.MethodPost, - Url: "/api/collections/clients/confirm-password-reset?expand=rel,missing", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", - "password":"12345678", - "passwordConfirm":"12345678" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{"token":{"code":"validation_token_collection_mismatch"`, - }, - }, - { - Name: "valid token and data", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-password-reset", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", - "password":"12345678", - "passwordConfirm":"12345678" - }`), - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordBeforeConfirmPasswordResetRequest": 1, - "OnRecordAfterConfirmPasswordResetRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthRequestVerification(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "not an auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/request-verification", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/collections/users/request-verification", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`}, - }, - { - Name: "invalid data", - Method: http.MethodPost, - Url: "/api/collections/users/request-verification", - Body: strings.NewReader(`{"email`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "missing auth record", - Method: http.MethodPost, - Url: "/api/collections/users/request-verification", - Body: strings.NewReader(`{"email":"missing@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - }, - { - Name: "already verified auth record", - Method: http.MethodPost, - Url: "/api/collections/users/request-verification", - Body: strings.NewReader(`{"email":"test2@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnRecordBeforeRequestVerificationRequest": 1, - "OnRecordAfterRequestVerificationRequest": 1, - }, - }, - { - Name: "existing auth record", - Method: http.MethodPost, - Url: "/api/collections/users/request-verification", - Body: strings.NewReader(`{"email":"test@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnRecordBeforeRequestVerificationRequest": 1, - "OnRecordAfterRequestVerificationRequest": 1, - "OnMailerBeforeRecordVerificationSend": 1, - "OnMailerAfterRecordVerificationSend": 1, - }, - }, - { - Name: "existing auth record (after already sent)", - Method: http.MethodPost, - Url: "/api/collections/users/request-verification", - Body: strings.NewReader(`{"email":"test@example.com"}`), - Delay: 100 * time.Millisecond, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - // "OnRecordBeforeRequestVerificationRequest": 1, - // "OnRecordAfterRequestVerificationRequest": 1, - }, - BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - // simulate recent verification sent - authRecord, err := app.Dao().FindFirstRecordByData("users", "email", "test@example.com") - if err != nil { - t.Fatal(err) - } - authRecord.SetLastVerificationSentAt(types.NowDateTime()) - dao := daos.New(app.Dao().DB()) // new dao to ignore hooks - if err := dao.Save(authRecord); err != nil { - t.Fatal(err) - } - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthConfirmVerification(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-verification", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"token":{"code":"validation_required"`, - }, - }, - { - Name: "invalid data format", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-verification", - Body: strings.NewReader(`{"password`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired token", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-verification", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.Avbt9IP8sBisVz_2AGrlxLDvangVq4PhL2zqQVYLKlE" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"token":{"code":"validation_invalid_token"`, - }, - }, - { - Name: "non auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/confirm-verification?expand=rel,missing", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "different auth collection", - Method: http.MethodPost, - Url: "/api/collections/clients/confirm-verification?expand=rel,missing", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{"token":{"code":"validation_token_collection_mismatch"`, - }, - }, - { - Name: "valid token", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-verification", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc" - }`), - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordBeforeConfirmVerificationRequest": 1, - "OnRecordAfterConfirmVerificationRequest": 1, - }, - }, - { - Name: "valid token (already verified)", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-verification", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg" - }`), - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnRecordBeforeConfirmVerificationRequest": 1, - "OnRecordAfterConfirmVerificationRequest": 1, - }, - }, - { - Name: "valid verification token from a collection without allowed login", - Method: http.MethodPost, - Url: "/api/collections/nologin/confirm-verification", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.coREjeTDS3_Go7DP1nxHtevIX5rujwHU-_mRB6oOm3w" - }`), - ExpectedStatus: 204, - ExpectedContent: []string{}, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordBeforeConfirmVerificationRequest": 1, - "OnRecordAfterConfirmVerificationRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthRequestEmailChange(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPost, - Url: "/api/collections/users/request-email-change", - Body: strings.NewReader(`{"newEmail":"change@example.com"}`), - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "not an auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/request-email-change", - Body: strings.NewReader(`{"newEmail":"change@example.com"}`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "admin authentication", - Method: http.MethodPost, - Url: "/api/collections/users/request-email-change", - Body: strings.NewReader(`{"newEmail":"change@example.com"}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "record authentication but from different auth collection", - Method: http.MethodPost, - Url: "/api/collections/clients/request-email-change", - Body: strings.NewReader(`{"newEmail":"change@example.com"}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "invalid data", - Method: http.MethodPost, - Url: "/api/collections/users/request-email-change", - Body: strings.NewReader(`{"newEmail`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/collections/users/request-email-change", - Body: strings.NewReader(`{}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":`, - `"newEmail":{"code":"validation_required"`, - }, - }, - { - Name: "valid data (existing email)", - Method: http.MethodPost, - Url: "/api/collections/users/request-email-change", - Body: strings.NewReader(`{"newEmail":"test2@example.com"}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":`, - `"newEmail":{"code":"validation_record_email_exists"`, - }, - }, - { - Name: "valid data (new email)", - Method: http.MethodPost, - Url: "/api/collections/users/request-email-change", - Body: strings.NewReader(`{"newEmail":"change@example.com"}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnMailerBeforeRecordChangeEmailSend": 1, - "OnMailerAfterRecordChangeEmailSend": 1, - "OnRecordBeforeRequestEmailChangeRequest": 1, - "OnRecordAfterRequestEmailChangeRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthConfirmEmailChange(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "not an auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/confirm-email-change", - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{}`, - }, - }, - { - Name: "empty data", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-email-change", - Body: strings.NewReader(``), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":`, - `"token":{"code":"validation_required"`, - `"password":{"code":"validation_required"`, - }, - }, - { - Name: "invalid data", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-email-change", - Body: strings.NewReader(`{"token`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expired token and correct password", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-email-change", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjE2NDA5OTE2NjF9.D20jh5Ss7SZyXRUXjjEyLCYo9Ky0N5cE5dKB_MGJ8G8", - "password":"1234567890" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"token":{`, - `"code":"validation_invalid_token"`, - }, - }, - { - Name: "valid token and incorrect password", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-email-change", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs", - "password":"1234567891" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"password":{`, - `"code":"validation_invalid_password"`, - }, - }, - { - Name: "valid token and correct password", - Method: http.MethodPost, - Url: "/api/collections/users/confirm-email-change", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs", - "password":"1234567890" - }`), - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordBeforeConfirmEmailChangeRequest": 1, - "OnRecordAfterConfirmEmailChangeRequest": 1, - }, - }, - { - Name: "valid token and correct password in different auth collection", - Method: http.MethodPost, - Url: "/api/collections/clients/confirm-email-change", - Body: strings.NewReader(`{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs", - "password":"1234567890" - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"token":{"code":"validation_token_collection_mismatch"`, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthListExternalsAuths(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "admin + nonexisting record id", - Method: http.MethodGet, - Url: "/api/collections/users/records/missing/external-auths", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "admin + existing record id and no external auths", - Method: http.MethodGet, - Url: "/api/collections/users/records/oap640cot4yru2s/external-auths", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{`[]`}, - ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, - }, - { - Name: "admin + existing user id and 2 external auths", - Method: http.MethodGet, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"clmflokuq1xl341"`, - `"id":"dlmflokuq1xl342"`, - `"recordId":"4q1xlclmfloku33"`, - `"collectionId":"_pb_users_auth_"`, - }, - ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, - }, - { - Name: "auth record + trying to list another user external auths", - Method: http.MethodGet, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record + trying to list another user external auths from different collection", - Method: http.MethodGet, - Url: "/api/collections/clients/records/o1y0dd0spd786md/external-auths", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record + owner without external auths", - Method: http.MethodGet, - Url: "/api/collections/users/records/oap640cot4yru2s/external-auths", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno", - }, - ExpectedStatus: 200, - ExpectedContent: []string{`[]`}, - ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, - }, - { - Name: "authorized as user - owner with 2 external auths", - Method: http.MethodGet, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"clmflokuq1xl341"`, - `"id":"dlmflokuq1xl342"`, - `"recordId":"4q1xlclmfloku33"`, - `"collectionId":"_pb_users_auth_"`, - }, - ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordAuthUnlinkExternalsAuth(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodDelete, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "admin - nonexisting recod id", - Method: http.MethodDelete, - Url: "/api/collections/users/records/missing/external-auths/google", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "admin - nonlinked provider", - Method: http.MethodDelete, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/facebook", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "admin - linked provider", - Method: http.MethodDelete, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedContent: []string{}, - ExpectedEvents: map[string]int{ - "OnModelAfterDelete": 1, - "OnModelBeforeDelete": 1, - "OnRecordAfterUnlinkExternalAuthRequest": 1, - "OnRecordBeforeUnlinkExternalAuthRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33") - if err != nil { - t.Fatal(err) - } - auth, _ := app.Dao().FindExternalAuthByRecordAndProvider(record, "google") - if auth != nil { - t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth) - } - }, - }, - { - Name: "auth record - trying to unlink another user external auth", - Method: http.MethodDelete, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record - trying to unlink another user external auth from different collection", - Method: http.MethodDelete, - Url: "/api/collections/clients/records/o1y0dd0spd786md/external-auths/google", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record - owner with existing external auth", - Method: http.MethodDelete, - Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 204, - ExpectedContent: []string{}, - ExpectedEvents: map[string]int{ - "OnModelAfterDelete": 1, - "OnModelBeforeDelete": 1, - "OnRecordAfterUnlinkExternalAuthRequest": 1, - "OnRecordBeforeUnlinkExternalAuthRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33") - if err != nil { - t.Fatal(err) - } - auth, _ := app.Dao().FindExternalAuthByRecordAndProvider(record, "google") - if auth != nil { - t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth) - } - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/record_crud.go b/apis/record_crud.go deleted file mode 100644 index 5d25f8f7c2f0be1d47b72e49efa1c74f64a73aa7..0000000000000000000000000000000000000000 --- a/apis/record_crud.go +++ /dev/null @@ -1,390 +0,0 @@ -package apis - -import ( - "fmt" - "log" - "net/http" - "strings" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/resolvers" - "github.com/pocketbase/pocketbase/tools/search" -) - -const expandQueryParam = "expand" - -// bindRecordCrudApi registers the record crud api endpoints and -// the corresponding handlers. -func bindRecordCrudApi(app core.App, rg *echo.Group) { - api := recordApi{app: app} - - subGroup := rg.Group( - "/collections/:collection", - ActivityLogger(app), - LoadCollectionContext(app), - ) - - subGroup.GET("/records", api.list) - subGroup.POST("/records", api.create) - subGroup.GET("/records/:id", api.view) - subGroup.PATCH("/records/:id", api.update) - subGroup.DELETE("/records/:id", api.delete) -} - -type recordApi struct { - app core.App -} - -func (api *recordApi) list(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("", "Missing collection context.") - } - - // forbid users and guests to query special filter/sort fields - if err := api.checkForForbiddenQueryFields(c); err != nil { - return err - } - - requestData := RequestData(c) - - if requestData.Admin == nil && collection.ListRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - - fieldsResolver := resolvers.NewRecordFieldResolver( - api.app.Dao(), - collection, - requestData, - // hidden fields are searchable only by admins - requestData.Admin != nil, - ) - - searchProvider := search.NewProvider(fieldsResolver). - Query(api.app.Dao().RecordQuery(collection)) - - if requestData.Admin == nil && collection.ListRule != nil { - searchProvider.AddFilter(search.FilterData(*collection.ListRule)) - } - - var rawRecords = []dbx.NullStringMap{} - result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords) - if err != nil { - return NewBadRequestError("Invalid filter parameters.", err) - } - - records := models.NewRecordsFromNullStringMaps(collection, rawRecords) - - result.Items = records - - event := &core.RecordsListEvent{ - HttpContext: c, - Collection: collection, - Records: records, - Result: result, - } - - return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error { - if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() { - log.Println(err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Result) - }) -} - -func (api *recordApi) view(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("", "Missing collection context.") - } - - recordId := c.PathParam("id") - if recordId == "" { - return NewNotFoundError("", nil) - } - - requestData := RequestData(c) - - if requestData.Admin == nil && collection.ViewRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - - ruleFunc := func(q *dbx.SelectQuery) error { - if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" { - resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true) - expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - } - return nil - } - - record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc) - if fetchErr != nil || record == nil { - return NewNotFoundError("", fetchErr) - } - - event := &core.RecordViewEvent{ - HttpContext: c, - Record: record, - } - - return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error { - if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { - log.Println(err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Record) - }) -} - -func (api *recordApi) create(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("", "Missing collection context.") - } - - requestData := RequestData(c) - - if requestData.Admin == nil && collection.CreateRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - - hasFullManageAccess := requestData.Admin != nil - - // temporary save the record and check it against the create rule - if requestData.Admin == nil && collection.CreateRule != nil { - createRuleFunc := func(q *dbx.SelectQuery) error { - if *collection.CreateRule == "" { - return nil // no create rule to resolve - } - - resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true) - expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - return nil - } - - testRecord := models.NewRecord(collection) - testForm := forms.NewRecordUpsert(api.app, testRecord) - testForm.SetFullManageAccess(true) - if err := testForm.LoadRequest(c.Request(), ""); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - testErr := testForm.DrySubmit(func(txDao *daos.Dao) error { - foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc) - if err != nil { - return fmt.Errorf("DrySubmit create rule failure: %w", err) - } - hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData) - return nil - }) - - if testErr != nil { - return NewBadRequestError("Failed to create record.", testErr) - } - } - - record := models.NewRecord(collection) - form := forms.NewRecordUpsert(api.app, record) - form.SetFullManageAccess(hasFullManageAccess) - - // load request - if err := form.LoadRequest(c.Request(), ""); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - event := &core.RecordCreateEvent{ - HttpContext: c, - Record: record, - } - - // create the record - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error { - if err := next(); err != nil { - return NewBadRequestError("Failed to create record.", err) - } - - if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { - log.Println(err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Record) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterCreateRequest().Trigger(event) - } - - return submitErr -} - -func (api *recordApi) update(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("", "Missing collection context.") - } - - recordId := c.PathParam("id") - if recordId == "" { - return NewNotFoundError("", nil) - } - - requestData := RequestData(c) - - if requestData.Admin == nil && collection.UpdateRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - - ruleFunc := func(q *dbx.SelectQuery) error { - if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" { - resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true) - expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - } - return nil - } - - // fetch record - record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc) - if fetchErr != nil || record == nil { - return NewNotFoundError("", fetchErr) - } - - form := forms.NewRecordUpsert(api.app, record) - form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData)) - - // load request - if err := form.LoadRequest(c.Request(), ""); err != nil { - return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) - } - - event := &core.RecordUpdateEvent{ - HttpContext: c, - Record: record, - } - - // update the record - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error { - if err := next(); err != nil { - return NewBadRequestError("Failed to update record.", err) - } - - if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { - log.Println(err) - } - - return e.HttpContext.JSON(http.StatusOK, e.Record) - }) - } - }) - - if submitErr == nil { - api.app.OnRecordAfterUpdateRequest().Trigger(event) - } - - return submitErr -} - -func (api *recordApi) delete(c echo.Context) error { - collection, _ := c.Get(ContextCollectionKey).(*models.Collection) - if collection == nil { - return NewNotFoundError("", "Missing collection context.") - } - - recordId := c.PathParam("id") - if recordId == "" { - return NewNotFoundError("", nil) - } - - requestData := RequestData(c) - - if requestData.Admin == nil && collection.DeleteRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - - ruleFunc := func(q *dbx.SelectQuery) error { - if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" { - resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true) - expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - } - return nil - } - - record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc) - if fetchErr != nil || record == nil { - return NewNotFoundError("", fetchErr) - } - - event := &core.RecordDeleteEvent{ - HttpContext: c, - Record: record, - } - - handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error { - // delete the record - if err := api.app.Dao().DeleteRecord(e.Record); err != nil { - return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err) - } - - return e.HttpContext.NoContent(http.StatusNoContent) - }) - - if handlerErr == nil { - api.app.OnRecordAfterDeleteRequest().Trigger(event) - } - - return handlerErr -} - -func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error { - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin != nil { - return nil // admins are allowed to query everything - } - - decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam) - forbiddenFields := []string{"@collection.", "@request."} - - for _, field := range forbiddenFields { - if strings.Contains(decodedQuery, field) { - return NewForbiddenError("Only admins can filter by @collection and @request query params", nil) - } - } - - return nil -} diff --git a/apis/record_crud_test.go b/apis/record_crud_test.go deleted file mode 100644 index 9b21baa637a8ceb6bccd95b8080aa92700a58822..0000000000000000000000000000000000000000 --- a/apis/record_crud_test.go +++ /dev/null @@ -1,1749 +0,0 @@ -package apis_test - -import ( - "net/http" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestRecordCrudList(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodGet, - Url: "/api/collections/missing/records", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "unauthenticated trying to access nil rule collection (aka. need admin auth)", - Method: http.MethodGet, - Url: "/api/collections/demo1/records", - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authenticated record trying to access nil rule collection (aka. need admin auth)", - Method: http.MethodGet, - Url: "/api/collections/demo1/records", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "public collection but with admin only filter/sort (aka. @collection)", - Method: http.MethodGet, - Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'", - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "public collection but with ENCODED admin only filter/sort (aka. @collection)", - Method: http.MethodGet, - Url: "/api/collections/demo2/records?filter=%40collection.demo2.title%3D%27test1%27", - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "public collection", - Method: http.MethodGet, - Url: "/api/collections/demo2/records", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"0yxhwia2amd8gec"`, - `"id":"achvryl401bhse3"`, - `"id":"llvuca81nly1qls"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "public collection (using the collection id)", - Method: http.MethodGet, - Url: "/api/collections/sz5l5z67tg7gku0/records", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"0yxhwia2amd8gec"`, - `"id":"achvryl401bhse3"`, - `"id":"llvuca81nly1qls"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "authorized as admin trying to access nil rule collection (aka. need admin auth)", - Method: http.MethodGet, - Url: "/api/collections/demo1/records", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"al1h9ijdeojtsjy"`, - `"id":"84nmscqy84lsi1t"`, - `"id":"imy661ixudk5izi"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "valid query params", - Method: http.MethodGet, - Url: "/api/collections/demo1/records?filter=text~'test'&sort=-bool", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":2`, - `"items":[{`, - `"id":"al1h9ijdeojtsjy"`, - `"id":"84nmscqy84lsi1t"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "invalid filter", - Method: http.MethodGet, - Url: "/api/collections/demo1/records?filter=invalid~'test'", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "expand relations", - Method: http.MethodGet, - Url: "/api/collections/demo1/records?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":2`, - `"totalPages":2`, - `"totalItems":3`, - `"items":[{`, - `"collectionName":"demo1"`, - `"id":"84nmscqy84lsi1t"`, - `"id":"al1h9ijdeojtsjy"`, - `"expand":{`, - `"rel_one":""`, - `"rel_one":{"`, - `"rel_many":[{`, - `"rel":{`, - `"rel":""`, - `"json":[1,2,3]`, - `"select_many":["optionB","optionC"]`, - `"select_many":["optionB"]`, - // subrel items - `"id":"0yxhwia2amd8gec"`, - `"id":"llvuca81nly1qls"`, - // email visibility should be ignored for admins even in expanded rels - `"email":"test@example.com"`, - `"email":"test2@example.com"`, - `"email":"test3@example.com"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "authenticated record model that DOESN'T match the collection list rule", - Method: http.MethodGet, - Url: "/api/collections/demo3/records", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalItems":0`, - `"items":[]`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "authenticated record that matches the collection list rule", - Method: http.MethodGet, - Url: "/api/collections/demo3/records", - RequestHeaders: map[string]string{ - // clients, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":4`, - `"items":[{`, - `"id":"1tmknxy2868d869"`, - `"id":"lcl9d87w22ml6jy"`, - `"id":"7nwo8tuiatetxdm"`, - `"id":"mk5fmymtx4wsprk"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - - // auth collection checks - // ----------------------------------------------------------- - { - Name: "check email visibility as guest", - Method: http.MethodGet, - Url: "/api/collections/nologin/records", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"phhq3wr65cap535"`, - `"id":"dc49k6jgejn40h3"`, - `"id":"oos036e9xvqeexy"`, - `"email":"test2@example.com"`, - `"emailVisibility":true`, - `"emailVisibility":false`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - `"email":"test@example.com"`, - `"email":"test3@example.com"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "check email visibility as any authenticated record", - Method: http.MethodGet, - Url: "/api/collections/nologin/records", - RequestHeaders: map[string]string{ - // clients, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"phhq3wr65cap535"`, - `"id":"dc49k6jgejn40h3"`, - `"id":"oos036e9xvqeexy"`, - `"email":"test2@example.com"`, - `"emailVisibility":true`, - `"emailVisibility":false`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - `"email":"test@example.com"`, - `"email":"test3@example.com"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "check email visibility as manage auth record", - Method: http.MethodGet, - Url: "/api/collections/nologin/records", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"phhq3wr65cap535"`, - `"id":"dc49k6jgejn40h3"`, - `"id":"oos036e9xvqeexy"`, - `"email":"test@example.com"`, - `"email":"test2@example.com"`, - `"email":"test3@example.com"`, - `"emailVisibility":true`, - `"emailVisibility":false`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "check email visibility as admin", - Method: http.MethodGet, - Url: "/api/collections/nologin/records", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"phhq3wr65cap535"`, - `"id":"dc49k6jgejn40h3"`, - `"id":"oos036e9xvqeexy"`, - `"email":"test@example.com"`, - `"email":"test2@example.com"`, - `"email":"test3@example.com"`, - `"emailVisibility":true`, - `"emailVisibility":false`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - { - Name: "check self email visibility resolver", - Method: http.MethodGet, - Url: "/api/collections/nologin/records", - RequestHeaders: map[string]string{ - // nologin, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyMjA4OTg1MjYxfQ.DOYSon3x1-C0hJbwjEU6dp2-6oLeEa8bOlkyP1CinyM", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"page":1`, - `"perPage":30`, - `"totalPages":1`, - `"totalItems":3`, - `"items":[{`, - `"id":"phhq3wr65cap535"`, - `"id":"dc49k6jgejn40h3"`, - `"id":"oos036e9xvqeexy"`, - `"email":"test2@example.com"`, - `"email":"test@example.com"`, - `"emailVisibility":true`, - `"emailVisibility":false`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - `"email":"test3@example.com"`, - }, - ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordCrudView(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodGet, - Url: "/api/collections/missing/records/0yxhwia2amd8gec", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "missing record", - Method: http.MethodGet, - Url: "/api/collections/demo2/records/missing", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "unauthenticated trying to access nil rule collection (aka. need admin auth)", - Method: http.MethodGet, - Url: "/api/collections/demo1/records/imy661ixudk5izi", - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authenticated record trying to access nil rule collection (aka. need admin auth)", - Method: http.MethodGet, - Url: "/api/collections/demo1/records/imy661ixudk5izi", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authenticated record that doesn't match the collection view rule", - Method: http.MethodGet, - Url: "/api/collections/users/records/bgs820n361vj1qd", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "public collection view", - Method: http.MethodGet, - Url: "/api/collections/demo2/records/0yxhwia2amd8gec", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"0yxhwia2amd8gec"`, - `"collectionName":"demo2"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "public collection view (using the collection id)", - Method: http.MethodGet, - Url: "/api/collections/sz5l5z67tg7gku0/records/0yxhwia2amd8gec", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"0yxhwia2amd8gec"`, - `"collectionName":"demo2"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "authorized as admin trying to access nil rule collection view (aka. need admin auth)", - Method: http.MethodGet, - Url: "/api/collections/demo1/records/imy661ixudk5izi", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"imy661ixudk5izi"`, - `"collectionName":"demo1"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "authenticated record that does match the collection view rule", - Method: http.MethodGet, - Url: "/api/collections/users/records/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"4q1xlclmfloku33"`, - `"collectionName":"users"`, - // owners can always view their email - `"emailVisibility":false`, - `"email":"test@example.com"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "expand relations", - Method: http.MethodGet, - Url: "/api/collections/demo1/records/al1h9ijdeojtsjy?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"al1h9ijdeojtsjy"`, - `"collectionName":"demo1"`, - `"rel_many":[{`, - `"rel_one":{`, - `"collectionName":"users"`, - `"id":"bgs820n361vj1qd"`, - `"expand":{"rel":{`, - `"id":"0yxhwia2amd8gec"`, - `"collectionName":"demo2"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - - // auth collection checks - // ----------------------------------------------------------- - { - Name: "check email visibility as guest", - Method: http.MethodGet, - Url: "/api/collections/nologin/records/oos036e9xvqeexy", - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"oos036e9xvqeexy"`, - `"emailVisibility":false`, - `"verified":true`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - `"email":"test3@example.com"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "check email visibility as any authenticated record", - Method: http.MethodGet, - Url: "/api/collections/nologin/records/oos036e9xvqeexy", - RequestHeaders: map[string]string{ - // clients, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"oos036e9xvqeexy"`, - `"emailVisibility":false`, - `"verified":true`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - `"email":"test3@example.com"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "check email visibility as manage auth record", - Method: http.MethodGet, - Url: "/api/collections/nologin/records/oos036e9xvqeexy", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"oos036e9xvqeexy"`, - `"emailVisibility":false`, - `"email":"test3@example.com"`, - `"verified":true`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "check email visibility as admin", - Method: http.MethodGet, - Url: "/api/collections/nologin/records/oos036e9xvqeexy", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"oos036e9xvqeexy"`, - `"emailVisibility":false`, - `"email":"test3@example.com"`, - `"verified":true`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - { - Name: "check self email visibility resolver", - Method: http.MethodGet, - Url: "/api/collections/nologin/records/dc49k6jgejn40h3", - RequestHeaders: map[string]string{ - // nologin, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyMjA4OTg1MjYxfQ.DOYSon3x1-C0hJbwjEU6dp2-6oLeEa8bOlkyP1CinyM", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"dc49k6jgejn40h3"`, - `"email":"test@example.com"`, - `"emailVisibility":false`, - `"verified":false`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordCrudDelete(t *testing.T) { - ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) { - storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId) - - entries, _ := os.ReadDir(storageDir) - if len(entries) != 0 { - t.Errorf("Expected empty/deleted dir, found %d", len(entries)) - } - } - - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodDelete, - Url: "/api/collections/missing/records/0yxhwia2amd8gec", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "missing record", - Method: http.MethodDelete, - Url: "/api/collections/demo2/records/missing", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "unauthenticated trying to delete nil rule collection (aka. need admin auth)", - Method: http.MethodDelete, - Url: "/api/collections/demo1/records/imy661ixudk5izi", - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authenticated record trying to delete nil rule collection (aka. need admin auth)", - Method: http.MethodDelete, - Url: "/api/collections/demo1/records/imy661ixudk5izi", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authenticated record that doesn't match the collection delete rule", - Method: http.MethodDelete, - Url: "/api/collections/users/records/bgs820n361vj1qd", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "public collection record delete", - Method: http.MethodDelete, - Url: "/api/collections/nologin/records/dc49k6jgejn40h3", - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelAfterDelete": 1, - "OnModelBeforeDelete": 1, - "OnRecordAfterDeleteRequest": 1, - "OnRecordBeforeDeleteRequest": 1, - }, - }, - { - Name: "public collection record delete (using the collection id as identifier)", - Method: http.MethodDelete, - Url: "/api/collections/kpv709sk2lqbqk8/records/dc49k6jgejn40h3", - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelAfterDelete": 1, - "OnModelBeforeDelete": 1, - "OnRecordAfterDeleteRequest": 1, - "OnRecordBeforeDeleteRequest": 1, - }, - }, - { - Name: "authorized as admin trying to delete nil rule collection view (aka. need admin auth)", - Method: http.MethodDelete, - Url: "/api/collections/clients/records/o1y0dd0spd786md", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelAfterDelete": 1, - "OnModelBeforeDelete": 1, - "OnRecordAfterDeleteRequest": 1, - "OnRecordBeforeDeleteRequest": 1, - }, - }, - { - Name: "authenticated record that does match the collection delete rule", - Method: http.MethodDelete, - Url: "/api/collections/users/records/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelAfterDelete": 1, - "OnModelAfterUpdate": 1, - "OnModelBeforeDelete": 1, - "OnModelBeforeUpdate": 1, - "OnRecordAfterDeleteRequest": 1, - "OnRecordBeforeDeleteRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - ensureDeletedFiles(app, "_pb_users_auth_", "4q1xlclmfloku33") - - // check if all the external auths records were deleted - collection, _ := app.Dao().FindCollectionByNameOrId("users") - record := models.NewRecord(collection) - record.Id = "4q1xlclmfloku33" - externalAuths, err := app.Dao().FindAllExternalAuthsByRecord(record) - if err != nil { - t.Errorf("Failed to fetch external auths: %v", err) - } - if len(externalAuths) > 0 { - t.Errorf("Expected the linked external auths to be deleted, got %d", len(externalAuths)) - } - }, - }, - - // cascade delete checks - // ----------------------------------------------------------- - { - Name: "trying to delete a record while being part of a non-cascade required relation", - Method: http.MethodDelete, - Url: "/api/collections/demo3/records/7nwo8tuiatetxdm", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - ExpectedEvents: map[string]int{ - "OnRecordBeforeDeleteRequest": 1, - "OnModelBeforeUpdate": 1, // self_rel_many update of test1 record - "OnModelBeforeDelete": 1, // rel_one_cascade of test1 record - }, - }, - { - Name: "delete a record with non-cascade references", - Method: http.MethodDelete, - Url: "/api/collections/demo3/records/1tmknxy2868d869", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeDelete": 1, - "OnModelAfterDelete": 1, - "OnModelBeforeUpdate": 2, - "OnModelAfterUpdate": 2, - "OnRecordBeforeDeleteRequest": 1, - "OnRecordAfterDeleteRequest": 1, - }, - }, - { - Name: "delete a record with cascade references", - Method: http.MethodDelete, - Url: "/api/collections/users/records/oap640cot4yru2s", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 204, - ExpectedEvents: map[string]int{ - "OnModelBeforeDelete": 2, - "OnModelAfterDelete": 2, - "OnModelBeforeUpdate": 2, - "OnModelAfterUpdate": 2, - "OnRecordBeforeDeleteRequest": 1, - "OnRecordAfterDeleteRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - recId := "84nmscqy84lsi1t" - rec, _ := app.Dao().FindRecordById("demo1", recId, nil) - if rec != nil { - t.Errorf("Expected record %s to be cascade deleted", recId) - } - ensureDeletedFiles(app, "wsmn24bux7wo113", recId) - ensureDeletedFiles(app, "_pb_users_auth_", "oap640cot4yru2s") - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordCrudCreate(t *testing.T) { - formData, mp, err := tests.MockMultipartData(map[string]string{ - "title": "title_test", - }, "files") - if err != nil { - t.Fatal(err) - } - - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodPost, - Url: "/api/collections/missing/records", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "guest trying to access nil-rule collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/records", - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record trying to access nil-rule collection", - Method: http.MethodPost, - Url: "/api/collections/demo1/records", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "submit nil body", - Method: http.MethodPost, - Url: "/api/collections/demo2/records", - Body: nil, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "submit invalid format", - Method: http.MethodPost, - Url: "/api/collections/demo2/records", - Body: strings.NewReader(`{"`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "submit empty json body", - Method: http.MethodPost, - Url: "/api/collections/nologin/records", - Body: strings.NewReader(`{}`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"email":{"code":"validation_required"`, - `"password":{"code":"validation_required"`, - `"passwordConfirm":{"code":"validation_required"`, - }, - }, - { - Name: "guest submit in public collection", - Method: http.MethodPost, - Url: "/api/collections/demo2/records", - Body: strings.NewReader(`{"title":"new"}`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"title":"new"`, - `"active":false`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeCreateRequest": 1, - "OnRecordAfterCreateRequest": 1, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }, - }, - { - Name: "guest trying to submit in restricted collection", - Method: http.MethodPost, - Url: "/api/collections/demo3/records", - Body: strings.NewReader(`{"title":"test123"}`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record submit in restricted collection (rule failure check)", - Method: http.MethodPost, - Url: "/api/collections/demo3/records", - Body: strings.NewReader(`{"title":"test123"}`), - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record submit in restricted collection (rule pass check) + expand relations", - Method: http.MethodPost, - Url: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required", - Body: strings.NewReader(`{ - "title":"test123", - "rel_one_no_cascade":"mk5fmymtx4wsprk", - "rel_one_no_cascade_required":"7nwo8tuiatetxdm", - "rel_one_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"], - "rel_many_cascade":"lcl9d87w22ml6jy" - }`), - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"title":"test123"`, - `"rel_one_no_cascade":"mk5fmymtx4wsprk"`, - `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`, - `"rel_one_cascade":"mk5fmymtx4wsprk"`, - `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`, - `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`, - `"rel_many_cascade":["lcl9d87w22ml6jy"]`, - }, - NotExpectedContent: []string{ - // the users auth records don't have access to view the demo3 expands - `"expand":{`, - `"missing"`, - `"id":"mk5fmymtx4wsprk"`, - `"id":"7nwo8tuiatetxdm"`, - `"id":"lcl9d87w22ml6jy"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeCreateRequest": 1, - "OnRecordAfterCreateRequest": 1, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }, - }, - { - Name: "admin submit in restricted collection (rule skip check) + expand relations", - Method: http.MethodPost, - Url: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required", - Body: strings.NewReader(`{ - "title":"test123", - "rel_one_no_cascade":"mk5fmymtx4wsprk", - "rel_one_no_cascade_required":"7nwo8tuiatetxdm", - "rel_one_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"], - "rel_many_cascade":"lcl9d87w22ml6jy" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":`, - `"title":"test123"`, - `"rel_one_no_cascade":"mk5fmymtx4wsprk"`, - `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`, - `"rel_one_cascade":"mk5fmymtx4wsprk"`, - `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`, - `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`, - `"rel_many_cascade":["lcl9d87w22ml6jy"]`, - `"expand":{`, - `"id":"mk5fmymtx4wsprk"`, - `"id":"7nwo8tuiatetxdm"`, - `"id":"lcl9d87w22ml6jy"`, - }, - NotExpectedContent: []string{ - `"missing"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeCreateRequest": 1, - "OnRecordAfterCreateRequest": 1, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }, - }, - { - Name: "submit via multipart form data", - Method: http.MethodPost, - Url: "/api/collections/demo3/records", - Body: formData, - RequestHeaders: map[string]string{ - "Content-Type": mp.FormDataContentType(), - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"`, - `"title":"title_test"`, - `"files":["`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeCreateRequest": 1, - "OnRecordAfterCreateRequest": 1, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }, - }, - - // ID checks - // ----------------------------------------------------------- - { - Name: "invalid custom insertion id (less than 15 chars)", - Method: http.MethodPost, - Url: "/api/collections/demo3/records", - Body: strings.NewReader(`{ - "id": "12345678901234", - "title": "test" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"id":{"code":"validation_length_invalid"`, - }, - }, - { - Name: "invalid custom insertion id (more than 15 chars)", - Method: http.MethodPost, - Url: "/api/collections/demo3/records", - Body: strings.NewReader(`{ - "id": "1234567890123456", - "title": "test" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"id":{"code":"validation_length_invalid"`, - }, - }, - { - Name: "valid custom insertion id (exactly 15 chars)", - Method: http.MethodPost, - Url: "/api/collections/demo3/records", - Body: strings.NewReader(`{ - "id": "123456789012345", - "title": "test" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"123456789012345"`, - `"title":"test"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeCreateRequest": 1, - "OnRecordAfterCreateRequest": 1, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }, - }, - { - Name: "valid custom insertion id existing in another non-auth collection", - Method: http.MethodPost, - Url: "/api/collections/demo3/records", - Body: strings.NewReader(`{ - "id": "0yxhwia2amd8gec", - "title": "test" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"0yxhwia2amd8gec"`, - `"title":"test"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeCreateRequest": 1, - "OnRecordAfterCreateRequest": 1, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }, - }, - { - Name: "valid custom insertion auth id duplicating in another auth collection", - Method: http.MethodPost, - Url: "/api/collections/users/records", - Body: strings.NewReader(`{ - "id":"o1y0dd0spd786md", - "title":"test", - "password":"1234567890", - "passwordConfirm":"1234567890" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - ExpectedEvents: map[string]int{ - "OnRecordBeforeCreateRequest": 1, - }, - }, - - // auth records - // ----------------------------------------------------------- - { - Name: "auth record with invalid data", - Method: http.MethodPost, - Url: "/api/collections/users/records", - Body: strings.NewReader(`{ - "id":"o1y0pd786mq", - "username":"Users75657", - "email":"invalid", - "password":"1234567", - "passwordConfirm":"1234560" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"id":{"code":"validation_length_invalid"`, - `"username":{"code":"validation_invalid_username"`, // for duplicated case-insensitive username - `"email":{"code":"validation_is_email"`, - `"password":{"code":"validation_length_out_of_range"`, - `"passwordConfirm":{"code":"validation_values_mismatch"`, - }, - NotExpectedContent: []string{ - // schema fields are not checked if the base fields has errors - `"rel":{"code":`, - }, - }, - { - Name: "auth record with valid base fields but invalid schema data", - Method: http.MethodPost, - Url: "/api/collections/users/records", - Body: strings.NewReader(`{ - "password":"12345678", - "passwordConfirm":"12345678", - "rel":"invalid" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"rel":{"code":`, - }, - }, - { - Name: "auth record with valid data and explicitly verified state by guest", - Method: http.MethodPost, - Url: "/api/collections/users/records", - Body: strings.NewReader(`{ - "password":"12345678", - "passwordConfirm":"12345678", - "verified":true - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"verified":{"code":`, - }, - }, - { - Name: "auth record with valid data and explicitly verified state by random user", - Method: http.MethodPost, - Url: "/api/collections/users/records", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - Body: strings.NewReader(`{ - "password":"12345678", - "passwordConfirm":"12345678", - "emailVisibility":true, - "verified":true - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"verified":{"code":`, - }, - NotExpectedContent: []string{ - `"emailVisibility":{"code":`, - }, - }, - { - Name: "auth record with valid data by admin", - Method: http.MethodPost, - Url: "/api/collections/users/records", - Body: strings.NewReader(`{ - "id":"o1o1y0pd78686mq", - "username":"test.valid", - "email":"new@example.com", - "password":"12345678", - "passwordConfirm":"12345678", - "rel":"achvryl401bhse3", - "emailVisibility":true, - "verified":true - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"o1o1y0pd78686mq"`, - `"username":"test.valid"`, - `"email":"new@example.com"`, - `"rel":"achvryl401bhse3"`, - `"emailVisibility":true`, - `"verified":true`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"password"`, - `"passwordConfirm"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelAfterCreate": 1, - "OnModelBeforeCreate": 1, - "OnRecordAfterCreateRequest": 1, - "OnRecordBeforeCreateRequest": 1, - }, - }, - { - Name: "auth record with valid data by auth record with manage access", - Method: http.MethodPost, - Url: "/api/collections/nologin/records", - Body: strings.NewReader(`{ - "email":"new@example.com", - "password":"12345678", - "passwordConfirm":"12345678", - "name":"test_name", - "emailVisibility":true, - "verified":true - }`), - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"`, - `"username":"`, - `"email":"new@example.com"`, - `"name":"test_name"`, - `"emailVisibility":true`, - `"verified":true`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"password"`, - `"passwordConfirm"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelAfterCreate": 1, - "OnModelBeforeCreate": 1, - "OnRecordAfterCreateRequest": 1, - "OnRecordBeforeCreateRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestRecordCrudUpdate(t *testing.T) { - formData, mp, err := tests.MockMultipartData(map[string]string{ - "title": "title_test", - }, "files") - if err != nil { - t.Fatal(err) - } - - scenarios := []tests.ApiScenario{ - { - Name: "missing collection", - Method: http.MethodPatch, - Url: "/api/collections/missing/records/0yxhwia2amd8gec", - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "guest trying to access nil-rule collection record", - Method: http.MethodPatch, - Url: "/api/collections/demo1/records/imy661ixudk5izi", - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record trying to access nil-rule collection", - Method: http.MethodPatch, - Url: "/api/collections/demo1/records/imy661ixudk5izi", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 403, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "submit invalid body", - Method: http.MethodPatch, - Url: "/api/collections/demo2/records/0yxhwia2amd8gec", - Body: strings.NewReader(`{"`), - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "submit nil body", - Method: http.MethodPatch, - Url: "/api/collections/demo2/records/0yxhwia2amd8gec", - Body: nil, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "submit empty body (aka. no fields change)", - Method: http.MethodPatch, - Url: "/api/collections/demo2/records/0yxhwia2amd8gec", - Body: strings.NewReader(`{}`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"collectionName":"demo2"`, - `"id":"0yxhwia2amd8gec"`, - }, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordAfterUpdateRequest": 1, - "OnRecordBeforeUpdateRequest": 1, - }, - }, - { - Name: "trigger field validation", - Method: http.MethodPatch, - Url: "/api/collections/demo2/records/0yxhwia2amd8gec", - Body: strings.NewReader(`{"title":"a"}`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `data":{`, - `"title":{"code":"validation_min_text_constraint"`, - }, - }, - { - Name: "guest submit in public collection", - Method: http.MethodPatch, - Url: "/api/collections/demo2/records/0yxhwia2amd8gec", - Body: strings.NewReader(`{"title":"new"}`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"0yxhwia2amd8gec"`, - `"title":"new"`, - `"active":true`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeUpdateRequest": 1, - "OnRecordAfterUpdateRequest": 1, - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - }, - }, - { - Name: "guest trying to submit in restricted collection", - Method: http.MethodPatch, - Url: "/api/collections/demo3/records/mk5fmymtx4wsprk", - Body: strings.NewReader(`{"title":"new"}`), - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record submit in restricted collection (rule failure check)", - Method: http.MethodPatch, - Url: "/api/collections/demo3/records/mk5fmymtx4wsprk", - Body: strings.NewReader(`{"title":"new"}`), - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 404, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "auth record submit in restricted collection (rule pass check) + expand relations", - Method: http.MethodPatch, - Url: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required", - Body: strings.NewReader(`{ - "title":"test123", - "rel_one_no_cascade":"mk5fmymtx4wsprk", - "rel_one_no_cascade_required":"7nwo8tuiatetxdm", - "rel_one_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"], - "rel_many_cascade":"lcl9d87w22ml6jy" - }`), - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"i9naidtvr6qsgb4"`, - `"title":"test123"`, - `"rel_one_no_cascade":"mk5fmymtx4wsprk"`, - `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`, - `"rel_one_cascade":"mk5fmymtx4wsprk"`, - `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`, - `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`, - `"rel_many_cascade":["lcl9d87w22ml6jy"]`, - }, - NotExpectedContent: []string{ - // the users auth records don't have access to view the demo3 expands - `"expand":{`, - `"missing"`, - `"id":"mk5fmymtx4wsprk"`, - `"id":"7nwo8tuiatetxdm"`, - `"id":"lcl9d87w22ml6jy"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeUpdateRequest": 1, - "OnRecordAfterUpdateRequest": 1, - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - }, - }, - { - Name: "admin submit in restricted collection (rule skip check) + expand relations", - Method: http.MethodPatch, - Url: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required", - Body: strings.NewReader(`{ - "title":"test123", - "rel_one_no_cascade":"mk5fmymtx4wsprk", - "rel_one_no_cascade_required":"7nwo8tuiatetxdm", - "rel_one_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade":"mk5fmymtx4wsprk", - "rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"], - "rel_many_cascade":"lcl9d87w22ml6jy" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"i9naidtvr6qsgb4"`, - `"title":"test123"`, - `"rel_one_no_cascade":"mk5fmymtx4wsprk"`, - `"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`, - `"rel_one_cascade":"mk5fmymtx4wsprk"`, - `"rel_many_no_cascade":["mk5fmymtx4wsprk"]`, - `"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"]`, - `"rel_many_cascade":["lcl9d87w22ml6jy"]`, - `"expand":{`, - `"id":"mk5fmymtx4wsprk"`, - `"id":"7nwo8tuiatetxdm"`, - `"id":"lcl9d87w22ml6jy"`, - }, - NotExpectedContent: []string{ - `"missing"`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeUpdateRequest": 1, - "OnRecordAfterUpdateRequest": 1, - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - }, - }, - { - Name: "submit via multipart form data", - Method: http.MethodPatch, - Url: "/api/collections/demo3/records/mk5fmymtx4wsprk", - Body: formData, - RequestHeaders: map[string]string{ - "Content-Type": mp.FormDataContentType(), - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"mk5fmymtx4wsprk"`, - `"title":"title_test"`, - `"files":["`, - }, - ExpectedEvents: map[string]int{ - "OnRecordBeforeUpdateRequest": 1, - "OnRecordAfterUpdateRequest": 1, - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - }, - }, - { - Name: "try to change the id of an existing record", - Method: http.MethodPatch, - Url: "/api/collections/demo3/records/mk5fmymtx4wsprk", - Body: strings.NewReader(`{ - "id": "mk5fmymtx4wspra" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"id":{"code":"validation_in_invalid"`, - }, - }, - - // auth records - // ----------------------------------------------------------- - { - Name: "auth record with invalid data", - Method: http.MethodPatch, - Url: "/api/collections/users/records/bgs820n361vj1qd", - Body: strings.NewReader(`{ - "username":"Users75657", - "email":"invalid", - "password":"1234567", - "passwordConfirm":"1234560", - "verified":false - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"username":{"code":"validation_invalid_username"`, // for duplicated case-insensitive username - `"email":{"code":"validation_is_email"`, - `"password":{"code":"validation_length_out_of_range"`, - `"passwordConfirm":{"code":"validation_values_mismatch"`, - }, - NotExpectedContent: []string{ - // admins are allowed to change the verified state - `"verified"`, - // schema fields are not checked if the base fields has errors - `"rel":{"code":`, - }, - }, - { - Name: "auth record with valid base fields but invalid schema data", - Method: http.MethodPatch, - Url: "/api/collections/users/records/bgs820n361vj1qd", - Body: strings.NewReader(`{ - "password":"12345678", - "passwordConfirm":"12345678", - "rel":"invalid" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"rel":{"code":`, - }, - }, - { - Name: "try to change account managing fields by guest", - Method: http.MethodPatch, - Url: "/api/collections/nologin/records/phhq3wr65cap535", - Body: strings.NewReader(`{ - "password":"12345678", - "passwordConfirm":"12345678", - "emailVisibility":true, - "verified":true - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"verified":{"code":`, - `"oldPassword":{"code":`, - }, - NotExpectedContent: []string{ - `"emailVisibility":{"code":`, - }, - }, - { - Name: "try to change account managing fields by auth record (owner)", - Method: http.MethodPatch, - Url: "/api/collections/users/records/4q1xlclmfloku33", - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - Body: strings.NewReader(`{ - "password":"12345678", - "passwordConfirm":"12345678", - "emailVisibility":true, - "verified":true - }`), - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"verified":{"code":`, - `"oldPassword":{"code":`, - }, - NotExpectedContent: []string{ - `"emailVisibility":{"code":`, - }, - }, - { - Name: "try to change account managing fields by auth record with managing rights", - Method: http.MethodPatch, - Url: "/api/collections/nologin/records/phhq3wr65cap535", - Body: strings.NewReader(`{ - "email":"new@example.com", - "password":"12345678", - "passwordConfirm":"12345678", - "name":"test_name", - "emailVisibility":true, - "verified":true - }`), - RequestHeaders: map[string]string{ - // users, test@example.com - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"email":"new@example.com"`, - `"name":"test_name"`, - `"emailVisibility":true`, - `"verified":true`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"password"`, - `"passwordConfirm"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordAfterUpdateRequest": 1, - "OnRecordBeforeUpdateRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - record, _ := app.Dao().FindRecordById("nologin", "phhq3wr65cap535") - if !record.ValidatePassword("12345678") { - t.Fatal("Password update failed.") - } - }, - }, - { - Name: "update auth record with valid data by admin", - Method: http.MethodPatch, - Url: "/api/collections/users/records/oap640cot4yru2s", - Body: strings.NewReader(`{ - "username":"test.valid", - "email":"new@example.com", - "password":"12345678", - "passwordConfirm":"12345678", - "rel":"achvryl401bhse3", - "emailVisibility":true, - "verified":false - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"username":"test.valid"`, - `"email":"new@example.com"`, - `"rel":"achvryl401bhse3"`, - `"emailVisibility":true`, - `"verified":false`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"password"`, - `"passwordConfirm"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordAfterUpdateRequest": 1, - "OnRecordBeforeUpdateRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - record, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s") - if !record.ValidatePassword("12345678") { - t.Fatal("Password update failed.") - } - }, - }, - { - Name: "update auth record with valid data by guest (empty update filter)", - Method: http.MethodPatch, - Url: "/api/collections/nologin/records/dc49k6jgejn40h3", - Body: strings.NewReader(`{ - "username":"test_new", - "emailVisibility":true, - "name":"test" - }`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"username":"test_new"`, - `"email":"test@example.com"`, // the email should be visible since we updated the emailVisibility - `"emailVisibility":true`, - `"verified":false`, - `"name":"test"`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"password"`, - `"passwordConfirm"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordAfterUpdateRequest": 1, - "OnRecordBeforeUpdateRequest": 1, - }, - }, - { - Name: "success password change with oldPassword", - Method: http.MethodPatch, - Url: "/api/collections/nologin/records/dc49k6jgejn40h3", - Body: strings.NewReader(`{ - "password":"123456789", - "passwordConfirm":"123456789", - "oldPassword":"1234567890" - }`), - ExpectedStatus: 200, - ExpectedContent: []string{ - `"id":"dc49k6jgejn40h3"`, - }, - NotExpectedContent: []string{ - `"tokenKey"`, - `"password"`, - `"passwordConfirm"`, - `"passwordHash"`, - }, - ExpectedEvents: map[string]int{ - "OnModelAfterUpdate": 1, - "OnModelBeforeUpdate": 1, - "OnRecordAfterUpdateRequest": 1, - "OnRecordBeforeUpdateRequest": 1, - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - record, _ := app.Dao().FindRecordById("nologin", "dc49k6jgejn40h3") - if !record.ValidatePassword("123456789") { - t.Fatal("Password update failed.") - } - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/apis/record_helpers.go b/apis/record_helpers.go deleted file mode 100644 index 35d68d241627985cccb921bb65b21b7709748eec..0000000000000000000000000000000000000000 --- a/apis/record_helpers.go +++ /dev/null @@ -1,223 +0,0 @@ -package apis - -import ( - "fmt" - "strings" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/resolvers" - "github.com/pocketbase/pocketbase/tools/rest" - "github.com/pocketbase/pocketbase/tools/search" -) - -const ContextRequestDataKey = "requestData" - -// Deprecated: Will be removed after v0.9. Use apis.RequestData(c) instead. -func GetRequestData(c echo.Context) *models.RequestData { - return RequestData(c) -} - -// RequestData exports cached common request data fields -// (query, body, logged auth state, etc.) from the provided context. -func RequestData(c echo.Context) *models.RequestData { - // return cached to avoid copying the body multiple times - if v := c.Get(ContextRequestDataKey); v != nil { - if data, ok := v.(*models.RequestData); ok { - return data - } - } - - result := &models.RequestData{ - Method: c.Request().Method, - Query: map[string]any{}, - Data: map[string]any{}, - } - - result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record) - result.Admin, _ = c.Get(ContextAdminKey).(*models.Admin) - echo.BindQueryParams(c, &result.Query) - rest.BindBody(c, &result.Data) - - c.Set(ContextRequestDataKey, result) - - return result -} - -// EnrichRecord parses the request context and enrich the provided record: -// - expands relations (if defaultExpands and/or ?expand query param is set) -// - ensures that the emails of the auth record and its expanded auth relations -// are visibe only for the current logged admin, record owner or record with manage access -func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error { - return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...) -} - -// EnrichRecords parses the request context and enriches the provided records: -// - expands relations (if defaultExpands and/or ?expand query param is set) -// - ensures that the emails of the auth records and their expanded auth relations -// are visibe only for the current logged admin, record owner or record with manage access -func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error { - requestData := RequestData(c) - - if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil { - return fmt.Errorf("Failed to resolve email visibility: %w", err) - } - - expands := defaultExpands - expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...) - if len(expands) == 0 { - return nil // nothing to expand - } - - errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData)) - if len(errs) > 0 { - return fmt.Errorf("Failed to expand: %v", errs) - } - - return nil -} - -// expandFetch is the records fetch function that is used to expand related records. -func expandFetch( - dao *daos.Dao, - requestData *models.RequestData, -) daos.ExpandFetchFunc { - return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) { - records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error { - if requestData.Admin != nil { - return nil // admins can access everything - } - - if relCollection.ViewRule == nil { - return fmt.Errorf("Only admins can view collection %q records", relCollection.Name) - } - - if *relCollection.ViewRule != "" { - resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true) - expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - } - - return nil - }) - - if err == nil && len(records) > 0 { - autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData) - } - - return records, err - } -} - -// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for -// the provided record if the current auth model is admin, owner or a "manager". -// -// Note: Expects all records to be from the same auth collection! -func autoIgnoreAuthRecordsEmailVisibility( - dao *daos.Dao, - records []*models.Record, - requestData *models.RequestData, -) error { - if len(records) == 0 || !records[0].Collection().IsAuth() { - return nil // nothing to check - } - - if requestData.Admin != nil { - for _, rec := range records { - rec.IgnoreEmailVisibility(true) - } - return nil - } - - collection := records[0].Collection() - - mappedRecords := make(map[string]*models.Record, len(records)) - recordIds := make([]any, len(records)) - for i, rec := range records { - mappedRecords[rec.Id] = rec - recordIds[i] = rec.Id - } - - if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil { - mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true) - } - - authOptions := collection.AuthOptions() - if authOptions.ManageRule == nil || *authOptions.ManageRule == "" { - return nil // no manage rule to check - } - - // fetch the ids of the managed records - // --- - managedIds := []string{} - - query := dao.RecordQuery(collection). - Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id"). - AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...)) - - resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true) - expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(query) - query.AndWhere(expr) - - if err := query.Column(&managedIds); err != nil { - return err - } - // --- - - // ignore the email visibility check for the managed records - for _, id := range managedIds { - if rec, ok := mappedRecords[id]; ok { - rec.IgnoreEmailVisibility(true) - } - } - - return nil -} - -// hasAuthManageAccess checks whether the client is allowed to have full -// [forms.RecordUpsert] auth management permissions -// (aka. allowing to change system auth fields without oldPassword). -func hasAuthManageAccess( - dao *daos.Dao, - record *models.Record, - requestData *models.RequestData, -) bool { - if !record.Collection().IsAuth() { - return false - } - - manageRule := record.Collection().AuthOptions().ManageRule - - if manageRule == nil || *manageRule == "" { - return false // only for admins (manageRule can't be empty) - } - - if requestData == nil || requestData.AuthRecord == nil { - return false // no auth record - } - - ruleFunc := func(q *dbx.SelectQuery) error { - resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true) - expr, err := search.FilterData(*manageRule).BuildExpr(resolver) - if err != nil { - return err - } - resolver.UpdateQuery(q) - q.AndWhere(expr) - return nil - } - - _, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc) - - return findErr == nil -} diff --git a/apis/record_helpers_test.go b/apis/record_helpers_test.go deleted file mode 100644 index a147c491ebe44a59de345d3569c796c4843be0e3..0000000000000000000000000000000000000000 --- a/apis/record_helpers_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package apis_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestRequestData(t *testing.T) { - e := echo.New() - req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - dummyRecord := &models.Record{} - dummyRecord.Id = "id1" - c.Set(apis.ContextAuthRecordKey, dummyRecord) - - dummyAdmin := &models.Admin{} - dummyAdmin.Id = "id2" - c.Set(apis.ContextAdminKey, dummyAdmin) - - result := apis.RequestData(c) - - if result == nil { - t.Fatal("Expected *models.RequestData instance, got nil") - } - - if result.Method != http.MethodPost { - t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method) - } - - rawQuery, _ := json.Marshal(result.Query) - expectedQuery := `{"test":"123"}` - if v := string(rawQuery); v != expectedQuery { - t.Fatalf("Expected Query %v, got %v", expectedQuery, v) - } - - rawData, _ := json.Marshal(result.Data) - expectedData := `{"test":456}` - if v := string(rawData); v != expectedData { - t.Fatalf("Expected Data %v, got %v", expectedData, v) - } - - if result.AuthRecord == nil || result.AuthRecord.Id != dummyRecord.Id { - t.Fatalf("Expected AuthRecord %v, got %v", dummyRecord, result.AuthRecord) - } - - if result.Admin == nil || result.Admin.Id != dummyAdmin.Id { - t.Fatalf("Expected Admin %v, got %v", dummyAdmin, result.Admin) - } -} - -func TestEnrichRecords(t *testing.T) { - e := echo.New() - req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - dummyAdmin := &models.Admin{} - dummyAdmin.Id = "test_id" - c.Set(apis.ContextAdminKey, dummyAdmin) - - app, _ := tests.NewTestApp() - defer app.Cleanup() - - records, err := app.Dao().FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"}) - if err != nil { - t.Fatal(err) - } - - apis.EnrichRecords(c, app.Dao(), records, "rel_one") - - for _, record := range records { - expand := record.Expand() - if len(expand) == 0 { - t.Fatalf("Expected non-empty expand, got nil for record %v", record) - } - - if len(record.GetStringSlice("rel_one")) != 0 { - if _, ok := expand["rel_one"]; !ok { - t.Fatalf("Expected rel_one to be expanded for record %v, got \n%v", record, expand) - } - } - - if len(record.GetStringSlice("rel_many")) != 0 { - if _, ok := expand["rel_many"]; !ok { - t.Fatalf("Expected rel_many to be expanded for record %v, got \n%v", record, expand) - } - } - } -} diff --git a/apis/settings.go b/apis/settings.go deleted file mode 100644 index b7414faadb7f4b723df8409541820559601cbce7..0000000000000000000000000000000000000000 --- a/apis/settings.go +++ /dev/null @@ -1,127 +0,0 @@ -package apis - -import ( - "net/http" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tools/security" -) - -// bindSettingsApi registers the settings api endpoints. -func bindSettingsApi(app core.App, rg *echo.Group) { - api := settingsApi{app: app} - - subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth()) - subGroup.GET("", api.list) - subGroup.PATCH("", api.set) - subGroup.POST("/test/s3", api.testS3) - subGroup.POST("/test/email", api.testEmail) -} - -type settingsApi struct { - app core.App -} - -func (api *settingsApi) list(c echo.Context) error { - settings, err := api.app.Settings().RedactClone() - if err != nil { - return NewBadRequestError("", err) - } - - event := &core.SettingsListEvent{ - HttpContext: c, - RedactedSettings: settings, - } - - return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error { - return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings) - }) -} - -func (api *settingsApi) set(c echo.Context) error { - form := forms.NewSettingsUpsert(api.app) - - // load request - if err := c.Bind(form); err != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", err) - } - - event := &core.SettingsUpdateEvent{ - HttpContext: c, - OldSettings: api.app.Settings(), - NewSettings: form.Settings, - } - - // update the settings - submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error { - if err := next(); err != nil { - return NewBadRequestError("An error occurred while submitting the form.", err) - } - - redactedSettings, err := api.app.Settings().RedactClone() - if err != nil { - return NewBadRequestError("", err) - } - - return e.HttpContext.JSON(http.StatusOK, redactedSettings) - }) - } - }) - - if submitErr == nil { - api.app.OnSettingsAfterUpdateRequest().Trigger(event) - } - - return submitErr -} - -func (api *settingsApi) testS3(c echo.Context) error { - if !api.app.Settings().S3.Enabled { - return NewBadRequestError("S3 storage is not enabled.", nil) - } - - fs, err := api.app.NewFilesystem() - if err != nil { - return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil) - } - defer fs.Close() - - testFileKey := "pb_test_" + security.PseudorandomString(5) + "/test.txt" - - if err := fs.Upload([]byte("test"), testFileKey); err != nil { - return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil) - } - - if err := fs.Delete(testFileKey); err != nil { - return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil) - } - - return c.NoContent(http.StatusNoContent) -} - -func (api *settingsApi) testEmail(c echo.Context) error { - form := forms.NewTestEmailSend(api.app) - - // load request - if err := c.Bind(form); err != nil { - return NewBadRequestError("An error occurred while loading the submitted data.", err) - } - - // send - if err := form.Submit(); err != nil { - if fErr, ok := err.(validation.Errors); ok { - // form error - return NewBadRequestError("Failed to send the test email.", fErr) - } - - // mailer error - return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil) - } - - return c.NoContent(http.StatusNoContent) -} diff --git a/apis/settings_test.go b/apis/settings_test.go deleted file mode 100644 index 6f7af9218ed3218d8ed06e4e42372a7075810a52..0000000000000000000000000000000000000000 --- a/apis/settings_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package apis_test - -import ( - "net/http" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/tests" -) - -func TestSettingsList(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodGet, - Url: "/api/settings", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as auth record", - Method: http.MethodGet, - Url: "/api/settings", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin", - Method: http.MethodGet, - Url: "/api/settings", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"meta":{`, - `"logs":{`, - `"smtp":{`, - `"s3":{`, - `"adminAuthToken":{`, - `"adminPasswordResetToken":{`, - `"recordAuthToken":{`, - `"recordPasswordResetToken":{`, - `"recordEmailChangeToken":{`, - `"recordVerificationToken":{`, - `"emailAuth":{`, - `"googleAuth":{`, - `"facebookAuth":{`, - `"githubAuth":{`, - `"gitlabAuth":{`, - `"twitterAuth":{`, - `"discordAuth":{`, - `"microsoftAuth":{`, - `"spotifyAuth":{`, - `"kakaoAuth":{`, - `"twitchAuth":{`, - `"secret":"******"`, - `"clientSecret":"******"`, - }, - ExpectedEvents: map[string]int{ - "OnSettingsListRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestSettingsSet(t *testing.T) { - validData := `{"meta":{"appName":"update_test"}}` - - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPatch, - Url: "/api/settings", - Body: strings.NewReader(validData), - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as auth record", - Method: http.MethodPatch, - Url: "/api/settings", - Body: strings.NewReader(validData), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin submitting empty data", - Method: http.MethodPatch, - Url: "/api/settings", - Body: strings.NewReader(``), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"meta":{`, - `"logs":{`, - `"smtp":{`, - `"s3":{`, - `"adminAuthToken":{`, - `"adminPasswordResetToken":{`, - `"recordAuthToken":{`, - `"recordPasswordResetToken":{`, - `"recordEmailChangeToken":{`, - `"recordVerificationToken":{`, - `"emailAuth":{`, - `"googleAuth":{`, - `"facebookAuth":{`, - `"githubAuth":{`, - `"gitlabAuth":{`, - `"discordAuth":{`, - `"microsoftAuth":{`, - `"spotifyAuth":{`, - `"kakaoAuth":{`, - `"twitchAuth":{`, - `"secret":"******"`, - `"clientSecret":"******"`, - `"appName":"acme_test"`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnSettingsBeforeUpdateRequest": 1, - "OnSettingsAfterUpdateRequest": 1, - }, - }, - { - Name: "authorized as admin submitting invalid data", - Method: http.MethodPatch, - Url: "/api/settings", - Body: strings.NewReader(`{"meta":{"appName":""}}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"data":{`, - `"meta":{"appName":{"code":"validation_required"`, - }, - }, - { - Name: "authorized as admin submitting valid data", - Method: http.MethodPatch, - Url: "/api/settings", - Body: strings.NewReader(validData), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 200, - ExpectedContent: []string{ - `"meta":{`, - `"logs":{`, - `"smtp":{`, - `"s3":{`, - `"adminAuthToken":{`, - `"adminPasswordResetToken":{`, - `"recordAuthToken":{`, - `"recordPasswordResetToken":{`, - `"recordEmailChangeToken":{`, - `"recordVerificationToken":{`, - `"emailAuth":{`, - `"googleAuth":{`, - `"facebookAuth":{`, - `"githubAuth":{`, - `"gitlabAuth":{`, - `"twitterAuth":{`, - `"discordAuth":{`, - `"microsoftAuth":{`, - `"spotifyAuth":{`, - `"kakaoAuth":{`, - `"twitchAuth":{`, - `"secret":"******"`, - `"clientSecret":"******"`, - `"appName":"update_test"`, - }, - ExpectedEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnSettingsBeforeUpdateRequest": 1, - "OnSettingsAfterUpdateRequest": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestSettingsTestS3(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPost, - Url: "/api/settings/test/s3", - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as auth record", - Method: http.MethodPost, - Url: "/api/settings/test/s3", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin (no s3)", - Method: http.MethodPost, - Url: "/api/settings/test/s3", - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestSettingsTestEmail(t *testing.T) { - scenarios := []tests.ApiScenario{ - { - Name: "unauthorized", - Method: http.MethodPost, - Url: "/api/settings/test/email", - Body: strings.NewReader(`{ - "template": "verification", - "email": "test@example.com" - }`), - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as auth record", - Method: http.MethodPost, - Url: "/api/settings/test/email", - Body: strings.NewReader(`{ - "template": "verification", - "email": "test@example.com" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - }, - ExpectedStatus: 401, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin (invalid body)", - Method: http.MethodPost, - Url: "/api/settings/test/email", - Body: strings.NewReader(`{`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{`"data":{}`}, - }, - { - Name: "authorized as admin (empty json)", - Method: http.MethodPost, - Url: "/api/settings/test/email", - Body: strings.NewReader(`{}`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - ExpectedStatus: 400, - ExpectedContent: []string{ - `"email":{"code":"validation_required"`, - `"template":{"code":"validation_required"`, - }, - }, - { - Name: "authorized as admin (verifiation template)", - Method: http.MethodPost, - Url: "/api/settings/test/email", - Body: strings.NewReader(`{ - "template": "verification", - "email": "test@example.com" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if app.TestMailer.TotalSend != 1 { - t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend) - } - - if app.TestMailer.LastMessage.To.Address != "test@example.com" { - t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address) - } - - if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") { - t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML) - } - }, - ExpectedStatus: 204, - ExpectedContent: []string{}, - ExpectedEvents: map[string]int{ - "OnMailerBeforeRecordVerificationSend": 1, - "OnMailerAfterRecordVerificationSend": 1, - }, - }, - { - Name: "authorized as admin (password reset template)", - Method: http.MethodPost, - Url: "/api/settings/test/email", - Body: strings.NewReader(`{ - "template": "password-reset", - "email": "test@example.com" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if app.TestMailer.TotalSend != 1 { - t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend) - } - - if app.TestMailer.LastMessage.To.Address != "test@example.com" { - t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address) - } - - if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") { - t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML) - } - }, - ExpectedStatus: 204, - ExpectedContent: []string{}, - ExpectedEvents: map[string]int{ - "OnMailerBeforeRecordResetPasswordSend": 1, - "OnMailerAfterRecordResetPasswordSend": 1, - }, - }, - { - Name: "authorized as admin (email change)", - Method: http.MethodPost, - Url: "/api/settings/test/email", - Body: strings.NewReader(`{ - "template": "email-change", - "email": "test@example.com" - }`), - RequestHeaders: map[string]string{ - "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - }, - AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { - if app.TestMailer.TotalSend != 1 { - t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend) - } - - if app.TestMailer.LastMessage.To.Address != "test@example.com" { - t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address) - } - - if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") { - t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML) - } - }, - ExpectedStatus: 204, - ExpectedContent: []string{}, - ExpectedEvents: map[string]int{ - "OnMailerBeforeRecordChangeEmailSend": 1, - "OnMailerAfterRecordChangeEmailSend": 1, - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} diff --git a/cmd/serve.go b/cmd/serve.go deleted file mode 100644 index d63271ebd8bd71e3c5c0a1d3be3301d3a2795201..0000000000000000000000000000000000000000 --- a/cmd/serve.go +++ /dev/null @@ -1,172 +0,0 @@ -package cmd - -import ( - "crypto/tls" - "log" - "net" - "net/http" - "path/filepath" - "time" - - "github.com/fatih/color" - "github.com/labstack/echo/v5/middleware" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/apis" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/migrations/logs" - "github.com/pocketbase/pocketbase/tools/migrate" - "github.com/spf13/cobra" - "golang.org/x/crypto/acme" - "golang.org/x/crypto/acme/autocert" -) - -// NewServeCommand creates and returns new command responsible for -// starting the default PocketBase web server. -func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command { - var allowedOrigins []string - var httpAddr string - var httpsAddr string - - command := &cobra.Command{ - Use: "serve", - Short: "Starts the web server (default to 127.0.0.1:8090)", - Run: func(command *cobra.Command, args []string) { - // ensure that the latest migrations are applied before starting the server - if err := runMigrations(app); err != nil { - panic(err) - } - - // reload app settings in case a new default value was set with a migration - // (or if this is the first time the init migration was executed) - if err := app.RefreshSettings(); err != nil { - color.Yellow("=====================================") - color.Yellow("WARNING: Settings load error! \n%v", err) - color.Yellow("Fallback to the application defaults.") - color.Yellow("=====================================") - } - - router, err := apis.InitApi(app) - if err != nil { - panic(err) - } - - // configure cors - router.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - Skipper: middleware.DefaultSkipper, - AllowOrigins: allowedOrigins, - AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete}, - })) - - // start http server - // --- - mainAddr := httpAddr - if httpsAddr != "" { - mainAddr = httpsAddr - } - - mainHost, _, _ := net.SplitHostPort(mainAddr) - - certManager := autocert.Manager{ - Prompt: autocert.AcceptTOS, - Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")), - HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost), - } - - serverConfig := &http.Server{ - TLSConfig: &tls.Config{ - GetCertificate: certManager.GetCertificate, - NextProtos: []string{acme.ALPNProto}, - }, - ReadTimeout: 60 * time.Second, - // WriteTimeout: 60 * time.Second, // breaks sse! - Handler: router, - Addr: mainAddr, - } - - if showStartBanner { - schema := "http" - if httpsAddr != "" { - schema = "https" - } - regular := color.New() - bold := color.New(color.Bold).Add(color.FgGreen) - bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr)) - regular.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr)) - regular.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr)) - } - - var serveErr error - if httpsAddr != "" { - // if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version - if httpAddr != "" { - go http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil)) - } - - // start HTTPS server - serveErr = serverConfig.ListenAndServeTLS("", "") - } else { - // start HTTP server - serveErr = serverConfig.ListenAndServe() - } - - if serveErr != http.ErrServerClosed { - log.Fatalln(serveErr) - } - }, - } - - command.PersistentFlags().StringSliceVar( - &allowedOrigins, - "origins", - []string{"*"}, - "CORS allowed domain origins list", - ) - - command.PersistentFlags().StringVar( - &httpAddr, - "http", - "127.0.0.1:8090", - "api HTTP server address", - ) - - command.PersistentFlags().StringVar( - &httpsAddr, - "https", - "", - "api HTTPS server address (auto TLS via Let's Encrypt)\nthe incoming --http address traffic also will be redirected to this address", - ) - - return command -} - -type migrationsConnection struct { - DB *dbx.DB - MigrationsList migrate.MigrationsList -} - -func runMigrations(app core.App) error { - connections := []migrationsConnection{ - { - DB: app.DB(), - MigrationsList: migrations.AppMigrations, - }, - { - DB: app.LogsDB(), - MigrationsList: logs.LogsMigrations, - }, - } - - for _, c := range connections { - runner, err := migrate.NewRunner(c.DB, c.MigrationsList) - if err != nil { - return err - } - - if _, err := runner.Up(); err != nil { - return err - } - } - - return nil -} diff --git a/cmd/temp_upgrade.go b/cmd/temp_upgrade.go deleted file mode 100644 index 3e412ee587bd5cb2a9b8078bb3a66abebf808594..0000000000000000000000000000000000000000 --- a/cmd/temp_upgrade.go +++ /dev/null @@ -1,444 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "regexp" - "strings" - - "github.com/fatih/color" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/types" - "github.com/spf13/cobra" -) - -// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes. -// -// NB! It will be removed in v0.9+ -func NewTempUpgradeCommand(app core.App) *cobra.Command { - command := &cobra.Command{ - Use: "upgrade", - Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes", - Long: ` -Upgrades your existing pb_data to be compatible with the v0.8.x changes -Prerequisites and caveats: -- already upgraded to v0.7.* -- no existing users collection -- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc. -`, - Run: func(command *cobra.Command, args []string) { - if err := upgrade(app); err != nil { - color.Red("Error: %v", err) - } - }, - } - - return command -} - -func upgrade(app core.App) error { - if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil { - return errors.New("It seems that you've already upgraded or have an existing 'users' collection.") - } - - return app.Dao().RunInTransaction(func(txDao *daos.Dao) error { - if err := migrateCollections(txDao); err != nil { - return err - } - - if err := migrateUsers(app, txDao); err != nil { - return err - } - - if err := resetMigrationsTable(txDao); err != nil { - return err - } - - bold := color.New(color.Bold).Add(color.FgGreen) - bold.Println("The pb_data upgrade completed successfully!") - bold.Println("You can now start the application as usual with the 'serve' command.") - bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.") - fmt.Println() - - return nil - }) -} - -// ------------------------------------------------------------------- - -func migrateCollections(txDao *daos.Dao) error { - // add new collection columns - if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil { - return err - } - if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil { - return err - } - - ruleReplacements := []struct { - old string - new string - }{ - {"expand", "expand2"}, - {"collecitonId", "collectionId2"}, - {"collecitonName", "collectionName2"}, - {"profile.userId", "profile.id"}, - - // @collection.* - {"@collection.profiles.userId", "@collection.users.id"}, - {"@collection.profiles.username", "@collection.users.username2"}, - {"@collection.profiles.email", "@collection.users.email2"}, - {"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"}, - {"@collection.profiles.verified", "@collection.users.verified2"}, - {"@collection.profiles.tokenKey", "@collection.users.tokenKey2"}, - {"@collection.profiles.passwordHash", "@collection.users.passwordHash2"}, - {"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"}, - {"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"}, - {"@collection.profiles.", "@collection.users."}, - - // @request.* - {"@request.user.profile.userId", "@request.auth.id"}, - {"@request.user.profile.username", "@request.auth.username2"}, - {"@request.user.profile.email", "@request.auth.email2"}, - {"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"}, - {"@request.user.profile.verified", "@request.auth.verified2"}, - {"@request.user.profile.tokenKey", "@request.auth.tokenKey2"}, - {"@request.user.profile.passwordHash", "@request.auth.passwordHash2"}, - {"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"}, - {"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"}, - {"@request.user.profile.", "@request.auth."}, - {"@request.user", "@request.auth"}, - } - - collections := []*models.Collection{} - if err := txDao.CollectionQuery().All(&collections); err != nil { - return err - } - - for _, collection := range collections { - collection.Type = models.CollectionTypeBase - collection.NormalizeOptions() - - // rename profile fields - // --- - fieldsToRename := []string{ - "collectionId", - "collectionName", - "expand", - } - if collection.Name == "profiles" { - fieldsToRename = append(fieldsToRename, - "username", - "email", - "emailVisibility", - "verified", - "tokenKey", - "passwordHash", - "lastResetSentAt", - "lastVerificationSentAt", - ) - } - for _, name := range fieldsToRename { - f := collection.Schema.GetFieldByName(name) - if f != nil { - color.Blue("[%s - renamed field]", collection.Name) - color.Yellow(" - old: %s", f.Name) - color.Green(" - new: %s2", f.Name) - fmt.Println() - f.Name += "2" - } - } - // --- - - // replace rule fields - // --- - rules := map[string]*string{ - "ListRule": collection.ListRule, - "ViewRule": collection.ViewRule, - "CreateRule": collection.CreateRule, - "UpdateRule": collection.UpdateRule, - "DeleteRule": collection.DeleteRule, - } - - for ruleKey, rule := range rules { - if rule == nil || *rule == "" { - continue - } - - originalRule := *rule - - for _, replacement := range ruleReplacements { - re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`) - *rule = re.ReplaceAllString(*rule, replacement.new) - } - - *rule = replaceReversedLikes(*rule) - - if originalRule != *rule { - color.Blue("[%s - replaced %s]:", collection.Name, ruleKey) - color.Yellow(" - old: %s", strings.TrimSpace(originalRule)) - color.Green(" - new: %s", strings.TrimSpace(*rule)) - fmt.Println() - } - } - // --- - - if err := txDao.SaveCollection(collection); err != nil { - return err - } - } - - return nil -} - -func migrateUsers(app core.App, txDao *daos.Dao) error { - color.Blue(`[merging "_users" and "profiles"]:`) - - profilesCollection, err := txDao.FindCollectionByNameOrId("profiles") - if err != nil { - return err - } - - originalProfilesCollectionId := profilesCollection.Id - - // change the profiles collection id to something else since we will be using - // it for the new users collection in order to avoid renaming the storage dir - _, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf( - `UPDATE {{_collections}} - SET id = '%s' - WHERE id = '%s'; - `, - (originalProfilesCollectionId + "__old__"), - originalProfilesCollectionId, - )).Execute() - if idRenameErr != nil { - return idRenameErr - } - - // refresh profiles collection - profilesCollection, err = txDao.FindCollectionByNameOrId("profiles") - if err != nil { - return err - } - - usersSchema, _ := profilesCollection.Schema.Clone() - userIdField := usersSchema.GetFieldByName("userId") - if userIdField != nil { - usersSchema.RemoveField(userIdField.Id) - } - - usersCollection := &models.Collection{} - usersCollection.MarkAsNew() - usersCollection.Id = originalProfilesCollectionId - usersCollection.Name = "users" - usersCollection.Type = models.CollectionTypeAuth - usersCollection.Schema = *usersSchema - usersCollection.CreateRule = types.Pointer("") - if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" { - *profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id") - usersCollection.ListRule = profilesCollection.ListRule - } - if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" { - *profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id") - usersCollection.ViewRule = profilesCollection.ViewRule - } - if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" { - *profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id") - usersCollection.UpdateRule = profilesCollection.UpdateRule - } - if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" { - *profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id") - usersCollection.DeleteRule = profilesCollection.DeleteRule - } - - // set auth options - settings := app.Settings() - authOptions := usersCollection.AuthOptions() - authOptions.ManageRule = nil - authOptions.AllowOAuth2Auth = true - authOptions.AllowUsernameAuth = false - authOptions.AllowEmailAuth = settings.EmailAuth.Enabled - authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength - authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains - authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains - // twitter currently is the only provider that doesn't return an email - authOptions.RequireEmail = !settings.TwitterAuth.Enabled - - usersCollection.SetOptions(authOptions) - - if err := txDao.SaveCollection(usersCollection); err != nil { - return err - } - - // copy the original users - _, usersErr := txDao.DB().NewQuery(` - INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt) - SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt - FROM {{_users}}; - `).Execute() - if usersErr != nil { - return usersErr - } - - // generate the profile fields copy statements - sets := []string{"id = p.id"} - for _, f := range usersSchema.Fields() { - sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name)) - } - - // copy profile fields - _, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(` - UPDATE {{users}} as u - SET %s - FROM {{profiles}} as p - WHERE u.id = p.userId; - `, strings.Join(sets, ", "))).Execute() - if copyProfileErr != nil { - return copyProfileErr - } - - profileRecords, err := txDao.FindRecordsByExpr("profiles") - if err != nil { - return err - } - - // update all profiles and users fields to point to the new users collection - collections := []*models.Collection{} - if err := txDao.CollectionQuery().All(&collections); err != nil { - return err - } - for _, collection := range collections { - var hasChanges bool - - for _, f := range collection.Schema.Fields() { - f.InitOptions() - - if f.Type == schema.FieldTypeUser { - if collection.Name == "profiles" && f.Name == "userId" { - continue - } - - hasChanges = true - - // change the user field to a relation field - options, _ := f.Options.(*schema.UserOptions) - f.Type = schema.FieldTypeRelation - f.Options = &schema.RelationOptions{ - CollectionId: usersCollection.Id, - MaxSelect: &options.MaxSelect, - CascadeDelete: options.CascadeDelete, - } - - for _, p := range profileRecords { - pId := p.Id - pUserId := p.GetString("userId") - // replace all user record id references with the profile id - _, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(` - UPDATE %s - SET [[%s]] = REPLACE([[%s]], '%s', '%s') - WHERE [[%s]] LIKE ('%%%s%%'); - `, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute() - if replaceErr != nil { - return replaceErr - } - } - } - } - - if hasChanges { - if err := txDao.Save(collection); err != nil { - return err - } - } - } - - if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil { - return err - } - - // drop _users table - if _, err := txDao.DB().DropTable("_users").Execute(); err != nil { - return err - } - - // drop profiles table - if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil { - return err - } - - // delete profiles collection - if err := txDao.Delete(profilesCollection); err != nil { - return err - } - - color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`) - fmt.Println() - - return nil -} - -func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error { - _, alterErr := txDao.DB().NewQuery(` - -- crate new externalAuths table - CREATE TABLE {{_newExternalAuths}} ( - [[id]] TEXT PRIMARY KEY, - [[collectionId]] TEXT NOT NULL, - [[recordId]] TEXT NOT NULL, - [[provider]] TEXT NOT NULL, - [[providerId]] TEXT NOT NULL, - [[created]] TEXT DEFAULT "" NOT NULL, - [[updated]] TEXT DEFAULT "" NOT NULL, - --- - FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE - ); - - -- copy all data from the old table to the new one - INSERT INTO {{_newExternalAuths}} - SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated - FROM {{_externalAuths}} auth - INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]]; - - -- drop old table - DROP TABLE {{_externalAuths}}; - - -- rename new table - ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}}; - - -- create named indexes - CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]); - CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]); - `).Execute() - - return alterErr -} - -func resetMigrationsTable(txDao *daos.Dao) error { - // reset the migration state to the new init - _, err := txDao.DB().Delete("_migrations", dbx.HashExp{ - "file": "1661586591_add_externalAuths_table.go", - }).Execute() - - return err -} - -var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`) - -func replaceReversedLikes(rule string) string { - parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1) - - for _, p := range parts { - if len(p) != 4 { - continue - } - - newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1]) - - rule = strings.ReplaceAll(rule, p[0], newPart) - } - - return rule -} diff --git a/core/app.go b/core/app.go deleted file mode 100644 index 97d5a04cfb6858c71d178ab95a3e276e5a8c39ee..0000000000000000000000000000000000000000 --- a/core/app.go +++ /dev/null @@ -1,497 +0,0 @@ -// Package core is the backbone of PocketBase. -// -// It defines the main PocketBase App interface and its base implementation. -package core - -import ( - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models/settings" - "github.com/pocketbase/pocketbase/tools/filesystem" - "github.com/pocketbase/pocketbase/tools/hook" - "github.com/pocketbase/pocketbase/tools/mailer" - "github.com/pocketbase/pocketbase/tools/store" - "github.com/pocketbase/pocketbase/tools/subscriptions" -) - -// App defines the main PocketBase app interface. -type App interface { - // DB returns the default app database instance. - DB() *dbx.DB - - // Dao returns the default app Dao instance. - // - // This Dao could operate only on the tables and models - // associated with the default app database. For example, - // trying to access the request logs table will result in error. - Dao() *daos.Dao - - // LogsDB returns the app logs database instance. - LogsDB() *dbx.DB - - // LogsDao returns the app logs Dao instance. - // - // This Dao could operate only on the tables and models - // associated with the logs database. For example, trying to access - // the users table from LogsDao will result in error. - LogsDao() *daos.Dao - - // DataDir returns the app data directory path. - DataDir() string - - // EncryptionEnv returns the name of the app secret env key - // (used for settings encryption). - EncryptionEnv() string - - // IsDebug returns whether the app is in debug mode - // (showing more detailed error logs, executed sql statements, etc.). - IsDebug() bool - - // Settings returns the loaded app settings. - Settings() *settings.Settings - - // Cache returns the app internal cache store. - Cache() *store.Store[any] - - // SubscriptionsBroker returns the app realtime subscriptions broker instance. - SubscriptionsBroker() *subscriptions.Broker - - // NewMailClient creates and returns a configured app mail client. - NewMailClient() mailer.Mailer - - // NewFilesystem creates and returns a configured filesystem.System instance. - // - // NB! Make sure to call `Close()` on the returned result - // after you are done working with it. - NewFilesystem() (*filesystem.System, error) - - // RefreshSettings reinitializes and reloads the stored application settings. - RefreshSettings() error - - // Bootstrap takes care for initializing the application - // (open db connections, load settings, etc.) - Bootstrap() error - - // ResetBootstrapState takes care for releasing initialized app resources - // (eg. closing db connections). - ResetBootstrapState() error - - // --------------------------------------------------------------- - // App event hooks - // --------------------------------------------------------------- - - // OnBeforeBootstrap hook is triggered before initializing the base - // application resources (eg. before db open and initial settings load). - OnBeforeBootstrap() *hook.Hook[*BootstrapEvent] - - // OnAfterBootstrap hook is triggered after initializing the base - // application resources (eg. after db open and initial settings load). - OnAfterBootstrap() *hook.Hook[*BootstrapEvent] - - // OnBeforeServe hook is triggered before serving the internal router (echo), - // allowing you to adjust its options and attach new routes. - OnBeforeServe() *hook.Hook[*ServeEvent] - - // OnBeforeApiError hook is triggered right before sending an error API - // response to the client, allowing you to further modify the error data - // or to return a completely different API response (using [hook.StopPropagation]). - OnBeforeApiError() *hook.Hook[*ApiErrorEvent] - - // OnAfterApiError hook is triggered right after sending an error API - // response to the client. - // It could be used to log the final API error in external services. - OnAfterApiError() *hook.Hook[*ApiErrorEvent] - - // --------------------------------------------------------------- - // Dao event hooks - // --------------------------------------------------------------- - - // OnModelBeforeCreate hook is triggered before inserting a new - // entry in the DB, allowing you to modify or validate the stored data. - OnModelBeforeCreate() *hook.Hook[*ModelEvent] - - // OnModelAfterCreate hook is triggered after successfully - // inserting a new entry in the DB. - OnModelAfterCreate() *hook.Hook[*ModelEvent] - - // OnModelBeforeUpdate hook is triggered before updating existing - // entry in the DB, allowing you to modify or validate the stored data. - OnModelBeforeUpdate() *hook.Hook[*ModelEvent] - - // OnModelAfterUpdate hook is triggered after successfully updating - // existing entry in the DB. - OnModelAfterUpdate() *hook.Hook[*ModelEvent] - - // OnModelBeforeDelete hook is triggered before deleting an - // existing entry from the DB. - OnModelBeforeDelete() *hook.Hook[*ModelEvent] - - // OnModelAfterDelete is triggered after successfully deleting an - // existing entry from the DB. - OnModelAfterDelete() *hook.Hook[*ModelEvent] - - // --------------------------------------------------------------- - // Mailer event hooks - // --------------------------------------------------------------- - - // OnMailerBeforeAdminResetPasswordSend hook is triggered right before - // sending a password reset email to an admin. - // - // Could be used to send your own custom email template if - // [hook.StopPropagation] is returned in one of its listeners. - OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] - - // OnMailerAfterAdminResetPasswordSend hook is triggered after - // admin password reset email was successfully sent. - OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] - - // OnMailerBeforeRecordResetPasswordSend hook is triggered right before - // sending a password reset email to an auth record. - // - // Could be used to send your own custom email template if - // [hook.StopPropagation] is returned in one of its listeners. - OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] - - // OnMailerAfterRecordResetPasswordSend hook is triggered after - // an auth record password reset email was successfully sent. - OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] - - // OnMailerBeforeRecordVerificationSend hook is triggered right before - // sending a verification email to an auth record. - // - // Could be used to send your own custom email template if - // [hook.StopPropagation] is returned in one of its listeners. - OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] - - // OnMailerAfterRecordVerificationSend hook is triggered after a - // verification email was successfully sent to an auth record. - OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] - - // OnMailerBeforeRecordChangeEmailSend hook is triggered right before - // sending a confirmation new address email to an auth record. - // - // Could be used to send your own custom email template if - // [hook.StopPropagation] is returned in one of its listeners. - OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] - - // OnMailerAfterRecordChangeEmailSend hook is triggered after a - // verification email was successfully sent to an auth record. - OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] - - // --------------------------------------------------------------- - // Realtime API event hooks - // --------------------------------------------------------------- - - // OnRealtimeConnectRequest hook is triggered right before establishing - // the SSE client connection. - OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent] - - // OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted - // SSE client connection. - OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] - - // OnRealtimeBeforeMessage hook is triggered right before sending - // an SSE message to a client. - // - // Returning [hook.StopPropagation] will prevent sending the message. - // Returning any other non-nil error will close the realtime connection. - OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] - - // OnRealtimeBeforeMessage hook is triggered right after sending - // an SSE message to a client. - OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] - - // OnRealtimeBeforeSubscribeRequest hook is triggered before changing - // the client subscriptions, allowing you to further validate and - // modify the submitted change. - OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] - - // OnRealtimeAfterSubscribeRequest hook is triggered after the client - // subscriptions were successfully changed. - OnRealtimeAfterSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] - - // --------------------------------------------------------------- - // Settings API event hooks - // --------------------------------------------------------------- - - // OnSettingsListRequest hook is triggered on each successful - // API Settings list request. - // - // Could be used to validate or modify the response before - // returning it to the client. - OnSettingsListRequest() *hook.Hook[*SettingsListEvent] - - // OnSettingsBeforeUpdateRequest hook is triggered before each API - // Settings update request (after request data load and before settings persistence). - // - // Could be used to additionally validate the request data or - // implement completely different persistence behavior - // (returning [hook.StopPropagation]). - OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent] - - // OnSettingsAfterUpdateRequest hook is triggered after each - // successful API Settings update request. - OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEvent] - - // --------------------------------------------------------------- - // File API event hooks - // --------------------------------------------------------------- - - // OnFileDownloadRequest hook is triggered before each API File download request. - // - // Could be used to validate or modify the file response before - // returning it to the client. - OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] - - // --------------------------------------------------------------- - // Admin API event hooks - // --------------------------------------------------------------- - - // OnAdminsListRequest hook is triggered on each API Admins list request. - // - // Could be used to validate or modify the response before returning it to the client. - OnAdminsListRequest() *hook.Hook[*AdminsListEvent] - - // OnAdminViewRequest hook is triggered on each API Admin view request. - // - // Could be used to validate or modify the response before returning it to the client. - OnAdminViewRequest() *hook.Hook[*AdminViewEvent] - - // OnAdminBeforeCreateRequest hook is triggered before each API - // Admin create request (after request data load and before model persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent] - - // OnAdminAfterCreateRequest hook is triggered after each - // successful API Admin create request. - OnAdminAfterCreateRequest() *hook.Hook[*AdminCreateEvent] - - // OnAdminBeforeUpdateRequest hook is triggered before each API - // Admin update request (after request data load and before model persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent] - - // OnAdminAfterUpdateRequest hook is triggered after each - // successful API Admin update request. - OnAdminAfterUpdateRequest() *hook.Hook[*AdminUpdateEvent] - - // OnAdminBeforeDeleteRequest hook is triggered before each API - // Admin delete request (after model load and before actual deletion). - // - // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning [hook.StopPropagation]). - OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent] - - // OnAdminAfterDeleteRequest hook is triggered after each - // successful API Admin delete request. - OnAdminAfterDeleteRequest() *hook.Hook[*AdminDeleteEvent] - - // OnAdminAuthRequest hook is triggered on each successful API Admin - // authentication request (sign-in, token refresh, etc.). - // - // Could be used to additionally validate or modify the - // authenticated admin data and token. - OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] - - // --------------------------------------------------------------- - // Record Auth API event hooks - // --------------------------------------------------------------- - - // OnRecordAuthRequest hook is triggered on each successful API - // record authentication request (sign-in, token refresh, etc.). - // - // Could be used to additionally validate or modify the authenticated - // record data and token. - OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] - - // OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record - // request password reset API request (after request data load and before sending the reset email). - // - // Could be used to additionally validate the request data or implement - // completely different password reset behavior (returning [hook.StopPropagation]). - OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] - - // OnRecordAfterRequestPasswordResetRequest hook is triggered after each - // successful request password reset API request. - OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] - - // OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record - // confirm password reset API request (after request data load and before persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] - - // OnRecordAfterConfirmPasswordResetRequest hook is triggered after each - // successful confirm password reset API request. - OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] - - // OnRecordBeforeRequestVerificationRequest hook is triggered before each Record - // request verification API request (after request data load and before sending the verification email). - // - // Could be used to additionally validate the loaded request data or implement - // completely different verification behavior (returning [hook.StopPropagation]). - OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] - - // OnRecordAfterRequestVerificationRequest hook is triggered after each - // successful request verification API request. - OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] - - // OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record - // confirm verification API request (after request data load and before persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] - - // OnRecordAfterConfirmVerificationRequest hook is triggered after each - // successful confirm verification API request. - OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] - - // OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request - // (after request data load and before sending the email link to confirm the change). - // - // Could be used to additionally validate the request data or implement - // completely different request email change behavior (returning [hook.StopPropagation]). - OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] - - // OnRecordAfterRequestEmailChangeRequest hook is triggered after each - // successful request email change API request. - OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] - - // OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record - // confirm email change API request (after request data load and before persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] - - // OnRecordAfterConfirmEmailChangeRequest hook is triggered after each - // successful confirm email change API request. - OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] - - // OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request. - // - // Could be used to validate or modify the response before returning it to the client. - OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] - - // OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record - // external auth unlink request (after models load and before the actual relation deletion). - // - // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning [hook.StopPropagation]). - OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] - - // OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each - // successful API record external auth unlink request. - OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] - - // --------------------------------------------------------------- - // Record CRUD API event hooks - // --------------------------------------------------------------- - - // OnRecordsListRequest hook is triggered on each API Records list request. - // - // Could be used to validate or modify the response before returning it to the client. - OnRecordsListRequest() *hook.Hook[*RecordsListEvent] - - // OnRecordViewRequest hook is triggered on each API Record view request. - // - // Could be used to validate or modify the response before returning it to the client. - OnRecordViewRequest() *hook.Hook[*RecordViewEvent] - - // OnRecordBeforeCreateRequest hook is triggered before each API Record - // create request (after request data load and before model persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] - - // OnRecordAfterCreateRequest hook is triggered after each - // successful API Record create request. - OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] - - // OnRecordBeforeUpdateRequest hook is triggered before each API Record - // update request (after request data load and before model persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] - - // OnRecordAfterUpdateRequest hook is triggered after each - // successful API Record update request. - OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] - - // OnRecordBeforeDeleteRequest hook is triggered before each API Record - // delete request (after model load and before actual deletion). - // - // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning [hook.StopPropagation]). - OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] - - // OnRecordAfterDeleteRequest hook is triggered after each - // successful API Record delete request. - OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] - - // --------------------------------------------------------------- - // Collection API event hooks - // --------------------------------------------------------------- - - // OnCollectionsListRequest hook is triggered on each API Collections list request. - // - // Could be used to validate or modify the response before returning it to the client. - OnCollectionsListRequest() *hook.Hook[*CollectionsListEvent] - - // OnCollectionViewRequest hook is triggered on each API Collection view request. - // - // Could be used to validate or modify the response before returning it to the client. - OnCollectionViewRequest() *hook.Hook[*CollectionViewEvent] - - // OnCollectionBeforeCreateRequest hook is triggered before each API Collection - // create request (after request data load and before model persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent] - - // OnCollectionAfterCreateRequest hook is triggered after each - // successful API Collection create request. - OnCollectionAfterCreateRequest() *hook.Hook[*CollectionCreateEvent] - - // OnCollectionBeforeUpdateRequest hook is triggered before each API Collection - // update request (after request data load and before model persistence). - // - // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning [hook.StopPropagation]). - OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent] - - // OnCollectionAfterUpdateRequest hook is triggered after each - // successful API Collection update request. - OnCollectionAfterUpdateRequest() *hook.Hook[*CollectionUpdateEvent] - - // OnCollectionBeforeDeleteRequest hook is triggered before each API - // Collection delete request (after model load and before actual deletion). - // - // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning [hook.StopPropagation]). - OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent] - - // OnCollectionAfterDeleteRequest hook is triggered after each - // successful API Collection delete request. - OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent] - - // OnCollectionsBeforeImportRequest hook is triggered before each API - // collections import request (after request data load and before the actual import). - // - // Could be used to additionally validate the imported collections or - // to implement completely different import behavior (returning [hook.StopPropagation]). - OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] - - // OnCollectionsAfterImportRequest hook is triggered after each - // successful API collections import request. - OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] -} diff --git a/core/base.go b/core/base.go deleted file mode 100644 index e7da7046437e810b82786decc1fa496bfd158266..0000000000000000000000000000000000000000 --- a/core/base.go +++ /dev/null @@ -1,853 +0,0 @@ -package core - -import ( - "context" - "database/sql" - "errors" - "log" - "os" - "path/filepath" - "time" - - "github.com/fatih/color" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/settings" - "github.com/pocketbase/pocketbase/tools/filesystem" - "github.com/pocketbase/pocketbase/tools/hook" - "github.com/pocketbase/pocketbase/tools/mailer" - "github.com/pocketbase/pocketbase/tools/store" - "github.com/pocketbase/pocketbase/tools/subscriptions" -) - -var _ App = (*BaseApp)(nil) - -// BaseApp implements core.App and defines the base PocketBase app structure. -type BaseApp struct { - // configurable parameters - isDebug bool - dataDir string - encryptionEnv string - - // internals - cache *store.Store[any] - settings *settings.Settings - db *dbx.DB - dao *daos.Dao - logsDB *dbx.DB - logsDao *daos.Dao - subscriptionsBroker *subscriptions.Broker - - // app event hooks - onBeforeBootstrap *hook.Hook[*BootstrapEvent] - onAfterBootstrap *hook.Hook[*BootstrapEvent] - onBeforeServe *hook.Hook[*ServeEvent] - onBeforeApiError *hook.Hook[*ApiErrorEvent] - onAfterApiError *hook.Hook[*ApiErrorEvent] - - // dao event hooks - onModelBeforeCreate *hook.Hook[*ModelEvent] - onModelAfterCreate *hook.Hook[*ModelEvent] - onModelBeforeUpdate *hook.Hook[*ModelEvent] - onModelAfterUpdate *hook.Hook[*ModelEvent] - onModelBeforeDelete *hook.Hook[*ModelEvent] - onModelAfterDelete *hook.Hook[*ModelEvent] - - // mailer event hooks - onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent] - onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent] - onMailerBeforeRecordResetPasswordSend *hook.Hook[*MailerRecordEvent] - onMailerAfterRecordResetPasswordSend *hook.Hook[*MailerRecordEvent] - onMailerBeforeRecordVerificationSend *hook.Hook[*MailerRecordEvent] - onMailerAfterRecordVerificationSend *hook.Hook[*MailerRecordEvent] - onMailerBeforeRecordChangeEmailSend *hook.Hook[*MailerRecordEvent] - onMailerAfterRecordChangeEmailSend *hook.Hook[*MailerRecordEvent] - - // realtime api event hooks - onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent] - onRealtimeDisconnectRequest *hook.Hook[*RealtimeDisconnectEvent] - onRealtimeBeforeMessageSend *hook.Hook[*RealtimeMessageEvent] - onRealtimeAfterMessageSend *hook.Hook[*RealtimeMessageEvent] - onRealtimeBeforeSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent] - onRealtimeAfterSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent] - - // settings api event hooks - onSettingsListRequest *hook.Hook[*SettingsListEvent] - onSettingsBeforeUpdateRequest *hook.Hook[*SettingsUpdateEvent] - onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent] - - // file api event hooks - onFileDownloadRequest *hook.Hook[*FileDownloadEvent] - - // admin api event hooks - onAdminsListRequest *hook.Hook[*AdminsListEvent] - onAdminViewRequest *hook.Hook[*AdminViewEvent] - onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent] - onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent] - onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent] - onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent] - onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent] - onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent] - onAdminAuthRequest *hook.Hook[*AdminAuthEvent] - - // record auth API event hooks - onRecordAuthRequest *hook.Hook[*RecordAuthEvent] - onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent] - onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent] - onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent] - onRecordAfterConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent] - onRecordBeforeRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent] - onRecordAfterRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent] - onRecordBeforeConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent] - onRecordAfterConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent] - onRecordBeforeRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent] - onRecordAfterRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent] - onRecordBeforeConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent] - onRecordAfterConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent] - onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent] - onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] - onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] - - // record crud API event hooks - onRecordsListRequest *hook.Hook[*RecordsListEvent] - onRecordViewRequest *hook.Hook[*RecordViewEvent] - onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent] - onRecordAfterCreateRequest *hook.Hook[*RecordCreateEvent] - onRecordBeforeUpdateRequest *hook.Hook[*RecordUpdateEvent] - onRecordAfterUpdateRequest *hook.Hook[*RecordUpdateEvent] - onRecordBeforeDeleteRequest *hook.Hook[*RecordDeleteEvent] - onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent] - - // collection API event hooks - onCollectionsListRequest *hook.Hook[*CollectionsListEvent] - onCollectionViewRequest *hook.Hook[*CollectionViewEvent] - onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent] - onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent] - onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent] - onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent] - onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent] - onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent] - onCollectionsBeforeImportRequest *hook.Hook[*CollectionsImportEvent] - onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent] -} - -// NewBaseApp creates and returns a new BaseApp instance -// configured with the provided arguments. -// -// To initialize the app, you need to call `app.Bootstrap()`. -func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp { - app := &BaseApp{ - dataDir: dataDir, - isDebug: isDebug, - encryptionEnv: encryptionEnv, - cache: store.New[any](nil), - settings: settings.New(), - subscriptionsBroker: subscriptions.NewBroker(), - - // app event hooks - onBeforeBootstrap: &hook.Hook[*BootstrapEvent]{}, - onAfterBootstrap: &hook.Hook[*BootstrapEvent]{}, - onBeforeServe: &hook.Hook[*ServeEvent]{}, - onBeforeApiError: &hook.Hook[*ApiErrorEvent]{}, - onAfterApiError: &hook.Hook[*ApiErrorEvent]{}, - - // dao event hooks - onModelBeforeCreate: &hook.Hook[*ModelEvent]{}, - onModelAfterCreate: &hook.Hook[*ModelEvent]{}, - onModelBeforeUpdate: &hook.Hook[*ModelEvent]{}, - onModelAfterUpdate: &hook.Hook[*ModelEvent]{}, - onModelBeforeDelete: &hook.Hook[*ModelEvent]{}, - onModelAfterDelete: &hook.Hook[*ModelEvent]{}, - - // mailer event hooks - onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{}, - onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{}, - onMailerBeforeRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{}, - onMailerAfterRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{}, - onMailerBeforeRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{}, - onMailerAfterRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{}, - onMailerBeforeRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{}, - onMailerAfterRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{}, - - // realtime API event hooks - onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{}, - onRealtimeDisconnectRequest: &hook.Hook[*RealtimeDisconnectEvent]{}, - onRealtimeBeforeMessageSend: &hook.Hook[*RealtimeMessageEvent]{}, - onRealtimeAfterMessageSend: &hook.Hook[*RealtimeMessageEvent]{}, - onRealtimeBeforeSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{}, - onRealtimeAfterSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{}, - - // settings API event hooks - onSettingsListRequest: &hook.Hook[*SettingsListEvent]{}, - onSettingsBeforeUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{}, - onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{}, - - // file API event hooks - onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{}, - - // admin API event hooks - onAdminsListRequest: &hook.Hook[*AdminsListEvent]{}, - onAdminViewRequest: &hook.Hook[*AdminViewEvent]{}, - onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{}, - onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{}, - onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{}, - onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{}, - onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{}, - onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{}, - onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{}, - - // record auth API event hooks - onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{}, - onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{}, - onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{}, - onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{}, - onRecordAfterConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{}, - onRecordBeforeRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{}, - onRecordAfterRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{}, - onRecordBeforeConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{}, - onRecordAfterConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{}, - onRecordBeforeRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{}, - onRecordAfterRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{}, - onRecordBeforeConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{}, - onRecordAfterConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{}, - onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{}, - onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, - onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, - - // record crud API event hooks - onRecordsListRequest: &hook.Hook[*RecordsListEvent]{}, - onRecordViewRequest: &hook.Hook[*RecordViewEvent]{}, - onRecordBeforeCreateRequest: &hook.Hook[*RecordCreateEvent]{}, - onRecordAfterCreateRequest: &hook.Hook[*RecordCreateEvent]{}, - onRecordBeforeUpdateRequest: &hook.Hook[*RecordUpdateEvent]{}, - onRecordAfterUpdateRequest: &hook.Hook[*RecordUpdateEvent]{}, - onRecordBeforeDeleteRequest: &hook.Hook[*RecordDeleteEvent]{}, - onRecordAfterDeleteRequest: &hook.Hook[*RecordDeleteEvent]{}, - - // collection API event hooks - onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{}, - onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{}, - onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, - onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, - onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, - onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, - onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, - onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, - onCollectionsBeforeImportRequest: &hook.Hook[*CollectionsImportEvent]{}, - onCollectionsAfterImportRequest: &hook.Hook[*CollectionsImportEvent]{}, - } - - app.registerDefaultHooks() - - return app -} - -// Bootstrap initializes the application -// (aka. create data dir, open db connections, load settings, etc.) -func (app *BaseApp) Bootstrap() error { - event := &BootstrapEvent{app} - - if err := app.OnBeforeBootstrap().Trigger(event); err != nil { - return err - } - - // clear resources of previous core state (if any) - if err := app.ResetBootstrapState(); err != nil { - return err - } - - // ensure that data dir exist - if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil { - return err - } - - if err := app.initDataDB(); err != nil { - return err - } - - if err := app.initLogsDB(); err != nil { - return err - } - - // we don't check for an error because the db migrations may have not been executed yet - app.RefreshSettings() - - if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() { - log.Println(err) - } - - return nil -} - -// ResetBootstrapState takes care for releasing initialized app resources -// (eg. closing db connections). -func (app *BaseApp) ResetBootstrapState() error { - if app.db != nil { - if err := app.db.Close(); err != nil { - return err - } - } - - if app.logsDB != nil { - if err := app.logsDB.Close(); err != nil { - return err - } - } - - app.dao = nil - app.logsDao = nil - app.settings = nil - - return nil -} - -// DB returns the default app database instance. -func (app *BaseApp) DB() *dbx.DB { - return app.db -} - -// Dao returns the default app Dao instance. -func (app *BaseApp) Dao() *daos.Dao { - return app.dao -} - -// LogsDB returns the app logs database instance. -func (app *BaseApp) LogsDB() *dbx.DB { - return app.logsDB -} - -// LogsDao returns the app logs Dao instance. -func (app *BaseApp) LogsDao() *daos.Dao { - return app.logsDao -} - -// DataDir returns the app data directory path. -func (app *BaseApp) DataDir() string { - return app.dataDir -} - -// EncryptionEnv returns the name of the app secret env key -// (used for settings encryption). -func (app *BaseApp) EncryptionEnv() string { - return app.encryptionEnv -} - -// IsDebug returns whether the app is in debug mode -// (showing more detailed error logs, executed sql statements, etc.). -func (app *BaseApp) IsDebug() bool { - return app.isDebug -} - -// Settings returns the loaded app settings. -func (app *BaseApp) Settings() *settings.Settings { - return app.settings -} - -// Cache returns the app internal cache store. -func (app *BaseApp) Cache() *store.Store[any] { - return app.cache -} - -// SubscriptionsBroker returns the app realtime subscriptions broker instance. -func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker { - return app.subscriptionsBroker -} - -// NewMailClient creates and returns a new SMTP or Sendmail client -// based on the current app settings. -func (app *BaseApp) NewMailClient() mailer.Mailer { - if app.Settings().Smtp.Enabled { - return mailer.NewSmtpClient( - app.Settings().Smtp.Host, - app.Settings().Smtp.Port, - app.Settings().Smtp.Username, - app.Settings().Smtp.Password, - app.Settings().Smtp.Tls, - ) - } - - return &mailer.Sendmail{} -} - -// NewFilesystem creates a new local or S3 filesystem instance -// based on the current app settings. -// -// NB! Make sure to call `Close()` on the returned result -// after you are done working with it. -func (app *BaseApp) NewFilesystem() (*filesystem.System, error) { - if app.settings.S3.Enabled { - return filesystem.NewS3( - app.settings.S3.Bucket, - app.settings.S3.Region, - app.settings.S3.Endpoint, - app.settings.S3.AccessKey, - app.settings.S3.Secret, - app.settings.S3.ForcePathStyle, - ) - } - - // fallback to local filesystem - return filesystem.NewLocal(filepath.Join(app.DataDir(), "storage")) -} - -// RefreshSettings reinitializes and reloads the stored application settings. -func (app *BaseApp) RefreshSettings() error { - if app.settings == nil { - app.settings = settings.New() - } - - encryptionKey := os.Getenv(app.EncryptionEnv()) - - storedSettings, err := app.Dao().FindSettings(encryptionKey) - if err != nil && err != sql.ErrNoRows { - return err - } - - // no settings were previously stored - if storedSettings == nil { - return app.Dao().SaveSettings(app.settings, encryptionKey) - } - - // load the settings from the stored param into the app ones - if err := app.settings.Merge(storedSettings); err != nil { - return err - } - - return nil -} - -// ------------------------------------------------------------------- -// App event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnBeforeBootstrap() *hook.Hook[*BootstrapEvent] { - return app.onBeforeBootstrap -} - -func (app *BaseApp) OnAfterBootstrap() *hook.Hook[*BootstrapEvent] { - return app.onAfterBootstrap -} - -func (app *BaseApp) OnBeforeServe() *hook.Hook[*ServeEvent] { - return app.onBeforeServe -} - -func (app *BaseApp) OnBeforeApiError() *hook.Hook[*ApiErrorEvent] { - return app.onBeforeApiError -} - -func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] { - return app.onAfterApiError -} - -// ------------------------------------------------------------------- -// Dao event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnModelBeforeCreate() *hook.Hook[*ModelEvent] { - return app.onModelBeforeCreate -} - -func (app *BaseApp) OnModelAfterCreate() *hook.Hook[*ModelEvent] { - return app.onModelAfterCreate -} - -func (app *BaseApp) OnModelBeforeUpdate() *hook.Hook[*ModelEvent] { - return app.onModelBeforeUpdate -} - -func (app *BaseApp) OnModelAfterUpdate() *hook.Hook[*ModelEvent] { - return app.onModelAfterUpdate -} - -func (app *BaseApp) OnModelBeforeDelete() *hook.Hook[*ModelEvent] { - return app.onModelBeforeDelete -} - -func (app *BaseApp) OnModelAfterDelete() *hook.Hook[*ModelEvent] { - return app.onModelAfterDelete -} - -// ------------------------------------------------------------------- -// Mailer event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] { - return app.onMailerBeforeAdminResetPasswordSend -} - -func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] { - return app.onMailerAfterAdminResetPasswordSend -} - -func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] { - return app.onMailerBeforeRecordResetPasswordSend -} - -func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] { - return app.onMailerAfterRecordResetPasswordSend -} - -func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] { - return app.onMailerBeforeRecordVerificationSend -} - -func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] { - return app.onMailerAfterRecordVerificationSend -} - -func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] { - return app.onMailerBeforeRecordChangeEmailSend -} - -func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] { - return app.onMailerAfterRecordChangeEmailSend -} - -// ------------------------------------------------------------------- -// Realtime API event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent] { - return app.onRealtimeConnectRequest -} - -func (app *BaseApp) OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] { - return app.onRealtimeDisconnectRequest -} - -func (app *BaseApp) OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] { - return app.onRealtimeBeforeMessageSend -} - -func (app *BaseApp) OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] { - return app.onRealtimeAfterMessageSend -} - -func (app *BaseApp) OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] { - return app.onRealtimeBeforeSubscribeRequest -} - -func (app *BaseApp) OnRealtimeAfterSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] { - return app.onRealtimeAfterSubscribeRequest -} - -// ------------------------------------------------------------------- -// Settings API event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnSettingsListRequest() *hook.Hook[*SettingsListEvent] { - return app.onSettingsListRequest -} - -func (app *BaseApp) OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent] { - return app.onSettingsBeforeUpdateRequest -} - -func (app *BaseApp) OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEvent] { - return app.onSettingsAfterUpdateRequest -} - -// ------------------------------------------------------------------- -// File API event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] { - return app.onFileDownloadRequest -} - -// ------------------------------------------------------------------- -// Admin API event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnAdminsListRequest() *hook.Hook[*AdminsListEvent] { - return app.onAdminsListRequest -} - -func (app *BaseApp) OnAdminViewRequest() *hook.Hook[*AdminViewEvent] { - return app.onAdminViewRequest -} - -func (app *BaseApp) OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent] { - return app.onAdminBeforeCreateRequest -} - -func (app *BaseApp) OnAdminAfterCreateRequest() *hook.Hook[*AdminCreateEvent] { - return app.onAdminAfterCreateRequest -} - -func (app *BaseApp) OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent] { - return app.onAdminBeforeUpdateRequest -} - -func (app *BaseApp) OnAdminAfterUpdateRequest() *hook.Hook[*AdminUpdateEvent] { - return app.onAdminAfterUpdateRequest -} - -func (app *BaseApp) OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent] { - return app.onAdminBeforeDeleteRequest -} - -func (app *BaseApp) OnAdminAfterDeleteRequest() *hook.Hook[*AdminDeleteEvent] { - return app.onAdminAfterDeleteRequest -} - -func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] { - return app.onAdminAuthRequest -} - -// ------------------------------------------------------------------- -// Record auth API event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] { - return app.onRecordAuthRequest -} - -func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] { - return app.onRecordBeforeRequestPasswordResetRequest -} - -func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] { - return app.onRecordAfterRequestPasswordResetRequest -} - -func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] { - return app.onRecordBeforeConfirmPasswordResetRequest -} - -func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] { - return app.onRecordAfterConfirmPasswordResetRequest -} - -func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] { - return app.onRecordBeforeRequestVerificationRequest -} - -func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] { - return app.onRecordAfterRequestVerificationRequest -} - -func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] { - return app.onRecordBeforeConfirmVerificationRequest -} - -func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] { - return app.onRecordAfterConfirmVerificationRequest -} - -func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] { - return app.onRecordBeforeRequestEmailChangeRequest -} - -func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] { - return app.onRecordAfterRequestEmailChangeRequest -} - -func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] { - return app.onRecordBeforeConfirmEmailChangeRequest -} - -func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] { - return app.onRecordAfterConfirmEmailChangeRequest -} - -func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] { - return app.onRecordListExternalAuthsRequest -} - -func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] { - return app.onRecordBeforeUnlinkExternalAuthRequest -} - -func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] { - return app.onRecordAfterUnlinkExternalAuthRequest -} - -// ------------------------------------------------------------------- -// Record CRUD API event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] { - return app.onRecordsListRequest -} - -func (app *BaseApp) OnRecordViewRequest() *hook.Hook[*RecordViewEvent] { - return app.onRecordViewRequest -} - -func (app *BaseApp) OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] { - return app.onRecordBeforeCreateRequest -} - -func (app *BaseApp) OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] { - return app.onRecordAfterCreateRequest -} - -func (app *BaseApp) OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] { - return app.onRecordBeforeUpdateRequest -} - -func (app *BaseApp) OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] { - return app.onRecordAfterUpdateRequest -} - -func (app *BaseApp) OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] { - return app.onRecordBeforeDeleteRequest -} - -func (app *BaseApp) OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] { - return app.onRecordAfterDeleteRequest -} - -// ------------------------------------------------------------------- -// Collection API event hooks -// ------------------------------------------------------------------- - -func (app *BaseApp) OnCollectionsListRequest() *hook.Hook[*CollectionsListEvent] { - return app.onCollectionsListRequest -} - -func (app *BaseApp) OnCollectionViewRequest() *hook.Hook[*CollectionViewEvent] { - return app.onCollectionViewRequest -} - -func (app *BaseApp) OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent] { - return app.onCollectionBeforeCreateRequest -} - -func (app *BaseApp) OnCollectionAfterCreateRequest() *hook.Hook[*CollectionCreateEvent] { - return app.onCollectionAfterCreateRequest -} - -func (app *BaseApp) OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent] { - return app.onCollectionBeforeUpdateRequest -} - -func (app *BaseApp) OnCollectionAfterUpdateRequest() *hook.Hook[*CollectionUpdateEvent] { - return app.onCollectionAfterUpdateRequest -} - -func (app *BaseApp) OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent] { - return app.onCollectionBeforeDeleteRequest -} - -func (app *BaseApp) OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent] { - return app.onCollectionAfterDeleteRequest -} - -func (app *BaseApp) OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] { - return app.onCollectionsBeforeImportRequest -} - -func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] { - return app.onCollectionsAfterImportRequest -} - -// ------------------------------------------------------------------- -// Helpers -// ------------------------------------------------------------------- - -func (app *BaseApp) initLogsDB() error { - var connectErr error - app.logsDB, connectErr = connectDB(filepath.Join(app.DataDir(), "logs.db")) - if connectErr != nil { - return connectErr - } - - app.logsDao = daos.New(app.logsDB) - - return nil -} - -func (app *BaseApp) initDataDB() error { - var connectErr error - app.db, connectErr = connectDB(filepath.Join(app.DataDir(), "data.db")) - if connectErr != nil { - return connectErr - } - - if app.IsDebug() { - app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { - color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql) - } - - app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { - color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql) - } - } - - app.dao = app.createDaoWithHooks(app.db) - - return nil -} - -func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao { - dao := daos.New(db) - - dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error { - return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m}) - } - - dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) { - err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m}) - if err != nil && app.isDebug { - log.Println(err) - } - } - - dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error { - return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m}) - } - - dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) { - err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m}) - if err != nil && app.isDebug { - log.Println(err) - } - } - - dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error { - return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m}) - } - - dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) { - err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m}) - if err != nil && app.isDebug { - log.Println(err) - } - } - - return dao -} - -func (app *BaseApp) registerDefaultHooks() { - deletePrefix := func(prefix string) error { - fs, err := app.NewFilesystem() - if err != nil { - return err - } - defer fs.Close() - - failed := fs.DeletePrefix(prefix) - if len(failed) > 0 { - return errors.New("Failed to delete the files at " + prefix) - } - - return nil - } - - // delete storage files from deleted Collection, Records, etc. - app.OnModelAfterDelete().Add(func(e *ModelEvent) error { - if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" { - if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() { - // non critical error - only log for debug - // (usually could happen because of S3 api limits) - log.Println(err) - } - } - - return nil - }) -} diff --git a/core/base_settings_test.go b/core/base_settings_test.go deleted file mode 100644 index 08c515847bc3082e0d4fd1177ae6daaefbfc2090..0000000000000000000000000000000000000000 --- a/core/base_settings_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package core_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/types" -) - -func TestBaseAppRefreshSettings(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // cleanup all stored settings - if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil { - t.Fatalf("Failed to delete all test settings: %v", err) - } - - // check if the new settings are saved in the db - app.ResetEventCalls() - if err := app.RefreshSettings(); err != nil { - t.Fatal("Failed to refresh the settings after delete") - } - testEventCalls(t, app, map[string]int{ - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - }) - param, err := app.Dao().FindParamByKey(models.ParamAppSettings) - if err != nil { - t.Fatalf("Expected new settings to be persisted, got %v", err) - } - - // change the db entry and refresh the app settings (ensure that there was no db update) - param.Value = types.JsonRaw([]byte(`{"example": 123}`)) - if err := app.Dao().SaveParam(param.Key, param.Value); err != nil { - t.Fatalf("Failed to update the test settings: %v", err) - } - app.ResetEventCalls() - if err := app.RefreshSettings(); err != nil { - t.Fatalf("Failed to refresh the app settings: %v", err) - } - testEventCalls(t, app, nil) - - // try to refresh again without doing any changes - app.ResetEventCalls() - if err := app.RefreshSettings(); err != nil { - t.Fatalf("Failed to refresh the app settings without change: %v", err) - } - testEventCalls(t, app, nil) -} - -func testEventCalls(t *testing.T, app *tests.TestApp, events map[string]int) { - if len(events) != len(app.EventCalls) { - t.Fatalf("Expected events doesn't match: \n%v, \ngot \n%v", events, app.EventCalls) - } - - for name, total := range events { - if v, ok := app.EventCalls[name]; !ok || v != total { - t.Fatalf("Expected events doesn't exist or match: \n%v, \ngot \n%v", events, app.EventCalls) - } - } -} diff --git a/core/base_test.go b/core/base_test.go deleted file mode 100644 index 81ef9117dfb7cde37f2122b53b894d6efee12ade..0000000000000000000000000000000000000000 --- a/core/base_test.go +++ /dev/null @@ -1,442 +0,0 @@ -package core - -import ( - "os" - "testing" - - "github.com/pocketbase/pocketbase/tools/mailer" -) - -func TestNewBaseApp(t *testing.T) { - const testDataDir = "./pb_base_app_test_data_dir/" - defer os.RemoveAll(testDataDir) - - app := NewBaseApp(testDataDir, "test_env", true) - - if app.dataDir != testDataDir { - t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir) - } - - if app.encryptionEnv != "test_env" { - t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir) - } - - if !app.isDebug { - t.Fatalf("expected isDebug true, got %v", app.isDebug) - } - - if app.cache == nil { - t.Fatal("expected cache to be set, got nil") - } - - if app.settings == nil { - t.Fatal("expected settings to be set, got nil") - } - - if app.subscriptionsBroker == nil { - t.Fatal("expected subscriptionsBroker to be set, got nil") - } -} - -func TestBaseAppBootstrap(t *testing.T) { - const testDataDir = "./pb_base_app_test_data_dir/" - defer os.RemoveAll(testDataDir) - - app := NewBaseApp(testDataDir, "pb_test_env", false) - defer app.ResetBootstrapState() - - // bootstrap - if err := app.Bootstrap(); err != nil { - t.Fatal(err) - } - - if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() { - t.Fatal("Expected test data directory to be created.") - } - - if app.dao == nil { - t.Fatal("Expected app.dao to be initialized, got nil.") - } - - if app.dao.BeforeCreateFunc == nil { - t.Fatal("Expected app.dao.BeforeCreateFunc to be set, got nil.") - } - - if app.dao.AfterCreateFunc == nil { - t.Fatal("Expected app.dao.AfterCreateFunc to be set, got nil.") - } - - if app.dao.BeforeUpdateFunc == nil { - t.Fatal("Expected app.dao.BeforeUpdateFunc to be set, got nil.") - } - - if app.dao.AfterUpdateFunc == nil { - t.Fatal("Expected app.dao.AfterUpdateFunc to be set, got nil.") - } - - if app.dao.BeforeDeleteFunc == nil { - t.Fatal("Expected app.dao.BeforeDeleteFunc to be set, got nil.") - } - - if app.dao.AfterDeleteFunc == nil { - t.Fatal("Expected app.dao.AfterDeleteFunc to be set, got nil.") - } - - if app.logsDao == nil { - t.Fatal("Expected app.logsDao to be initialized, got nil.") - } - - if app.settings == nil { - t.Fatal("Expected app.settings to be initialized, got nil.") - } - - // reset - if err := app.ResetBootstrapState(); err != nil { - t.Fatal(err) - } - - if app.dao != nil { - t.Fatalf("Expected app.dao to be nil, got %v.", app.dao) - } - - if app.logsDao != nil { - t.Fatalf("Expected app.logsDao to be nil, got %v.", app.logsDao) - } - - if app.settings != nil { - t.Fatalf("Expected app.settings to be nil, got %v.", app.settings) - } -} - -func TestBaseAppGetters(t *testing.T) { - const testDataDir = "./pb_base_app_test_data_dir/" - defer os.RemoveAll(testDataDir) - - app := NewBaseApp(testDataDir, "pb_test_env", false) - defer app.ResetBootstrapState() - - if err := app.Bootstrap(); err != nil { - t.Fatal(err) - } - - if app.db != app.DB() { - t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.db) - } - - if app.dao != app.Dao() { - t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao) - } - - if app.logsDB != app.LogsDB() { - t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDB) - } - - if app.logsDao != app.LogsDao() { - t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao) - } - - if app.dataDir != app.DataDir() { - t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir) - } - - if app.encryptionEnv != app.EncryptionEnv() { - t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv) - } - - if app.isDebug != app.IsDebug() { - t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug) - } - - if app.settings != app.Settings() { - t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings) - } - - if app.cache != app.Cache() { - t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache) - } - - if app.subscriptionsBroker != app.SubscriptionsBroker() { - t.Fatalf("Expected app.SubscriptionsBroker %v, got %v", app.SubscriptionsBroker(), app.subscriptionsBroker) - } - - if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil { - t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe) - } - - if app.onModelBeforeCreate != app.OnModelBeforeCreate() || app.OnModelBeforeCreate() == nil { - t.Fatalf("Getter app.OnModelBeforeCreate does not match or nil (%v vs %v)", app.OnModelBeforeCreate(), app.onModelBeforeCreate) - } - - if app.onModelAfterCreate != app.OnModelAfterCreate() || app.OnModelAfterCreate() == nil { - t.Fatalf("Getter app.OnModelAfterCreate does not match or nil (%v vs %v)", app.OnModelAfterCreate(), app.onModelAfterCreate) - } - - if app.onModelBeforeUpdate != app.OnModelBeforeUpdate() || app.OnModelBeforeUpdate() == nil { - t.Fatalf("Getter app.OnModelBeforeUpdate does not match or nil (%v vs %v)", app.OnModelBeforeUpdate(), app.onModelBeforeUpdate) - } - - if app.onModelAfterUpdate != app.OnModelAfterUpdate() || app.OnModelAfterUpdate() == nil { - t.Fatalf("Getter app.OnModelAfterUpdate does not match or nil (%v vs %v)", app.OnModelAfterUpdate(), app.onModelAfterUpdate) - } - - if app.onModelBeforeDelete != app.OnModelBeforeDelete() || app.OnModelBeforeDelete() == nil { - t.Fatalf("Getter app.OnModelBeforeDelete does not match or nil (%v vs %v)", app.OnModelBeforeDelete(), app.onModelBeforeDelete) - } - - if app.onModelAfterDelete != app.OnModelAfterDelete() || app.OnModelAfterDelete() == nil { - t.Fatalf("Getter app.OnModelAfterDelete does not match or nil (%v vs %v)", app.OnModelAfterDelete(), app.onModelAfterDelete) - } - - if app.onMailerBeforeAdminResetPasswordSend != app.OnMailerBeforeAdminResetPasswordSend() || app.OnMailerBeforeAdminResetPasswordSend() == nil { - t.Fatalf("Getter app.OnMailerBeforeAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeAdminResetPasswordSend(), app.onMailerBeforeAdminResetPasswordSend) - } - - if app.onMailerAfterAdminResetPasswordSend != app.OnMailerAfterAdminResetPasswordSend() || app.OnMailerAfterAdminResetPasswordSend() == nil { - t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend) - } - - if app.onMailerBeforeRecordResetPasswordSend != app.OnMailerBeforeRecordResetPasswordSend() || app.OnMailerBeforeRecordResetPasswordSend() == nil { - t.Fatalf("Getter app.OnMailerBeforeRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordResetPasswordSend(), app.onMailerBeforeRecordResetPasswordSend) - } - - if app.onMailerAfterRecordResetPasswordSend != app.OnMailerAfterRecordResetPasswordSend() || app.OnMailerAfterRecordResetPasswordSend() == nil { - t.Fatalf("Getter app.OnMailerAfterRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordResetPasswordSend(), app.onMailerAfterRecordResetPasswordSend) - } - - if app.onMailerBeforeRecordVerificationSend != app.OnMailerBeforeRecordVerificationSend() || app.OnMailerBeforeRecordVerificationSend() == nil { - t.Fatalf("Getter app.OnMailerBeforeRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordVerificationSend(), app.onMailerBeforeRecordVerificationSend) - } - - if app.onMailerAfterRecordVerificationSend != app.OnMailerAfterRecordVerificationSend() || app.OnMailerAfterRecordVerificationSend() == nil { - t.Fatalf("Getter app.OnMailerAfterRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordVerificationSend(), app.onMailerAfterRecordVerificationSend) - } - - if app.onMailerBeforeRecordChangeEmailSend != app.OnMailerBeforeRecordChangeEmailSend() || app.OnMailerBeforeRecordChangeEmailSend() == nil { - t.Fatalf("Getter app.OnMailerBeforeRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordChangeEmailSend(), app.onMailerBeforeRecordChangeEmailSend) - } - - if app.onMailerAfterRecordChangeEmailSend != app.OnMailerAfterRecordChangeEmailSend() || app.OnMailerAfterRecordChangeEmailSend() == nil { - t.Fatalf("Getter app.OnMailerAfterRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordChangeEmailSend(), app.onMailerAfterRecordChangeEmailSend) - } - - if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil { - t.Fatalf("Getter app.OnRealtimeConnectRequest does not match or nil (%v vs %v)", app.OnRealtimeConnectRequest(), app.onRealtimeConnectRequest) - } - - if app.onRealtimeBeforeSubscribeRequest != app.OnRealtimeBeforeSubscribeRequest() || app.OnRealtimeBeforeSubscribeRequest() == nil { - t.Fatalf("Getter app.OnRealtimeBeforeSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeBeforeSubscribeRequest(), app.onRealtimeBeforeSubscribeRequest) - } - - if app.onRealtimeAfterSubscribeRequest != app.OnRealtimeAfterSubscribeRequest() || app.OnRealtimeAfterSubscribeRequest() == nil { - t.Fatalf("Getter app.OnRealtimeAfterSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeAfterSubscribeRequest(), app.onRealtimeAfterSubscribeRequest) - } - - if app.onSettingsListRequest != app.OnSettingsListRequest() || app.OnSettingsListRequest() == nil { - t.Fatalf("Getter app.OnSettingsListRequest does not match or nil (%v vs %v)", app.OnSettingsListRequest(), app.onSettingsListRequest) - } - - if app.onSettingsBeforeUpdateRequest != app.OnSettingsBeforeUpdateRequest() || app.OnSettingsBeforeUpdateRequest() == nil { - t.Fatalf("Getter app.OnSettingsBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsBeforeUpdateRequest(), app.onSettingsBeforeUpdateRequest) - } - - if app.onSettingsAfterUpdateRequest != app.OnSettingsAfterUpdateRequest() || app.OnSettingsAfterUpdateRequest() == nil { - t.Fatalf("Getter app.OnSettingsAfterUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsAfterUpdateRequest(), app.onSettingsAfterUpdateRequest) - } - - if app.onFileDownloadRequest != app.OnFileDownloadRequest() || app.OnFileDownloadRequest() == nil { - t.Fatalf("Getter app.OnFileDownloadRequest does not match or nil (%v vs %v)", app.OnFileDownloadRequest(), app.onFileDownloadRequest) - } - - if app.onAdminsListRequest != app.OnAdminsListRequest() || app.OnAdminsListRequest() == nil { - t.Fatalf("Getter app.OnAdminsListRequest does not match or nil (%v vs %v)", app.OnAdminsListRequest(), app.onAdminsListRequest) - } - - if app.onAdminViewRequest != app.OnAdminViewRequest() || app.OnAdminViewRequest() == nil { - t.Fatalf("Getter app.OnAdminViewRequest does not match or nil (%v vs %v)", app.OnAdminViewRequest(), app.onAdminViewRequest) - } - - if app.onAdminBeforeCreateRequest != app.OnAdminBeforeCreateRequest() || app.OnAdminBeforeCreateRequest() == nil { - t.Fatalf("Getter app.OnAdminBeforeCreateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeCreateRequest(), app.onAdminBeforeCreateRequest) - } - - if app.onAdminAfterCreateRequest != app.OnAdminAfterCreateRequest() || app.OnAdminAfterCreateRequest() == nil { - t.Fatalf("Getter app.OnAdminAfterCreateRequest does not match or nil (%v vs %v)", app.OnAdminAfterCreateRequest(), app.onAdminAfterCreateRequest) - } - - if app.onAdminBeforeUpdateRequest != app.OnAdminBeforeUpdateRequest() || app.OnAdminBeforeUpdateRequest() == nil { - t.Fatalf("Getter app.OnAdminBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeUpdateRequest(), app.onAdminBeforeUpdateRequest) - } - - if app.onAdminAfterUpdateRequest != app.OnAdminAfterUpdateRequest() || app.OnAdminAfterUpdateRequest() == nil { - t.Fatalf("Getter app.OnAdminAfterUpdateRequest does not match or nil (%v vs %v)", app.OnAdminAfterUpdateRequest(), app.onAdminAfterUpdateRequest) - } - - if app.onAdminBeforeDeleteRequest != app.OnAdminBeforeDeleteRequest() || app.OnAdminBeforeDeleteRequest() == nil { - t.Fatalf("Getter app.OnAdminBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnAdminBeforeDeleteRequest(), app.onAdminBeforeDeleteRequest) - } - - if app.onAdminAfterDeleteRequest != app.OnAdminAfterDeleteRequest() || app.OnAdminAfterDeleteRequest() == nil { - t.Fatalf("Getter app.OnAdminAfterDeleteRequest does not match or nil (%v vs %v)", app.OnAdminAfterDeleteRequest(), app.onAdminAfterDeleteRequest) - } - - if app.onAdminAuthRequest != app.OnAdminAuthRequest() || app.OnAdminAuthRequest() == nil { - t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest) - } - - if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil { - t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest) - } - - if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil { - t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest) - } - - if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil { - t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest) - } - - if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil { - t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest) - } - - if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil { - t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest) - } - - if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil { - t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest) - } - - if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil { - t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest) - } - - if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil { - t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest) - } - - if app.onRecordAuthRequest != app.OnRecordAuthRequest() || app.OnRecordAuthRequest() == nil { - t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest) - } - - if app.onRecordListExternalAuthsRequest != app.OnRecordListExternalAuthsRequest() || app.OnRecordListExternalAuthsRequest() == nil { - t.Fatalf("Getter app.OnRecordListExternalAuthsRequest does not match or nil (%v vs %v)", app.OnRecordListExternalAuthsRequest(), app.onRecordListExternalAuthsRequest) - } - - if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil { - t.Fatalf("Getter app.OnRecordBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUnlinkExternalAuthRequest(), app.onRecordBeforeUnlinkExternalAuthRequest) - } - - if app.onRecordAfterUnlinkExternalAuthRequest != app.OnRecordAfterUnlinkExternalAuthRequest() || app.OnRecordAfterUnlinkExternalAuthRequest() == nil { - t.Fatalf("Getter app.OnRecordAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordAfterUnlinkExternalAuthRequest(), app.onRecordAfterUnlinkExternalAuthRequest) - } - - if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil { - t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest) - } - - if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil { - t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest) - } - - if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil { - t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest) - } - - if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil { - t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest) - } - - if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil { - t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest) - } - - if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil { - t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest) - } - - if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil { - t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest) - } - - if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil { - t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest) - } - - if app.onCollectionsListRequest != app.OnCollectionsListRequest() || app.OnCollectionsListRequest() == nil { - t.Fatalf("Getter app.OnCollectionsListRequest does not match or nil (%v vs %v)", app.OnCollectionsListRequest(), app.onCollectionsListRequest) - } - - if app.onCollectionViewRequest != app.OnCollectionViewRequest() || app.OnCollectionViewRequest() == nil { - t.Fatalf("Getter app.OnCollectionViewRequest does not match or nil (%v vs %v)", app.OnCollectionViewRequest(), app.onCollectionViewRequest) - } - - if app.onCollectionBeforeCreateRequest != app.OnCollectionBeforeCreateRequest() || app.OnCollectionBeforeCreateRequest() == nil { - t.Fatalf("Getter app.OnCollectionBeforeCreateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeCreateRequest(), app.onCollectionBeforeCreateRequest) - } - - if app.onCollectionAfterCreateRequest != app.OnCollectionAfterCreateRequest() || app.OnCollectionAfterCreateRequest() == nil { - t.Fatalf("Getter app.OnCollectionAfterCreateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterCreateRequest(), app.onCollectionAfterCreateRequest) - } - - if app.onCollectionBeforeUpdateRequest != app.OnCollectionBeforeUpdateRequest() || app.OnCollectionBeforeUpdateRequest() == nil { - t.Fatalf("Getter app.OnCollectionBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeUpdateRequest(), app.onCollectionBeforeUpdateRequest) - } - - if app.onCollectionAfterUpdateRequest != app.OnCollectionAfterUpdateRequest() || app.OnCollectionAfterUpdateRequest() == nil { - t.Fatalf("Getter app.OnCollectionAfterUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterUpdateRequest(), app.onCollectionAfterUpdateRequest) - } - - if app.onCollectionBeforeDeleteRequest != app.OnCollectionBeforeDeleteRequest() || app.OnCollectionBeforeDeleteRequest() == nil { - t.Fatalf("Getter app.OnCollectionBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeDeleteRequest(), app.onCollectionBeforeDeleteRequest) - } - - if app.onCollectionAfterDeleteRequest != app.OnCollectionAfterDeleteRequest() || app.OnCollectionAfterDeleteRequest() == nil { - t.Fatalf("Getter app.OnCollectionAfterDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionAfterDeleteRequest(), app.onCollectionAfterDeleteRequest) - } -} - -func TestBaseAppNewMailClient(t *testing.T) { - const testDataDir = "./pb_base_app_test_data_dir/" - defer os.RemoveAll(testDataDir) - - app := NewBaseApp(testDataDir, "pb_test_env", false) - - client1 := app.NewMailClient() - if val, ok := client1.(*mailer.Sendmail); !ok { - t.Fatalf("Expected mailer.Sendmail instance, got %v", val) - } - - app.Settings().Smtp.Enabled = true - - client2 := app.NewMailClient() - if val, ok := client2.(*mailer.SmtpClient); !ok { - t.Fatalf("Expected mailer.SmtpClient instance, got %v", val) - } -} - -func TestBaseAppNewFilesystem(t *testing.T) { - const testDataDir = "./pb_base_app_test_data_dir/" - defer os.RemoveAll(testDataDir) - - app := NewBaseApp(testDataDir, "pb_test_env", false) - - // local - local, localErr := app.NewFilesystem() - if localErr != nil { - t.Fatal(localErr) - } - if local == nil { - t.Fatal("Expected local filesystem instance, got nil") - } - - // misconfigured s3 - app.Settings().S3.Enabled = true - s3, s3Err := app.NewFilesystem() - if s3Err == nil { - t.Fatal("Expected S3 error, got nil") - } - if s3 != nil { - t.Fatalf("Expected nil s3 filesystem, got %v", s3) - } -} diff --git a/core/db_cgo.go b/core/db_cgo.go deleted file mode 100644 index 42118fa6ac728d6f3af67c528cd3298c9cc87f2e..0000000000000000000000000000000000000000 --- a/core/db_cgo.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build cgo - -package core - -import ( - "fmt" - "time" - - "github.com/pocketbase/dbx" - _ "github.com/mattn/go-sqlite3" -) - -func connectDB(dbPath string) (*dbx.DB, error) { - // note: the busy_timeout pragma must be first because - // the connection needs to be set to block on busy before WAL mode - // is set in case it hasn't been already set by another connection - pragmas := "_busy_timeout=10000&_journal_mode=WAL&_foreign_keys=1&_synchronous=NORMAL" - - db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas)) - if openErr != nil { - return nil, openErr - } - - // use a fixed connection pool to limit the SQLITE_BUSY errors - // and reduce the open file descriptors - // (the limits are arbitrary and may change in the future) - db.DB().SetMaxOpenConns(1000) - db.DB().SetMaxIdleConns(30) - db.DB().SetConnMaxIdleTime(5 * time.Minute) - - // additional pragmas not supported through the dsn string - _, err := db.NewQuery(` - pragma journal_size_limit = 100000000; - `).Execute() - - return db, err -} diff --git a/core/db_nocgo.go b/core/db_nocgo.go deleted file mode 100644 index ba35082dc875f4d673ac6a87c0c07c9e7c6be1f2..0000000000000000000000000000000000000000 --- a/core/db_nocgo.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:build !cgo - -package core - -import ( - "fmt" - "time" - - "github.com/pocketbase/dbx" - _ "modernc.org/sqlite" -) - -func connectDB(dbPath string) (*dbx.DB, error) { - // note: the busy_timeout pragma must be first because - // the connection needs to be set to block on busy before WAL mode - // is set in case it hasn't been already set by another connection - pragmas := "_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_pragma=journal_size_limit(100000000)" - - db, err := dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas)) - if err != nil { - return nil, err - } - - // use a fixed connection pool to limit the SQLITE_BUSY errors and - // reduce the open file descriptors - // (the limits are arbitrary and may change in the future) - // - // @see https://gitlab.com/cznic/sqlite/-/issues/115 - db.DB().SetMaxOpenConns(1000) - db.DB().SetMaxIdleConns(30) - db.DB().SetConnMaxIdleTime(5 * time.Minute) - - return db, nil -} diff --git a/core/events.go b/core/events.go deleted file mode 100644 index 62bf968d749807ddcb8f6af054228e84a7474922..0000000000000000000000000000000000000000 --- a/core/events.go +++ /dev/null @@ -1,267 +0,0 @@ -package core - -import ( - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/models/settings" - "github.com/pocketbase/pocketbase/tools/mailer" - "github.com/pocketbase/pocketbase/tools/search" - "github.com/pocketbase/pocketbase/tools/subscriptions" - - "github.com/labstack/echo/v5" -) - -// ------------------------------------------------------------------- -// Serve events data -// ------------------------------------------------------------------- - -type BootstrapEvent struct { - App App -} - -type ServeEvent struct { - App App - Router *echo.Echo -} - -type ApiErrorEvent struct { - HttpContext echo.Context - Error error -} - -// ------------------------------------------------------------------- -// Model DAO events data -// ------------------------------------------------------------------- - -type ModelEvent struct { - Dao *daos.Dao - Model models.Model -} - -// ------------------------------------------------------------------- -// Mailer events data -// ------------------------------------------------------------------- - -type MailerRecordEvent struct { - MailClient mailer.Mailer - Message *mailer.Message - Record *models.Record - Meta map[string]any -} - -type MailerAdminEvent struct { - MailClient mailer.Mailer - Message *mailer.Message - Admin *models.Admin - Meta map[string]any -} - -// ------------------------------------------------------------------- -// Realtime API events data -// ------------------------------------------------------------------- - -type RealtimeConnectEvent struct { - HttpContext echo.Context - Client subscriptions.Client -} - -type RealtimeDisconnectEvent struct { - HttpContext echo.Context - Client subscriptions.Client -} - -type RealtimeMessageEvent struct { - HttpContext echo.Context - Client subscriptions.Client - Message *subscriptions.Message -} - -type RealtimeSubscribeEvent struct { - HttpContext echo.Context - Client subscriptions.Client - Subscriptions []string -} - -// ------------------------------------------------------------------- -// Settings API events data -// ------------------------------------------------------------------- - -type SettingsListEvent struct { - HttpContext echo.Context - RedactedSettings *settings.Settings -} - -type SettingsUpdateEvent struct { - HttpContext echo.Context - OldSettings *settings.Settings - NewSettings *settings.Settings -} - -// ------------------------------------------------------------------- -// Record CRUD API events data -// ------------------------------------------------------------------- - -type RecordsListEvent struct { - HttpContext echo.Context - Collection *models.Collection - Records []*models.Record - Result *search.Result -} - -type RecordViewEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordCreateEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordUpdateEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordDeleteEvent struct { - HttpContext echo.Context - Record *models.Record -} - -// ------------------------------------------------------------------- -// Auth Record API events data -// ------------------------------------------------------------------- - -type RecordAuthEvent struct { - HttpContext echo.Context - Record *models.Record - Token string - Meta any -} - -type RecordRequestPasswordResetEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordConfirmPasswordResetEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordRequestVerificationEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordConfirmVerificationEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordRequestEmailChangeEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordConfirmEmailChangeEvent struct { - HttpContext echo.Context - Record *models.Record -} - -type RecordListExternalAuthsEvent struct { - HttpContext echo.Context - Record *models.Record - ExternalAuths []*models.ExternalAuth -} - -type RecordUnlinkExternalAuthEvent struct { - HttpContext echo.Context - Record *models.Record - ExternalAuth *models.ExternalAuth -} - -// ------------------------------------------------------------------- -// Admin API events data -// ------------------------------------------------------------------- - -type AdminsListEvent struct { - HttpContext echo.Context - Admins []*models.Admin - Result *search.Result -} - -type AdminViewEvent struct { - HttpContext echo.Context - Admin *models.Admin -} - -type AdminCreateEvent struct { - HttpContext echo.Context - Admin *models.Admin -} - -type AdminUpdateEvent struct { - HttpContext echo.Context - Admin *models.Admin -} - -type AdminDeleteEvent struct { - HttpContext echo.Context - Admin *models.Admin -} - -type AdminAuthEvent struct { - HttpContext echo.Context - Admin *models.Admin - Token string -} - -// ------------------------------------------------------------------- -// Collection API events data -// ------------------------------------------------------------------- - -type CollectionsListEvent struct { - HttpContext echo.Context - Collections []*models.Collection - Result *search.Result -} - -type CollectionViewEvent struct { - HttpContext echo.Context - Collection *models.Collection -} - -type CollectionCreateEvent struct { - HttpContext echo.Context - Collection *models.Collection -} - -type CollectionUpdateEvent struct { - HttpContext echo.Context - Collection *models.Collection -} - -type CollectionDeleteEvent struct { - HttpContext echo.Context - Collection *models.Collection -} - -type CollectionsImportEvent struct { - HttpContext echo.Context - Collections []*models.Collection -} - -// ------------------------------------------------------------------- -// File API events data -// ------------------------------------------------------------------- - -type FileDownloadEvent struct { - HttpContext echo.Context - Collection *models.Collection - Record *models.Record - FileField *schema.SchemaField - ServedPath string - ServedName string -} diff --git a/daos/admin.go b/daos/admin.go deleted file mode 100644 index 3a0000231edf32674c6c742fc39aee5dc83452c0..0000000000000000000000000000000000000000 --- a/daos/admin.go +++ /dev/null @@ -1,128 +0,0 @@ -package daos - -import ( - "errors" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/security" -) - -// AdminQuery returns a new Admin select query. -func (dao *Dao) AdminQuery() *dbx.SelectQuery { - return dao.ModelQuery(&models.Admin{}) -} - -// FindAdminById finds the admin with the provided id. -func (dao *Dao) FindAdminById(id string) (*models.Admin, error) { - model := &models.Admin{} - - err := dao.AdminQuery(). - AndWhere(dbx.HashExp{"id": id}). - Limit(1). - One(model) - - if err != nil { - return nil, err - } - - return model, nil -} - -// FindAdminByEmail finds the admin with the provided email address. -func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) { - model := &models.Admin{} - - err := dao.AdminQuery(). - AndWhere(dbx.HashExp{"email": email}). - Limit(1). - One(model) - - if err != nil { - return nil, err - } - - return model, nil -} - -// FindAdminByToken finds the admin associated with the provided JWT token. -// -// Returns an error if the JWT token is invalid or expired. -func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) { - // @todo consider caching the unverified claims - unverifiedClaims, err := security.ParseUnverifiedJWT(token) - if err != nil { - return nil, err - } - - // check required claims - id, _ := unverifiedClaims["id"].(string) - if id == "" { - return nil, errors.New("Missing or invalid token claims.") - } - - admin, err := dao.FindAdminById(id) - if err != nil || admin == nil { - return nil, err - } - - verificationKey := admin.TokenKey + baseTokenKey - - // verify token signature - if _, err := security.ParseJWT(token, verificationKey); err != nil { - return nil, err - } - - return admin, nil -} - -// TotalAdmins returns the number of existing admin records. -func (dao *Dao) TotalAdmins() (int, error) { - var total int - - err := dao.AdminQuery().Select("count(*)").Row(&total) - - return total, err -} - -// IsAdminEmailUnique checks if the provided email address is not -// already in use by other admins. -func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool { - if email == "" { - return false - } - - query := dao.AdminQuery().Select("count(*)"). - AndWhere(dbx.HashExp{"email": email}). - Limit(1) - - if len(excludeIds) > 0 { - query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...)) - } - - var exists bool - - return query.Row(&exists) == nil && !exists -} - -// DeleteAdmin deletes the provided Admin model. -// -// Returns an error if there is only 1 admin. -func (dao *Dao) DeleteAdmin(admin *models.Admin) error { - total, err := dao.TotalAdmins() - if err != nil { - return err - } - - if total == 1 { - return errors.New("You cannot delete the only existing admin.") - } - - return dao.Delete(admin) -} - -// SaveAdmin upserts the provided Admin model. -func (dao *Dao) SaveAdmin(admin *models.Admin) error { - return dao.Save(admin) -} diff --git a/daos/admin_test.go b/daos/admin_test.go deleted file mode 100644 index d49f5972b8d19effde7a9e8a81e54abdc14b5ff0..0000000000000000000000000000000000000000 --- a/daos/admin_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package daos_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestAdminQuery(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - expected := "SELECT {{_admins}}.* FROM `_admins`" - - sql := app.Dao().AdminQuery().Build().SQL() - if sql != expected { - t.Errorf("Expected sql %s, got %s", expected, sql) - } -} - -func TestFindAdminById(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - id string - expectError bool - }{ - {" ", true}, - {"missing", true}, - {"9q2trqumvlyr3bd", false}, - } - - for i, scenario := range scenarios { - admin, err := app.Dao().FindAdminById(scenario.id) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if admin != nil && admin.Id != scenario.id { - t.Errorf("(%d) Expected admin with id %s, got %s", i, scenario.id, admin.Id) - } - } -} - -func TestFindAdminByEmail(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - email string - expectError bool - }{ - {"", true}, - {"invalid", true}, - {"missing@example.com", true}, - {"test@example.com", false}, - } - - for i, scenario := range scenarios { - admin, err := app.Dao().FindAdminByEmail(scenario.email) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - continue - } - - if !scenario.expectError && admin.Email != scenario.email { - t.Errorf("(%d) Expected admin with email %s, got %s", i, scenario.email, admin.Email) - } - } -} - -func TestFindAdminByToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - token string - baseKey string - expectedEmail string - expectError bool - }{ - // invalid auth token - { - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY", - app.Settings().AdminAuthToken.Secret, - "", - true, - }, - // expired token - { - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4", - app.Settings().AdminAuthToken.Secret, - "", - true, - }, - // wrong base token (password reset token secret instead of auth secret) - { - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - app.Settings().AdminPasswordResetToken.Secret, - "", - true, - }, - // valid token - { - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8", - app.Settings().AdminAuthToken.Secret, - "test@example.com", - false, - }, - } - - for i, scenario := range scenarios { - admin, err := app.Dao().FindAdminByToken(scenario.token, scenario.baseKey) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - continue - } - - if !scenario.expectError && admin.Email != scenario.expectedEmail { - t.Errorf("(%d) Expected admin model %s, got %s", i, scenario.expectedEmail, admin.Email) - } - } -} - -func TestTotalAdmins(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - result1, err := app.Dao().TotalAdmins() - if err != nil { - t.Fatal(err) - } - if result1 != 3 { - t.Fatalf("Expected 3 admins, got %d", result1) - } - - // delete all - app.Dao().DB().NewQuery("delete from {{_admins}}").Execute() - - result2, err := app.Dao().TotalAdmins() - if err != nil { - t.Fatal(err) - } - if result2 != 0 { - t.Fatalf("Expected 0 admins, got %d", result2) - } -} - -func TestIsAdminEmailUnique(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - email string - excludeId string - expected bool - }{ - {"", "", false}, - {"test@example.com", "", false}, - {"test2@example.com", "", false}, - {"test3@example.com", "", false}, - {"new@example.com", "", true}, - {"test@example.com", "sywbhecnh46rhm0", true}, - } - - for i, scenario := range scenarios { - result := app.Dao().IsAdminEmailUnique(scenario.email, scenario.excludeId) - if result != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) - } - } -} - -func TestDeleteAdmin(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // try to delete unsaved admin model - deleteErr0 := app.Dao().DeleteAdmin(&models.Admin{}) - if deleteErr0 == nil { - t.Fatal("Expected error, got nil") - } - - admin1, err := app.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - admin2, err := app.Dao().FindAdminByEmail("test2@example.com") - if err != nil { - t.Fatal(err) - } - admin3, err := app.Dao().FindAdminByEmail("test3@example.com") - if err != nil { - t.Fatal(err) - } - - deleteErr1 := app.Dao().DeleteAdmin(admin1) - if deleteErr1 != nil { - t.Fatal(deleteErr1) - } - - deleteErr2 := app.Dao().DeleteAdmin(admin2) - if deleteErr2 != nil { - t.Fatal(deleteErr2) - } - - // cannot delete the only remaining admin - deleteErr3 := app.Dao().DeleteAdmin(admin3) - if deleteErr3 == nil { - t.Fatal("Expected delete error, got nil") - } - - total, _ := app.Dao().TotalAdmins() - if total != 1 { - t.Fatalf("Expected only 1 admin, got %d", total) - } -} - -func TestSaveAdmin(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create - newAdmin := &models.Admin{} - newAdmin.Email = "new@example.com" - newAdmin.SetPassword("123456") - saveErr1 := app.Dao().SaveAdmin(newAdmin) - if saveErr1 != nil { - t.Fatal(saveErr1) - } - if newAdmin.Id == "" { - t.Fatal("Expected admin id to be set") - } - - // update - existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - updatedEmail := "test_update@example.com" - existingAdmin.Email = updatedEmail - saveErr2 := app.Dao().SaveAdmin(existingAdmin) - if saveErr2 != nil { - t.Fatal(saveErr2) - } - existingAdmin, _ = app.Dao().FindAdminById(existingAdmin.Id) - if existingAdmin.Email != updatedEmail { - t.Fatalf("Expected admin email to be %s, got %s", updatedEmail, existingAdmin.Email) - } -} diff --git a/daos/base.go b/daos/base.go deleted file mode 100644 index b59a2e41ed4cdf81c3c8b53b8bead0faeaef8c3a..0000000000000000000000000000000000000000 --- a/daos/base.go +++ /dev/null @@ -1,249 +0,0 @@ -// Package daos handles common PocketBase DB model manipulations. -// -// Think of daos as DB repository and service layer in one. -package daos - -import ( - "errors" - "fmt" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" -) - -// New creates a new Dao instance with the provided db builder. -func New(db dbx.Builder) *Dao { - return &Dao{ - db: db, - } -} - -// Dao handles various db operations. -// Think of Dao as a repository and service layer in one. -type Dao struct { - db dbx.Builder - - BeforeCreateFunc func(eventDao *Dao, m models.Model) error - AfterCreateFunc func(eventDao *Dao, m models.Model) - BeforeUpdateFunc func(eventDao *Dao, m models.Model) error - AfterUpdateFunc func(eventDao *Dao, m models.Model) - BeforeDeleteFunc func(eventDao *Dao, m models.Model) error - AfterDeleteFunc func(eventDao *Dao, m models.Model) -} - -// DB returns the internal db builder (*dbx.DB or *dbx.TX). -func (dao *Dao) DB() dbx.Builder { - return dao.db -} - -// ModelQuery creates a new query with preset Select and From fields -// based on the provided model argument. -func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery { - tableName := m.TableName() - return dao.db.Select(fmt.Sprintf("{{%s}}.*", tableName)).From(tableName) -} - -// FindById finds a single db record with the specified id and -// scans the result into m. -func (dao *Dao) FindById(m models.Model, id string) error { - return dao.ModelQuery(m).Where(dbx.HashExp{"id": id}).Limit(1).One(m) -} - -type afterCallGroup struct { - Action string - EventDao *Dao - Model models.Model -} - -// RunInTransaction wraps fn into a transaction. -// -// It is safe to nest RunInTransaction calls. -func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error { - switch txOrDB := dao.db.(type) { - case *dbx.Tx: - // nested transactions are not supported by default - // so execute the function within the current transaction - return fn(dao) - case *dbx.DB: - afterCalls := []afterCallGroup{} - - txError := txOrDB.Transactional(func(tx *dbx.Tx) error { - txDao := New(tx) - - if dao.BeforeCreateFunc != nil { - txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error { - return dao.BeforeCreateFunc(eventDao, m) - } - } - if dao.BeforeUpdateFunc != nil { - txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error { - return dao.BeforeUpdateFunc(eventDao, m) - } - } - if dao.BeforeDeleteFunc != nil { - txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error { - return dao.BeforeDeleteFunc(eventDao, m) - } - } - - if dao.AfterCreateFunc != nil { - txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) { - afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m}) - } - } - if dao.AfterUpdateFunc != nil { - txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) { - afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m}) - } - } - if dao.AfterDeleteFunc != nil { - txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) { - afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m}) - } - } - - return fn(txDao) - }) - - if txError == nil { - // execute after event calls on successful transaction - for _, call := range afterCalls { - switch call.Action { - case "create": - dao.AfterCreateFunc(call.EventDao, call.Model) - case "update": - dao.AfterUpdateFunc(call.EventDao, call.Model) - case "delete": - dao.AfterDeleteFunc(call.EventDao, call.Model) - } - } - } - - return txError - } - - return errors.New("Failed to start transaction (unknown dao.db)") -} - -// Delete deletes the provided model. -func (dao *Dao) Delete(m models.Model) error { - if !m.HasId() { - return errors.New("ID is not set") - } - - if dao.BeforeDeleteFunc != nil { - if err := dao.BeforeDeleteFunc(dao, m); err != nil { - return err - } - } - - if err := dao.db.Model(m).Delete(); err != nil { - return err - } - - if dao.AfterDeleteFunc != nil { - dao.AfterDeleteFunc(dao, m) - } - - return nil -} - -// Save upserts (update or create if primary key is not set) the provided model. -func (dao *Dao) Save(m models.Model) error { - if m.IsNew() { - return dao.create(m) - } - - return dao.update(m) -} - -func (dao *Dao) update(m models.Model) error { - if !m.HasId() { - return errors.New("ID is not set") - } - - if m.GetCreated().IsZero() { - m.RefreshCreated() - } - - m.RefreshUpdated() - - if dao.BeforeUpdateFunc != nil { - if err := dao.BeforeUpdateFunc(dao, m); err != nil { - return err - } - } - - if v, ok := any(m).(models.ColumnValueMapper); ok { - dataMap := v.ColumnValueMap() - - _, err := dao.db.Update( - m.TableName(), - dataMap, - dbx.HashExp{"id": m.GetId()}, - ).Execute() - - if err != nil { - return err - } - } else { - if err := dao.db.Model(m).Update(); err != nil { - return err - } - } - - if dao.AfterUpdateFunc != nil { - dao.AfterUpdateFunc(dao, m) - } - - return nil -} - -func (dao *Dao) create(m models.Model) error { - if !m.HasId() { - // auto generate id - m.RefreshId() - } - - // mark the model as "new" since the model now always has an ID - m.MarkAsNew() - - if m.GetCreated().IsZero() { - m.RefreshCreated() - } - - if m.GetUpdated().IsZero() { - m.RefreshUpdated() - } - - if dao.BeforeCreateFunc != nil { - if err := dao.BeforeCreateFunc(dao, m); err != nil { - return err - } - } - - if v, ok := any(m).(models.ColumnValueMapper); ok { - dataMap := v.ColumnValueMap() - if _, ok := dataMap["id"]; !ok { - dataMap["id"] = m.GetId() - } - - _, err := dao.db.Insert(m.TableName(), dataMap).Execute() - if err != nil { - return err - } - } else { - if err := dao.db.Model(m).Insert(); err != nil { - return err - } - } - - // clears the "new" model flag - m.MarkAsNotNew() - - if dao.AfterCreateFunc != nil { - dao.AfterCreateFunc(dao, m) - } - - return nil -} diff --git a/daos/base_test.go b/daos/base_test.go deleted file mode 100644 index 37f45e3eaf416ed0c9fd2a67db429343160ec30e..0000000000000000000000000000000000000000 --- a/daos/base_test.go +++ /dev/null @@ -1,504 +0,0 @@ -package daos_test - -import ( - "errors" - "testing" - - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestNew(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - dao := daos.New(testApp.DB()) - - if dao.DB() != testApp.DB() { - t.Fatal("The 2 db instances are different") - } -} - -func TestDaoModelQuery(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - dao := daos.New(testApp.DB()) - - scenarios := []struct { - model models.Model - expected string - }{ - { - &models.Collection{}, - "SELECT {{_collections}}.* FROM `_collections`", - }, - { - &models.Admin{}, - "SELECT {{_admins}}.* FROM `_admins`", - }, - { - &models.Request{}, - "SELECT {{_requests}}.* FROM `_requests`", - }, - } - - for i, scenario := range scenarios { - sql := dao.ModelQuery(scenario.model).Build().SQL() - if sql != scenario.expected { - t.Errorf("(%d) Expected select %s, got %s", i, scenario.expected, sql) - } - } -} - -func TestDaoFindById(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - scenarios := []struct { - model models.Model - id string - expectError bool - }{ - // missing id - { - &models.Collection{}, - "missing", - true, - }, - // existing collection id - { - &models.Collection{}, - "wsmn24bux7wo113", - false, - }, - // existing admin id - { - &models.Admin{}, - "sbmbsdb40jyxf7h", - false, - }, - } - - for i, scenario := range scenarios { - err := testApp.Dao().FindById(scenario.model, scenario.id) - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expectError, err) - } - - if !scenario.expectError && scenario.id != scenario.model.GetId() { - t.Errorf("(%d) Expected model with id %v, got %v", i, scenario.id, scenario.model.GetId()) - } - } -} - -func TestDaoRunInTransaction(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - // failed nested transaction - testApp.Dao().RunInTransaction(func(txDao *daos.Dao) error { - admin, _ := txDao.FindAdminByEmail("test@example.com") - - return txDao.RunInTransaction(func(tx2Dao *daos.Dao) error { - if err := tx2Dao.DeleteAdmin(admin); err != nil { - t.Fatal(err) - } - return errors.New("test error") - }) - }) - - // admin should still exist - admin1, _ := testApp.Dao().FindAdminByEmail("test@example.com") - if admin1 == nil { - t.Fatal("Expected admin test@example.com to not be deleted") - } - - // successful nested transaction - testApp.Dao().RunInTransaction(func(txDao *daos.Dao) error { - admin, _ := txDao.FindAdminByEmail("test@example.com") - - return txDao.RunInTransaction(func(tx2Dao *daos.Dao) error { - return tx2Dao.DeleteAdmin(admin) - }) - }) - - // admin should have been deleted - admin2, _ := testApp.Dao().FindAdminByEmail("test@example.com") - if admin2 != nil { - t.Fatalf("Expected admin test@example.com to be deleted, found %v", admin2) - } -} - -func TestDaoSaveCreate(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - model := &models.Admin{} - model.Email = "test_new@example.com" - model.Avatar = 8 - if err := testApp.Dao().Save(model); err != nil { - t.Fatal(err) - } - - // refresh - model, _ = testApp.Dao().FindAdminByEmail("test_new@example.com") - - if model.Avatar != 8 { - t.Fatalf("Expected model avatar field to be 8, got %v", model.Avatar) - } - - expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"} - for _, h := range expectedHooks { - if v, ok := testApp.EventCalls[h]; !ok || v != 1 { - t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) - } - } -} - -func TestDaoSaveWithInsertId(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - model := &models.Admin{} - model.Id = "test" - model.Email = "test_new@example.com" - model.MarkAsNew() - if err := testApp.Dao().Save(model); err != nil { - t.Fatal(err) - } - - // refresh - model, _ = testApp.Dao().FindAdminById("test") - - if model == nil { - t.Fatal("Failed to find admin with id 'test'") - } - - expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"} - for _, h := range expectedHooks { - if v, ok := testApp.EventCalls[h]; !ok || v != 1 { - t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) - } - } -} - -func TestDaoSaveUpdate(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - model, _ := testApp.Dao().FindAdminByEmail("test@example.com") - - model.Avatar = 8 - if err := testApp.Dao().Save(model); err != nil { - t.Fatal(err) - } - - // refresh - model, _ = testApp.Dao().FindAdminByEmail("test@example.com") - - if model.Avatar != 8 { - t.Fatalf("Expected model avatar field to be updated to 8, got %v", model.Avatar) - } - - expectedHooks := []string{"OnModelBeforeUpdate", "OnModelAfterUpdate"} - for _, h := range expectedHooks { - if v, ok := testApp.EventCalls[h]; !ok || v != 1 { - t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) - } - } -} - -type dummyColumnValueMapper struct { - models.Admin -} - -func (a *dummyColumnValueMapper) ColumnValueMap() map[string]any { - return map[string]any{ - "email": a.Email, - "passwordHash": a.PasswordHash, - "tokenKey": "custom_token_key", - } -} - -func TestDaoSaveWithColumnValueMapper(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - model := &dummyColumnValueMapper{} - model.Id = "test_mapped_id" // explicitly set an id - model.Email = "test_mapped_create@example.com" - model.TokenKey = "test_unmapped_token_key" // not used in the map - model.SetPassword("123456") - model.MarkAsNew() - if err := testApp.Dao().Save(model); err != nil { - t.Fatal(err) - } - - createdModel, _ := testApp.Dao().FindAdminById("test_mapped_id") - if createdModel == nil { - t.Fatal("[create] Failed to find model with id 'test_mapped_id'") - } - if createdModel.Email != model.Email { - t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email) - } - if createdModel.TokenKey != "custom_token_key" { - t.Fatalf("Expected model with tokenKey %q, got %q", "custom_token_key", createdModel.TokenKey) - } - - model.Email = "test_mapped_update@example.com" - model.Avatar = 9 // not mapped and expect to be ignored - if err := testApp.Dao().Save(model); err != nil { - t.Fatal(err) - } - - updatedModel, _ := testApp.Dao().FindAdminById("test_mapped_id") - if updatedModel == nil { - t.Fatal("[update] Failed to find model with id 'test_mapped_id'") - } - if updatedModel.Email != model.Email { - t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email) - } - if updatedModel.Avatar != 0 { - t.Fatalf("Expected model avatar 0, got %v", updatedModel.Avatar) - } -} - -func TestDaoDelete(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - model, _ := testApp.Dao().FindAdminByEmail("test@example.com") - - if err := testApp.Dao().Delete(model); err != nil { - t.Fatal(err) - } - - model, _ = testApp.Dao().FindAdminByEmail("test@example.com") - if model != nil { - t.Fatalf("Expected model to be deleted, found %v", model) - } - - expectedHooks := []string{"OnModelBeforeDelete", "OnModelAfterDelete"} - for _, h := range expectedHooks { - if v, ok := testApp.EventCalls[h]; !ok || v != 1 { - t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) - } - } -} - -func TestDaoBeforeHooksError(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - baseDao := testApp.Dao() - - baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error { - return errors.New("before_create") - } - baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error { - return errors.New("before_update") - } - baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error { - return errors.New("before_delete") - } - - existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com") - - // test create error - // --- - newModel := &models.Admin{} - if err := baseDao.Save(newModel); err.Error() != "before_create" { - t.Fatalf("Expected before_create error, got %v", err) - } - - // test update error - // --- - if err := baseDao.Save(existingModel); err.Error() != "before_update" { - t.Fatalf("Expected before_update error, got %v", err) - } - - // test delete error - // --- - if err := baseDao.Delete(existingModel); err.Error() != "before_delete" { - t.Fatalf("Expected before_delete error, got %v", err) - } -} - -func TestDaoTransactionHooksCallsOnFailure(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - beforeCreateFuncCalls := 0 - beforeUpdateFuncCalls := 0 - beforeDeleteFuncCalls := 0 - afterCreateFuncCalls := 0 - afterUpdateFuncCalls := 0 - afterDeleteFuncCalls := 0 - - baseDao := testApp.Dao() - - baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error { - beforeCreateFuncCalls++ - return nil - } - baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error { - beforeUpdateFuncCalls++ - return nil - } - baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error { - beforeDeleteFuncCalls++ - return nil - } - - baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) { - afterCreateFuncCalls++ - } - baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) { - afterUpdateFuncCalls++ - } - baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) { - afterDeleteFuncCalls++ - } - - existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com") - - baseDao.RunInTransaction(func(txDao1 *daos.Dao) error { - return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error { - // test create - // --- - newModel := &models.Admin{} - newModel.Email = "test_new1@example.com" - newModel.SetPassword("123456") - if err := txDao2.Save(newModel); err != nil { - t.Fatal(err) - } - - // test update (twice) - // --- - if err := txDao2.Save(existingModel); err != nil { - t.Fatal(err) - } - if err := txDao2.Save(existingModel); err != nil { - t.Fatal(err) - } - - // test delete - // --- - if err := txDao2.Delete(existingModel); err != nil { - t.Fatal(err) - } - - return errors.New("test_tx_error") - }) - }) - - if beforeCreateFuncCalls != 1 { - t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls) - } - if beforeUpdateFuncCalls != 2 { - t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls) - } - if beforeDeleteFuncCalls != 1 { - t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls) - } - if afterCreateFuncCalls != 0 { - t.Fatalf("Expected afterCreateFuncCalls to be called 0 times, got %d", afterCreateFuncCalls) - } - if afterUpdateFuncCalls != 0 { - t.Fatalf("Expected afterUpdateFuncCalls to be called 0 times, got %d", afterUpdateFuncCalls) - } - if afterDeleteFuncCalls != 0 { - t.Fatalf("Expected afterDeleteFuncCalls to be called 0 times, got %d", afterDeleteFuncCalls) - } -} - -func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - beforeCreateFuncCalls := 0 - beforeUpdateFuncCalls := 0 - beforeDeleteFuncCalls := 0 - afterCreateFuncCalls := 0 - afterUpdateFuncCalls := 0 - afterDeleteFuncCalls := 0 - - baseDao := testApp.Dao() - - baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error { - beforeCreateFuncCalls++ - return nil - } - baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error { - beforeUpdateFuncCalls++ - return nil - } - baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error { - beforeDeleteFuncCalls++ - return nil - } - - baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) { - afterCreateFuncCalls++ - } - baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) { - afterUpdateFuncCalls++ - } - baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) { - afterDeleteFuncCalls++ - } - - existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com") - - baseDao.RunInTransaction(func(txDao1 *daos.Dao) error { - return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error { - // test create - // --- - newModel := &models.Admin{} - newModel.Email = "test_new1@example.com" - newModel.SetPassword("123456") - if err := txDao2.Save(newModel); err != nil { - t.Fatal(err) - } - - // test update (twice) - // --- - if err := txDao2.Save(existingModel); err != nil { - t.Fatal(err) - } - if err := txDao2.Save(existingModel); err != nil { - t.Fatal(err) - } - - // test delete - // --- - if err := txDao2.Delete(existingModel); err != nil { - t.Fatal(err) - } - - return nil - }) - }) - - if beforeCreateFuncCalls != 1 { - t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls) - } - if beforeUpdateFuncCalls != 2 { - t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls) - } - if beforeDeleteFuncCalls != 1 { - t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls) - } - if afterCreateFuncCalls != 1 { - t.Fatalf("Expected afterCreateFuncCalls to be called 1 times, got %d", afterCreateFuncCalls) - } - if afterUpdateFuncCalls != 2 { - t.Fatalf("Expected afterUpdateFuncCalls to be called 2 times, got %d", afterUpdateFuncCalls) - } - if afterDeleteFuncCalls != 1 { - t.Fatalf("Expected afterDeleteFuncCalls to be called 1 times, got %d", afterDeleteFuncCalls) - } -} diff --git a/daos/collection.go b/daos/collection.go deleted file mode 100644 index 8330f254b96bb86fb618d7dc8ecb89ea35911656..0000000000000000000000000000000000000000 --- a/daos/collection.go +++ /dev/null @@ -1,292 +0,0 @@ -package daos - -import ( - "errors" - "fmt" - "strings" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/list" -) - -// CollectionQuery returns a new Collection select query. -func (dao *Dao) CollectionQuery() *dbx.SelectQuery { - return dao.ModelQuery(&models.Collection{}) -} - -// FindCollectionsByType finds all collections by the given type. -func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) { - collections := []*models.Collection{} - - err := dao.CollectionQuery(). - AndWhere(dbx.HashExp{"type": collectionType}). - OrderBy("created ASC"). - All(&collections) - - if err != nil { - return nil, err - } - - return collections, nil -} - -// FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id. -func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) { - model := &models.Collection{} - - err := dao.CollectionQuery(). - AndWhere(dbx.NewExp("[[id]] = {:id} OR LOWER([[name]])={:name}", dbx.Params{ - "id": nameOrId, - "name": strings.ToLower(nameOrId), - })). - Limit(1). - One(model) - - if err != nil { - return nil, err - } - - return model, nil -} - -// IsCollectionNameUnique checks that there is no existing collection -// with the provided name (case insensitive!). -// -// Note: case insensitive check because the name is used also as a table name for the records. -func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool { - if name == "" { - return false - } - - query := dao.CollectionQuery(). - Select("count(*)"). - AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})). - Limit(1) - - if len(excludeIds) > 0 { - uniqueExcludeIds := list.NonzeroUniques(excludeIds) - query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...)) - } - - var exists bool - - return query.Row(&exists) == nil && !exists -} - -// FindCollectionReferences returns information for all -// relation schema fields referencing the provided collection. -// -// If the provided collection has reference to itself then it will be -// also included in the result. To exclude it, pass the collection id -// as the excludeId argument. -func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...string) (map[*models.Collection][]*schema.SchemaField, error) { - collections := []*models.Collection{} - - query := dao.CollectionQuery() - if len(excludeIds) > 0 { - uniqueExcludeIds := list.NonzeroUniques(excludeIds) - query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...)) - } - if err := query.All(&collections); err != nil { - return nil, err - } - - result := map[*models.Collection][]*schema.SchemaField{} - for _, c := range collections { - for _, f := range c.Schema.Fields() { - if f.Type != schema.FieldTypeRelation { - continue - } - f.InitOptions() - options, _ := f.Options.(*schema.RelationOptions) - if options != nil && options.CollectionId == collection.Id { - result[c] = append(result[c], f) - } - } - } - - return result, nil -} - -// DeleteCollection deletes the provided Collection model. -// This method automatically deletes the related collection records table. -// -// NB! The collection cannot be deleted, if: -// - is system collection (aka. collection.System is true) -// - is referenced as part of a relation field in another collection -func (dao *Dao) DeleteCollection(collection *models.Collection) error { - if collection.System { - return fmt.Errorf("System collection %q cannot be deleted.", collection.Name) - } - - // ensure that there aren't any existing references. - // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction - result, err := dao.FindCollectionReferences(collection, collection.Id) - if err != nil { - return err - } - if total := len(result); total > 0 { - return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total) - } - - return dao.RunInTransaction(func(txDao *Dao) error { - // delete the related records table - if err := txDao.DeleteTable(collection.Name); err != nil { - return err - } - - return txDao.Delete(collection) - }) -} - -// SaveCollection upserts the provided Collection model and updates -// its related records table schema. -func (dao *Dao) SaveCollection(collection *models.Collection) error { - var oldCollection *models.Collection - - if !collection.IsNew() { - // get the existing collection state to compare with the new one - // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction - var findErr error - oldCollection, findErr = dao.FindCollectionByNameOrId(collection.Id) - if findErr != nil { - return findErr - } - } - - return dao.RunInTransaction(func(txDao *Dao) error { - // set default collection type - if collection.Type == "" { - collection.Type = models.CollectionTypeBase - } - - // persist the collection model - if err := txDao.Save(collection); err != nil { - return err - } - - // sync the changes with the related records table - return txDao.SyncRecordTableSchema(collection, oldCollection) - }) -} - -// ImportCollections imports the provided collections list within a single transaction. -// -// NB1! If deleteMissing is set, all local collections and schema fields, that are not present -// in the imported configuration, WILL BE DELETED (including their related records data). -// -// NB2! This method doesn't perform validations on the imported collections data! -// If you need validations, use [forms.CollectionsImport]. -func (dao *Dao) ImportCollections( - importedCollections []*models.Collection, - deleteMissing bool, - beforeRecordsSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error, -) error { - if len(importedCollections) == 0 { - return errors.New("No collections to import") - } - - return dao.RunInTransaction(func(txDao *Dao) error { - existingCollections := []*models.Collection{} - if err := txDao.CollectionQuery().OrderBy("created ASC").All(&existingCollections); err != nil { - return err - } - mappedExisting := make(map[string]*models.Collection, len(existingCollections)) - for _, existing := range existingCollections { - mappedExisting[existing.GetId()] = existing - } - - mappedImported := make(map[string]*models.Collection, len(importedCollections)) - for _, imported := range importedCollections { - // generate id if not set - if !imported.HasId() { - imported.MarkAsNew() - imported.RefreshId() - } - - // set default type if missing - if imported.Type == "" { - imported.Type = models.CollectionTypeBase - } - - if existing, ok := mappedExisting[imported.GetId()]; ok { - imported.MarkAsNotNew() - - // preserve original created date - if !existing.Created.IsZero() { - imported.Created = existing.Created - } - - // extend existing schema - if !deleteMissing { - schema, _ := existing.Schema.Clone() - for _, f := range imported.Schema.Fields() { - schema.AddField(f) // add or replace - } - imported.Schema = *schema - } - } else { - imported.MarkAsNew() - } - - mappedImported[imported.GetId()] = imported - } - - // delete old collections not available in the new configuration - // (before saving the imports in case a deleted collection name is being reused) - if deleteMissing { - for _, existing := range existingCollections { - if mappedImported[existing.GetId()] != nil { - continue // exist - } - - if existing.System { - return fmt.Errorf("System collection %q cannot be deleted.", existing.Name) - } - - // delete the collection - if err := txDao.Delete(existing); err != nil { - return err - } - } - } - - // upsert imported collections - for _, imported := range importedCollections { - if err := txDao.Save(imported); err != nil { - return err - } - } - - if beforeRecordsSync != nil { - if err := beforeRecordsSync(txDao, mappedImported, mappedExisting); err != nil { - return err - } - } - - // delete the record tables of the deleted collections - if deleteMissing { - for _, existing := range existingCollections { - if mappedImported[existing.GetId()] != nil { - continue // exist - } - - if err := txDao.DeleteTable(existing.Name); err != nil { - return err - } - } - } - - // sync the upserted collections with the related records table - for _, imported := range importedCollections { - existing := mappedExisting[imported.GetId()] - if err := txDao.SyncRecordTableSchema(imported, existing); err != nil { - return err - } - } - - return nil - }) -} diff --git a/daos/collection_test.go b/daos/collection_test.go deleted file mode 100644 index 31e385ef715609aac9a403b663a2e026af4ec4c1..0000000000000000000000000000000000000000 --- a/daos/collection_test.go +++ /dev/null @@ -1,555 +0,0 @@ -package daos_test - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/list" -) - -func TestCollectionQuery(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - expected := "SELECT {{_collections}}.* FROM `_collections`" - - sql := app.Dao().CollectionQuery().Build().SQL() - if sql != expected { - t.Errorf("Expected sql %s, got %s", expected, sql) - } -} - -func TestFindCollectionsByType(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionType string - expectError bool - expectTotal int - }{ - {"", false, 0}, - {"unknown", false, 0}, - {models.CollectionTypeAuth, false, 3}, - {models.CollectionTypeBase, false, 4}, - } - - for i, scenario := range scenarios { - collections, err := app.Dao().FindCollectionsByType(scenario.collectionType) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if len(collections) != scenario.expectTotal { - t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections)) - } - - for _, c := range collections { - if c.Type != scenario.collectionType { - t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c) - } - } - } -} - -func TestFindCollectionByNameOrId(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - nameOrId string - expectError bool - }{ - {"", true}, - {"missing", true}, - {"wsmn24bux7wo113", false}, - {"demo1", false}, - {"DEMO1", false}, // case insensitive check - } - - for i, scenario := range scenarios { - model, err := app.Dao().FindCollectionByNameOrId(scenario.nameOrId) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) { - t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model) - } - } -} - -func TestIsCollectionNameUnique(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - name string - excludeId string - expected bool - }{ - {"", "", false}, - {"demo1", "", false}, - {"Demo1", "", false}, - {"new", "", true}, - {"demo1", "wsmn24bux7wo113", true}, - } - - for i, scenario := range scenarios { - result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId) - if result != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) - } - } -} - -func TestFindCollectionReferences(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo3") - if err != nil { - t.Fatal(err) - } - - result, err := app.Dao().FindCollectionReferences(collection, collection.Id) - if err != nil { - t.Fatal(err) - } - - if len(result) != 1 { - t.Fatalf("Expected 1 collection, got %d: %v", len(result), result) - } - - expectedFields := []string{ - "rel_one_no_cascade", - "rel_one_no_cascade_required", - "rel_one_cascade", - "rel_many_no_cascade", - "rel_many_no_cascade_required", - "rel_many_cascade", - } - - for col, fields := range result { - if col.Name != "demo4" { - t.Fatalf("Expected collection demo4, got %s", col.Name) - } - if len(fields) != len(expectedFields) { - t.Fatalf("Expected fields %v, got %v", expectedFields, fields) - } - for i, f := range fields { - if !list.ExistInSlice(f.Name, expectedFields) { - t.Fatalf("(%d) Didn't expect field %v", i, f) - } - } - } -} - -func TestDeleteCollection(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - c0 := &models.Collection{} - c1, err := app.Dao().FindCollectionByNameOrId("clients") - if err != nil { - t.Fatal(err) - } - c2, err := app.Dao().FindCollectionByNameOrId("demo2") - if err != nil { - t.Fatal(err) - } - c3, err := app.Dao().FindCollectionByNameOrId("demo1") - if err != nil { - t.Fatal(err) - } - c3.System = true - if err := app.Dao().Save(c3); err != nil { - t.Fatal(err) - } - - scenarios := []struct { - model *models.Collection - expectError bool - }{ - {c0, true}, - {c1, false}, - {c2, true}, // is part of a reference - {c3, true}, // system - } - - for i, scenario := range scenarios { - err := app.Dao().DeleteCollection(scenario.model) - hasErr := err != nil - - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr) - } - } -} - -func TestSaveCollectionCreate(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection := &models.Collection{ - Name: "new_test", - Type: models.CollectionTypeBase, - Schema: schema.NewSchema( - &schema.SchemaField{ - Type: schema.FieldTypeText, - Name: "test", - }, - ), - } - - err := app.Dao().SaveCollection(collection) - if err != nil { - t.Fatal(err) - } - - if collection.Id == "" { - t.Fatal("Expected collection id to be set") - } - - // check if the records table was created - hasTable := app.Dao().HasTable(collection.Name) - if !hasTable { - t.Fatalf("Expected records table %s to be created", collection.Name) - } - - // check if the records table has the schema fields - columns, err := app.Dao().GetTableColumns(collection.Name) - if err != nil { - t.Fatal(err) - } - expectedColumns := []string{"id", "created", "updated", "test"} - if len(columns) != len(expectedColumns) { - t.Fatalf("Expected columns %v, got %v", expectedColumns, columns) - } - for i, c := range columns { - if !list.ExistInSlice(c, expectedColumns) { - t.Fatalf("(%d) Didn't expect record column %s", i, c) - } - } -} - -func TestSaveCollectionUpdate(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo3") - if err != nil { - t.Fatal(err) - } - - // rename an existing schema field and add a new one - oldField := collection.Schema.GetFieldByName("title") - oldField.Name = "title_update" - collection.Schema.AddField(&schema.SchemaField{ - Type: schema.FieldTypeText, - Name: "test", - }) - - saveErr := app.Dao().SaveCollection(collection) - if saveErr != nil { - t.Fatal(saveErr) - } - - // check if the records table has the schema fields - expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"} - columns, err := app.Dao().GetTableColumns(collection.Name) - if err != nil { - t.Fatal(err) - } - if len(columns) != len(expectedColumns) { - t.Fatalf("Expected columns %v, got %v", expectedColumns, columns) - } - for i, c := range columns { - if !list.ExistInSlice(c, expectedColumns) { - t.Fatalf("(%d) Didn't expect record column %s", i, c) - } - } -} - -func TestImportCollections(t *testing.T) { - scenarios := []struct { - name string - jsonData string - deleteMissing bool - beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error - expectError bool - expectCollectionsCount int - beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection) - afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection) - }{ - { - name: "empty collections", - jsonData: `[]`, - expectError: true, - expectCollectionsCount: 7, - }, - { - name: "minimal collection import", - jsonData: `[ - {"name": "import_test1", "schema": [{"name":"test", "type": "text"}]}, - {"name": "import_test2", "type": "auth"} - ]`, - deleteMissing: false, - expectError: false, - expectCollectionsCount: 9, - }, - { - name: "minimal collection import + failed beforeRecordsSync", - jsonData: `[ - {"name": "import_test", "schema": [{"name":"test", "type": "text"}]} - ]`, - beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error { - return errors.New("test_error") - }, - deleteMissing: false, - expectError: true, - expectCollectionsCount: 7, - }, - { - name: "minimal collection import + successful beforeRecordsSync", - jsonData: `[ - {"name": "import_test", "schema": [{"name":"test", "type": "text"}]} - ]`, - beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error { - return nil - }, - deleteMissing: false, - expectError: false, - expectCollectionsCount: 8, - }, - { - name: "new + update + delete system collection", - jsonData: `[ - { - "id":"wsmn24bux7wo113", - "name":"demo", - "schema":[ - { - "id":"_2hlxbmp", - "name":"title", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - } - ] - }, - { - "name": "import1", - "schema": [ - { - "name":"active", - "type":"bool" - } - ] - } - ]`, - deleteMissing: true, - expectError: true, - expectCollectionsCount: 7, - }, - { - name: "new + update + delete non-system collection", - jsonData: `[ - { - "id": "kpv709sk2lqbqk8", - "system": true, - "name": "nologin", - "type": "auth", - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": [], - "manageRule": "@request.auth.collectionName = 'users'", - "minPasswordLength": 8, - "onlyEmailDomains": [], - "requireEmail": true - }, - "listRule": "", - "viewRule": "", - "createRule": "", - "updateRule": "", - "deleteRule": "", - "schema": [ - { - "id": "x8zzktwe", - "name": "name", - "type": "text", - "system": false, - "required": false, - "unique": false, - "options": { - "min": null, - "max": null, - "pattern": "" - } - } - ] - }, - { - "id":"wsmn24bux7wo113", - "name":"demo", - "schema":[ - { - "id":"_2hlxbmp", - "name":"title", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - } - ] - }, - { - "id": "test_deleted_collection_name_reuse", - "name": "demo2", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - } - ]`, - deleteMissing: true, - expectError: false, - expectCollectionsCount: 3, - }, - { - name: "test with deleteMissing: false", - jsonData: `[ - { - "id":"wsmn24bux7wo113", - "name":"demo1", - "schema":[ - { - "id":"_2hlxbmp", - "name":"title", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - }, - { - "id":"_2hlxbmp", - "name":"field_with_duplicate_id", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - }, - { - "id":"abcd_import", - "name":"new_field", - "type":"text" - } - ] - }, - { - "name": "new_import", - "schema": [ - { - "id":"abcd_import", - "name":"active", - "type":"bool" - } - ] - } - ]`, - deleteMissing: false, - expectError: false, - expectCollectionsCount: 8, - afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) { - expectedCollectionFields := map[string]int{ - "nologin": 1, - "demo1": 15, - "demo2": 2, - "demo3": 2, - "demo4": 11, - "new_import": 1, - } - for name, expectedCount := range expectedCollectionFields { - collection, err := testApp.Dao().FindCollectionByNameOrId(name) - if err != nil { - t.Fatal(err) - } - - if totalFields := len(collection.Schema.Fields()); totalFields != expectedCount { - t.Errorf("Expected %d %q fields, got %d", expectedCount, collection.Name, totalFields) - } - } - }, - }, - } - - for _, scenario := range scenarios { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - importedCollections := []*models.Collection{} - - // load data - loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections) - if loadErr != nil { - t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr) - continue - } - - err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err) - } - - // check collections count - collections := []*models.Collection{} - if err := testApp.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - if len(collections) != scenario.expectCollectionsCount { - t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections)) - } - - if scenario.afterTestFunc != nil { - scenario.afterTestFunc(testApp, collections) - } - } -} diff --git a/daos/external_auth.go b/daos/external_auth.go deleted file mode 100644 index 525c273c6960ede9e0fab0d200c34de36f82a1ec..0000000000000000000000000000000000000000 --- a/daos/external_auth.go +++ /dev/null @@ -1,91 +0,0 @@ -package daos - -import ( - "errors" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" -) - -// ExternalAuthQuery returns a new ExternalAuth select query. -func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery { - return dao.ModelQuery(&models.ExternalAuth{}) -} - -/// FindAllExternalAuthsByRecord returns all ExternalAuth models -/// linked to the provided auth record. -func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) { - auths := []*models.ExternalAuth{} - - err := dao.ExternalAuthQuery(). - AndWhere(dbx.HashExp{ - "collectionId": authRecord.Collection().Id, - "recordId": authRecord.Id, - }). - OrderBy("created ASC"). - All(&auths) - - if err != nil { - return nil, err - } - - return auths, nil -} - -// FindExternalAuthByProvider returns the first available -// ExternalAuth model for the specified provider and providerId. -func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) { - model := &models.ExternalAuth{} - - err := dao.ExternalAuthQuery(). - AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds - AndWhere(dbx.HashExp{ - "provider": provider, - "providerId": providerId, - }). - Limit(1). - One(model) - - if err != nil { - return nil, err - } - - return model, nil -} - -// FindExternalAuthByRecordAndProvider returns the first available -// ExternalAuth model for the specified record data and provider. -func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) { - model := &models.ExternalAuth{} - - err := dao.ExternalAuthQuery(). - AndWhere(dbx.HashExp{ - "collectionId": authRecord.Collection().Id, - "recordId": authRecord.Id, - "provider": provider, - }). - Limit(1). - One(model) - - if err != nil { - return nil, err - } - - return model, nil -} - -// SaveExternalAuth upserts the provided ExternalAuth model. -func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error { - // extra check the model data in case the provider's API response - // has changed and no longer returns the expected fields - if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" { - return errors.New("Missing required ExternalAuth fields.") - } - - return dao.Save(model) -} - -// DeleteExternalAuth deletes the provided ExternalAuth model. -func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error { - return dao.Delete(model) -} diff --git a/daos/external_auth_test.go b/daos/external_auth_test.go deleted file mode 100644 index f4d05c080dd04393bf73ef1d01d198b64e457ecf..0000000000000000000000000000000000000000 --- a/daos/external_auth_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package daos_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestExternalAuthQuery(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - expected := "SELECT {{_externalAuths}}.* FROM `_externalAuths`" - - sql := app.Dao().ExternalAuthQuery().Build().SQL() - if sql != expected { - t.Errorf("Expected sql %s, got %s", expected, sql) - } -} - -func TestFindAllExternalAuthsByRecord(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - userId string - expectedCount int - }{ - {"oap640cot4yru2s", 0}, - {"4q1xlclmfloku33", 2}, - } - - for i, s := range scenarios { - record, err := app.Dao().FindRecordById("users", s.userId) - if err != nil { - t.Errorf("(%d) Unexpected record fetch error %v", i, err) - continue - } - - auths, err := app.Dao().FindAllExternalAuthsByRecord(record) - if err != nil { - t.Errorf("(%d) Unexpected auths fetch error %v", i, err) - continue - } - - if len(auths) != s.expectedCount { - t.Errorf("(%d) Expected %d auths, got %d", i, s.expectedCount, len(auths)) - } - - for _, auth := range auths { - if auth.RecordId != record.Id { - t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth) - } - } - } -} - -func TestFindExternalAuthByProvider(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - provider string - providerId string - expectedId string - }{ - {"", "", ""}, - {"github", "", ""}, - {"github", "id1", ""}, - {"github", "id2", ""}, - {"google", "test123", "clmflokuq1xl341"}, - {"gitlab", "test123", "dlmflokuq1xl342"}, - } - - for i, s := range scenarios { - auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId) - - hasErr := err != nil - expectErr := s.expectedId == "" - if hasErr != expectErr { - t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err) - continue - } - - if auth != nil && auth.Id != s.expectedId { - t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth) - } - } -} - -func TestFindExternalAuthByRecordAndProvider(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - userId string - provider string - expectedId string - }{ - {"bgs820n361vj1qd", "google", ""}, - {"4q1xlclmfloku33", "google", "clmflokuq1xl341"}, - {"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"}, - } - - for i, s := range scenarios { - record, err := app.Dao().FindRecordById("users", s.userId) - if err != nil { - t.Errorf("(%d) Unexpected record fetch error %v", i, err) - continue - } - - auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider) - - hasErr := err != nil - expectErr := s.expectedId == "" - if hasErr != expectErr { - t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err) - continue - } - - if auth != nil && auth.Id != s.expectedId { - t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth) - } - } -} - -func TestSaveExternalAuth(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // save with empty provider data - emptyAuth := &models.ExternalAuth{} - if err := app.Dao().SaveExternalAuth(emptyAuth); err == nil { - t.Fatal("Expected error, got nil") - } - - auth := &models.ExternalAuth{ - RecordId: "o1y0dd0spd786md", - CollectionId: "v851q4r790rhknl", - Provider: "test", - ProviderId: "test_id", - } - - if err := app.Dao().SaveExternalAuth(auth); err != nil { - t.Fatal(err) - } - - // check if it was really saved - foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id") - if err != nil { - t.Fatal(err) - } - - if auth.Id != foundAuth.Id { - t.Fatalf("Expected ExternalAuth with id %s, got \n%v", auth.Id, foundAuth) - } -} - -func TestDeleteExternalAuth(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33") - if err != nil { - t.Fatal(err) - } - - auths, err := app.Dao().FindAllExternalAuthsByRecord(record) - if err != nil { - t.Fatal(err) - } - - for _, auth := range auths { - if err := app.Dao().DeleteExternalAuth(auth); err != nil { - t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err) - } - } - - // check if the relations were really deleted - newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record) - if err != nil { - t.Fatal(err) - } - - if len(newAuths) != 0 { - t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths) - } -} diff --git a/daos/param.go b/daos/param.go deleted file mode 100644 index 23ba07dd497c534436dcaa7d8f2296a8b04981da..0000000000000000000000000000000000000000 --- a/daos/param.go +++ /dev/null @@ -1,73 +0,0 @@ -package daos - -import ( - "encoding/json" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/pocketbase/pocketbase/tools/types" -) - -// ParamQuery returns a new Param select query. -func (dao *Dao) ParamQuery() *dbx.SelectQuery { - return dao.ModelQuery(&models.Param{}) -} - -// FindParamByKey finds the first Param model with the provided key. -func (dao *Dao) FindParamByKey(key string) (*models.Param, error) { - param := &models.Param{} - - err := dao.ParamQuery(). - AndWhere(dbx.HashExp{"key": key}). - Limit(1). - One(param) - - if err != nil { - return nil, err - } - - return param, nil -} - -// SaveParam creates or updates a Param model by the provided key-value pair. -// The value argument will be encoded as json string. -// -// If `optEncryptionKey` is provided it will encrypt the value before storing it. -func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) error { - param, _ := dao.FindParamByKey(key) - if param == nil { - param = &models.Param{Key: key} - } - - normalizedValue := value - - // encrypt if optEncryptionKey is set - if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" { - encoded, encodingErr := json.Marshal(value) - if encodingErr != nil { - return encodingErr - } - - encryptVal, encryptErr := security.Encrypt(encoded, optEncryptionKey[0]) - if encryptErr != nil { - return encryptErr - } - - normalizedValue = encryptVal - } - - encodedValue := types.JsonRaw{} - if err := encodedValue.Scan(normalizedValue); err != nil { - return err - } - - param.Value = encodedValue - - return dao.Save(param) -} - -// DeleteParam deletes the provided Param model. -func (dao *Dao) DeleteParam(param *models.Param) error { - return dao.Delete(param) -} diff --git a/daos/param_test.go b/daos/param_test.go deleted file mode 100644 index b364608584a1fde034f4e1ab9967b4bb4a012e10..0000000000000000000000000000000000000000 --- a/daos/param_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package daos_test - -import ( - "encoding/json" - "testing" - - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/pocketbase/pocketbase/tools/types" -) - -func TestParamQuery(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - expected := "SELECT {{_params}}.* FROM `_params`" - - sql := app.Dao().ParamQuery().Build().SQL() - if sql != expected { - t.Errorf("Expected sql %s, got %s", expected, sql) - } -} - -func TestFindParamByKey(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - key string - expectError bool - }{ - {"", true}, - {"missing", true}, - {models.ParamAppSettings, false}, - } - - for i, scenario := range scenarios { - param, err := app.Dao().FindParamByKey(scenario.key) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if param != nil && param.Key != scenario.key { - t.Errorf("(%d) Expected param with identifier %s, got %v", i, scenario.key, param.Key) - } - } -} - -func TestSaveParam(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - key string - value any - }{ - {"", "demo"}, - {"test", nil}, - {"test", ""}, - {"test", 1}, - {"test", 123}, - {models.ParamAppSettings, map[string]any{"test": 123}}, - } - - for i, scenario := range scenarios { - err := app.Dao().SaveParam(scenario.key, scenario.value) - if err != nil { - t.Errorf("(%d) %v", i, err) - } - - jsonRaw := types.JsonRaw{} - jsonRaw.Scan(scenario.value) - encodedScenarioValue, err := jsonRaw.MarshalJSON() - if err != nil { - t.Errorf("(%d) Encoded error %v", i, err) - } - - // check if the param was really saved - param, _ := app.Dao().FindParamByKey(scenario.key) - encodedParamValue, err := param.Value.MarshalJSON() - if err != nil { - t.Errorf("(%d) Encoded error %v", i, err) - } - - if string(encodedParamValue) != string(encodedScenarioValue) { - t.Errorf("(%d) Expected the two values to be equal, got %v vs %v", i, string(encodedParamValue), string(encodedScenarioValue)) - } - } -} - -func TestSaveParamEncrypted(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - encryptionKey := security.RandomString(32) - data := map[string]int{"test": 123} - expected := map[string]int{} - - err := app.Dao().SaveParam("test", data, encryptionKey) - if err != nil { - t.Fatal(err) - } - - // check if the param was really saved - param, _ := app.Dao().FindParamByKey("test") - - // decrypt - decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey) - if decryptErr != nil { - t.Fatal(decryptErr) - } - - // decode - decryptedDecodeErr := json.Unmarshal(decrypted, &expected) - if decryptedDecodeErr != nil { - t.Fatal(decryptedDecodeErr) - } - - // check if the decoded value is correct - if len(expected) != len(data) || expected["test"] != data["test"] { - t.Fatalf("Expected %v, got %v", expected, data) - } -} - -func TestDeleteParam(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // unsaved param - err1 := app.Dao().DeleteParam(&models.Param{}) - if err1 == nil { - t.Fatal("Expected error, got nil") - } - - // existing param - param, _ := app.Dao().FindParamByKey(models.ParamAppSettings) - err2 := app.Dao().DeleteParam(param) - if err2 != nil { - t.Fatalf("Expected nil, got error %v", err2) - } - - // check if it was really deleted - paramCheck, _ := app.Dao().FindParamByKey(models.ParamAppSettings) - if paramCheck != nil { - t.Fatalf("Expected param to be deleted, got %v", paramCheck) - } -} diff --git a/daos/record.go b/daos/record.go deleted file mode 100644 index 1dbbb1bbdf639f0a40086642173c26d823f4e96d..0000000000000000000000000000000000000000 --- a/daos/record.go +++ /dev/null @@ -1,606 +0,0 @@ -package daos - -import ( - "errors" - "fmt" - "strings" - "time" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/inflector" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/pocketbase/pocketbase/tools/types" - "github.com/spf13/cast" -) - -// RecordQuery returns a new Record select query. -func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery { - tableName := collection.Name - selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName)) - - return dao.DB().Select(selectCols).From(tableName) -} - -// FindRecordById finds the Record model by its id. -func (dao *Dao) FindRecordById( - collectionNameOrId string, - recordId string, - optFilters ...func(q *dbx.SelectQuery) error, -) (*models.Record, error) { - collection, err := dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil { - return nil, err - } - - tableName := collection.Name - - query := dao.RecordQuery(collection). - AndWhere(dbx.HashExp{tableName + ".id": recordId}) - - for _, filter := range optFilters { - if filter == nil { - continue - } - if err := filter(query); err != nil { - return nil, err - } - } - - row := dbx.NullStringMap{} - if err := query.Limit(1).One(row); err != nil { - return nil, err - } - - return models.NewRecordFromNullStringMap(collection, row), nil -} - -// FindRecordsByIds finds all Record models by the provided ids. -// If no records are found, returns an empty slice. -func (dao *Dao) FindRecordsByIds( - collectionNameOrId string, - recordIds []string, - optFilters ...func(q *dbx.SelectQuery) error, -) ([]*models.Record, error) { - collection, err := dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil { - return nil, err - } - - query := dao.RecordQuery(collection). - AndWhere(dbx.In( - collection.Name+".id", - list.ToInterfaceSlice(recordIds)..., - )) - - for _, filter := range optFilters { - if filter == nil { - continue - } - if err := filter(query); err != nil { - return nil, err - } - } - - rows := []dbx.NullStringMap{} - if err := query.All(&rows); err != nil { - return nil, err - } - - return models.NewRecordsFromNullStringMaps(collection, rows), nil -} - -// FindRecordsByExpr finds all records by the specified db expression. -// -// Returns all collection records if no expressions are provided. -// -// Returns an empty slice if no records are found. -// -// Example: -// expr1 := dbx.HashExp{"email": "test@example.com"} -// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"}) -// dao.FindRecordsByExpr("example", expr1, expr2) -func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) { - collection, err := dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil { - return nil, err - } - - query := dao.RecordQuery(collection) - - // add only the non-nil expressions - for _, expr := range exprs { - if expr != nil { - query.AndWhere(expr) - } - } - - rows := []dbx.NullStringMap{} - - if err := query.All(&rows); err != nil { - return nil, err - } - - return models.NewRecordsFromNullStringMaps(collection, rows), nil -} - -// FindFirstRecordByData returns the first found record matching -// the provided key-value pair. -func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) { - collection, err := dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil { - return nil, err - } - - row := dbx.NullStringMap{} - - err = dao.RecordQuery(collection). - AndWhere(dbx.HashExp{inflector.Columnify(key): value}). - Limit(1). - One(row) - - if err != nil { - return nil, err - } - - return models.NewRecordFromNullStringMap(collection, row), nil -} - -// IsRecordValueUnique checks if the provided key-value pair is a unique Record value. -// -// For correctness, if the collection is "auth" and the key is "username", -// the unique check will be case insensitive. -// -// NB! Array values (eg. from multiple select fields) are matched -// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness -// depends on the elements order. Or in other words the following values -// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}` -func (dao *Dao) IsRecordValueUnique( - collectionNameOrId string, - key string, - value any, - excludeIds ...string, -) bool { - collection, err := dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil { - return false - } - - var expr dbx.Expression - if collection.IsAuth() && key == schema.FieldNameUsername { - expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{ - "username": strings.ToLower(cast.ToString(value)), - }) - } else { - var normalizedVal any - switch val := value.(type) { - case []string: - normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...) - case []any: - normalizedVal = append(types.JsonArray{}, val...) - default: - normalizedVal = val - } - - expr = dbx.HashExp{inflector.Columnify(key): normalizedVal} - } - - query := dao.RecordQuery(collection). - Select("count(*)"). - AndWhere(expr). - Limit(1) - - if len(excludeIds) > 0 { - uniqueExcludeIds := list.NonzeroUniques(excludeIds) - query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...)) - } - - var exists bool - - return query.Row(&exists) == nil && !exists -} - -// FindAuthRecordByToken finds the auth record associated with the provided JWT token. -// -// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record. -func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) { - unverifiedClaims, err := security.ParseUnverifiedJWT(token) - if err != nil { - return nil, err - } - - // check required claims - id, _ := unverifiedClaims["id"].(string) - collectionId, _ := unverifiedClaims["collectionId"].(string) - if id == "" || collectionId == "" { - return nil, errors.New("Missing or invalid token claims.") - } - - record, err := dao.FindRecordById(collectionId, id) - if err != nil { - return nil, err - } - - if !record.Collection().IsAuth() { - return nil, errors.New("The token is not associated to an auth collection record.") - } - - verificationKey := record.TokenKey() + baseTokenKey - - // verify token signature - if _, err := security.ParseJWT(token, verificationKey); err != nil { - return nil, err - } - - return record, nil -} - -// FindAuthRecordByEmail finds the auth record associated with the provided email. -// -// Returns an error if it is not an auth collection or the record is not found. -func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) { - collection, err := dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil || !collection.IsAuth() { - return nil, errors.New("Missing or not an auth collection.") - } - - row := dbx.NullStringMap{} - - err = dao.RecordQuery(collection). - AndWhere(dbx.HashExp{schema.FieldNameEmail: email}). - Limit(1). - One(row) - - if err != nil { - return nil, err - } - - return models.NewRecordFromNullStringMap(collection, row), nil -} - -// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive). -// -// Returns an error if it is not an auth collection or the record is not found. -func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) { - collection, err := dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil || !collection.IsAuth() { - return nil, errors.New("Missing or not an auth collection.") - } - - row := dbx.NullStringMap{} - - err = dao.RecordQuery(collection). - AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{ - "username": strings.ToLower(username), - })). - Limit(1). - One(row) - - if err != nil { - return nil, err - } - - return models.NewRecordFromNullStringMap(collection, row), nil -} - -// SuggestUniqueAuthRecordUsername checks if the provided username is unique -// and return a new "unique" username with appended random numeric part -// (eg. "existingName" -> "existingName583"). -// -// The same username will be returned if the provided string is already unique. -func (dao *Dao) SuggestUniqueAuthRecordUsername( - collectionNameOrId string, - baseUsername string, - excludeIds ...string, -) string { - username := baseUsername - - for i := 0; i < 10; i++ { // max 10 attempts - isUnique := dao.IsRecordValueUnique( - collectionNameOrId, - schema.FieldNameUsername, - username, - excludeIds..., - ) - if isUnique { - break // already unique - } - username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789") - } - - return username -} - -// SaveRecord upserts the provided Record model. -func (dao *Dao) SaveRecord(record *models.Record) error { - if record.Collection().IsAuth() { - if record.Username() == "" { - return errors.New("unable to save auth record without username") - } - - // Cross-check that the auth record id is unique for all auth collections. - // This is to make sure that the filter `@request.auth.id` always returns a unique id. - authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth) - if err != nil { - return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err) - } - for _, collection := range authCollections { - if record.Collection().Id == collection.Id { - continue // skip current collection (sqlite will do the check for us) - } - isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id) - if !isUnique { - return errors.New("the auth record ID must be unique across all auth collections") - } - } - } - - return dao.Save(record) -} - -// DeleteRecord deletes the provided Record model. -// -// This method will also cascade the delete operation to all linked -// relational records (delete or set to NULL, depending on the rel settings). -// -// The delete operation may fail if the record is part of a required -// reference in another record (aka. cannot be deleted or set to NULL). -func (dao *Dao) DeleteRecord(record *models.Record) error { - const maxAttempts = 6 - - attempts := 1 - -Retry: - err := dao.deleteRecord(record, attempts) - if err != nil && - attempts <= maxAttempts && - // note: we are checking the error msg so that we can handle both the cgo and noncgo errors - strings.Contains(err.Error(), "database is locked") { - time.Sleep(time.Duration(300*attempts) * time.Millisecond) - attempts++ - goto Retry - } - - return err -} - -func (dao *Dao) deleteRecord(record *models.Record, attempts int) error { - return dao.RunInTransaction(func(txDao *Dao) error { - // unset transaction dao before hook on retry to avoid - // triggering the same before callbacks multiple times - if attempts > 1 { - oldBeforeCreateFunc := txDao.BeforeCreateFunc - oldBeforeUpdateFunc := txDao.BeforeUpdateFunc - oldBeforeDeleteFunc := txDao.BeforeDeleteFunc - txDao.BeforeCreateFunc = nil - txDao.BeforeUpdateFunc = nil - txDao.BeforeDeleteFunc = nil - defer func() { - if txDao != nil { - txDao.BeforeCreateFunc = oldBeforeCreateFunc - txDao.BeforeUpdateFunc = oldBeforeUpdateFunc - txDao.BeforeDeleteFunc = oldBeforeDeleteFunc - } - }() - } - - // check for references - refs, err := txDao.FindCollectionReferences(record.Collection()) - if err != nil { - return err - } - - // check if related records has to be deleted (if `CascadeDelete` is set) - // OR - // just unset the record id from any relation field values (if they are not required) - for refCollection, fields := range refs { - for _, field := range fields { - options, _ := field.Options.(*schema.RelationOptions) - - rows := []dbx.NullStringMap{} - - // fetch all referenced records - recordTableName := inflector.Columnify(refCollection.Name) - fieldColumnName := inflector.Columnify(field.Name) - err := txDao.RecordQuery(refCollection). - Distinct(true). - LeftJoin(fmt.Sprintf( - // note: the case is used to normalize value access for single and multiple relations. - `json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{__je__}}`, - fieldColumnName, fieldColumnName, fieldColumnName, - ), nil). - AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": record.Id})). - AndWhere(dbx.HashExp{"__je__.value": record.Id}). - All(&rows) - if err != nil { - return err - } - - refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows) - for _, refRecord := range refRecords { - ids := refRecord.GetStringSlice(field.Name) - - // unset the record id - for i := len(ids) - 1; i >= 0; i-- { - if ids[i] == record.Id { - ids = append(ids[:i], ids[i+1:]...) - break - } - } - - // cascade delete the reference - // (only if there are no other active references in case of multiple select) - if options.CascadeDelete && len(ids) == 0 { - if err := txDao.DeleteRecord(refRecord); err != nil { - return err - } - // no further action are needed (the reference is deleted) - continue - } - - if field.Required && len(ids) == 0 { - return fmt.Errorf("The record cannot be deleted because it is part of a required reference in record %s (%s collection).", refRecord.Id, refCollection.Name) - } - - // save the reference changes - refRecord.Set(field.Name, field.PrepareValue(ids)) - if err := txDao.SaveRecord(refRecord); err != nil { - return err - } - } - } - } - - // delete linked external auths - if record.Collection().IsAuth() { - _, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{ - "collectionId": record.Collection().Id, - "recordId": record.Id, - }).Execute() - if err != nil { - return err - } - } - - return txDao.Delete(record) - }) -} - -// SyncRecordTableSchema compares the two provided collections -// and applies the necessary related record table changes. -// -// If `oldCollection` is null, then only `newCollection` is used to create the record table. -func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error { - // create - if oldCollection == nil { - cols := map[string]string{ - schema.FieldNameId: "TEXT PRIMARY KEY", - schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL", - schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL", - } - - if newCollection.IsAuth() { - cols[schema.FieldNameUsername] = "TEXT NOT NULL" - cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL" - cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL" - cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL" - cols[schema.FieldNameTokenKey] = "TEXT NOT NULL" - cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL" - cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL" - cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL" - } - - // ensure that the new collection has an id - if !newCollection.HasId() { - newCollection.RefreshId() - newCollection.MarkAsNew() - } - - tableName := newCollection.Name - - // add schema field definitions - for _, field := range newCollection.Schema.Fields() { - cols[field.Name] = field.ColDefinition() - } - - // create table - if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil { - return err - } - - // add named index on the base `created` column - if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil { - return err - } - - // add named unique index on the email and tokenKey columns - if newCollection.IsAuth() { - _, err := dao.DB().NewQuery(fmt.Sprintf( - ` - CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]); - CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != ''; - CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]); - `, - newCollection.Id, tableName, - newCollection.Id, tableName, - newCollection.Id, tableName, - )).Execute() - if err != nil { - return err - } - } - - return nil - } - - // update - return dao.RunInTransaction(func(txDao *Dao) error { - oldTableName := oldCollection.Name - newTableName := newCollection.Name - oldSchema := oldCollection.Schema - newSchema := newCollection.Schema - - // check for renamed table - if !strings.EqualFold(oldTableName, newTableName) { - _, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute() - if err != nil { - return err - } - } - - // check for deleted columns - for _, oldField := range oldSchema.Fields() { - if f := newSchema.GetFieldById(oldField.Id); f != nil { - continue // exist - } - - _, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute() - if err != nil { - return err - } - } - - // check for new or renamed columns - toRename := map[string]string{} - for _, field := range newSchema.Fields() { - oldField := oldSchema.GetFieldById(field.Id) - // Note: - // We are using a temporary column name when adding or renaming columns - // to ensure that there are no name collisions in case there is - // names switch/reuse of existing columns (eg. name, title -> title, name). - // This way we are always doing 1 more rename operation but it provides better dev experience. - - if oldField == nil { - tempName := field.Name + security.PseudorandomString(5) - toRename[tempName] = field.Name - - // add - _, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute() - if err != nil { - return err - } - } else if oldField.Name != field.Name { - tempName := field.Name + security.PseudorandomString(5) - toRename[tempName] = field.Name - - // rename - _, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute() - if err != nil { - return err - } - } - } - - // set the actual columns name - for tempName, actualName := range toRename { - _, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute() - if err != nil { - return err - } - } - - return nil - }) -} diff --git a/daos/record_expand.go b/daos/record_expand.go deleted file mode 100644 index b7e3c7e40fa4bb4dbd33c7032047b58064efc8d9..0000000000000000000000000000000000000000 --- a/daos/record_expand.go +++ /dev/null @@ -1,274 +0,0 @@ -package daos - -import ( - "errors" - "fmt" - "regexp" - "strings" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/inflector" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/pocketbase/pocketbase/tools/types" -) - -// MaxExpandDepth specifies the max allowed nested expand depth path. -const MaxExpandDepth = 6 - -// ExpandFetchFunc defines the function that is used to fetch the expanded relation records. -type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) - -// ExpandRecord expands the relations of a single Record model. -// -// Returns a map with the failed expand parameters and their errors. -func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error { - return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc) -} - -// ExpandRecords expands the relations of the provided Record models list. -// -// Returns a map with the failed expand parameters and their errors. -func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error { - normalized := normalizeExpands(expands) - - failed := map[string]error{} - - for _, expand := range normalized { - if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil { - failed[expand] = err - } - } - - return failed -} - -var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`) - -// notes: -// - fetchFunc must be non-nil func -// - all records are expected to be from the same collection -// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path -// - indirect expands are supported only with single relation fields -func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error { - if fetchFunc == nil { - return errors.New("Relation records fetchFunc is not set.") - } - - if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 { - return nil - } - - mainCollection := records[0].Collection() - - var relField *schema.SchemaField - var relFieldOptions *schema.RelationOptions - var relCollection *models.Collection - - parts := strings.SplitN(expandPath, ".", 2) - matches := indirectExpandRegex.FindStringSubmatch(parts[0]) - - if len(matches) == 3 { - indirectRel, _ := dao.FindCollectionByNameOrId(matches[1]) - if indirectRel == nil { - return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1]) - } - - indirectRelField := indirectRel.Schema.GetFieldByName(matches[2]) - if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation { - return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name) - } - - indirectRelField.InitOptions() - indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions) - if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id { - return fmt.Errorf("Invalid indirect relation field path %q.", parts[0]) - } - if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 { - // for now don't allow multi-relation indirect fields expand - // due to eventual poor query performance with large data sets. - return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0]) - } - - recordIds := make([]any, len(records)) - for i, record := range records { - recordIds[i] = record.Id - } - - indirectRecords, err := dao.FindRecordsByExpr( - indirectRel.Id, - dbx.In(inflector.Columnify(matches[2]), recordIds...), - ) - if err != nil { - return err - } - mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords)) - for _, indirectRecord := range indirectRecords { - recId := indirectRecord.GetString(matches[2]) - if recId != "" { - mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id) - } - } - - // add the indirect relation ids as a new relation field value - for _, record := range records { - relIds, ok := mappedIndirectRecordIds[record.Id] - if ok && len(relIds) > 0 { - record.Set(parts[0], relIds) - } - } - - relFieldOptions = &schema.RelationOptions{ - MaxSelect: nil, - CollectionId: indirectRel.Id, - } - if indirectRelField.Unique { - relFieldOptions.MaxSelect = types.Pointer(1) - } - // indirect relation - relField = &schema.SchemaField{ - Id: "indirect_" + security.PseudorandomString(5), - Type: schema.FieldTypeRelation, - Name: parts[0], - Options: relFieldOptions, - } - relCollection = indirectRel - } else { - // direct relation - relField = mainCollection.Schema.GetFieldByName(parts[0]) - if relField == nil || relField.Type != schema.FieldTypeRelation { - return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name) - } - relField.InitOptions() - relFieldOptions, _ = relField.Options.(*schema.RelationOptions) - if relFieldOptions == nil { - return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0]) - } - - relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId) - if relCollection == nil { - return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId) - } - } - - // --------------------------------------------------------------- - - // extract the id of the relations to expand - relIds := make([]string, 0, len(records)) - for _, record := range records { - relIds = append(relIds, record.GetStringSlice(relField.Name)...) - } - - // fetch rels - rels, relsErr := fetchFunc(relCollection, relIds) - if relsErr != nil { - return relsErr - } - - // expand nested fields - if len(parts) > 1 { - err := dao.expandRecords(rels, parts[1], fetchFunc, recursionLevel+1) - if err != nil { - return err - } - } - - // reindex with the rel id - indexedRels := map[string]*models.Record{} - for _, rel := range rels { - indexedRels[rel.GetId()] = rel - } - - for _, model := range records { - relIds := model.GetStringSlice(relField.Name) - - validRels := make([]*models.Record, 0, len(relIds)) - for _, id := range relIds { - if rel, ok := indexedRels[id]; ok { - validRels = append(validRels, rel) - } - } - - if len(validRels) == 0 { - continue // no valid relations - } - - expandData := model.Expand() - - // normalize access to the previously expanded rel records (if any) - var oldExpandedRels []*models.Record - switch v := expandData[relField.Name].(type) { - case nil: - // no old expands - case *models.Record: - oldExpandedRels = []*models.Record{v} - case []*models.Record: - oldExpandedRels = v - } - - // merge expands - for _, oldExpandedRel := range oldExpandedRels { - // find a matching rel record - for _, rel := range validRels { - if rel.Id != oldExpandedRel.Id { - continue - } - - oldRelExpand := oldExpandedRel.Expand() - newRelExpand := rel.Expand() - for k, v := range oldRelExpand { - newRelExpand[k] = v - } - rel.SetExpand(newRelExpand) - } - } - - // update the expanded data - if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 { - expandData[relField.Name] = validRels[0] - } else { - expandData[relField.Name] = validRels - } - - model.SetExpand(expandData) - } - - return nil -} - -// normalizeExpands normalizes expand strings and merges self containing paths -// (eg. ["a.b.c", "a.b", " test ", " ", "test"] -> ["a.b.c", "test"]). -func normalizeExpands(paths []string) []string { - // normalize paths - normalized := make([]string, 0, len(paths)) - for _, p := range paths { - p = strings.ReplaceAll(p, " ", "") // replace spaces - p = strings.Trim(p, ".") // trim incomplete paths - if p != "" { - normalized = append(normalized, p) - } - } - - // merge containing paths - result := make([]string, 0, len(normalized)) - for i, p1 := range normalized { - var skip bool - for j, p2 := range normalized { - if i == j { - continue - } - if strings.HasPrefix(p2, p1+".") { - // skip because there is more detailed expand path - skip = true - break - } - } - if !skip { - result = append(result, p1) - } - } - - return list.ToUniqueStringSlice(result) -} diff --git a/daos/record_expand_test.go b/daos/record_expand_test.go deleted file mode 100644 index d5059bd21d039d1e901f14cdc735a55a147f0b3b..0000000000000000000000000000000000000000 --- a/daos/record_expand_test.go +++ /dev/null @@ -1,348 +0,0 @@ -package daos_test - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/list" -) - -func TestExpandRecords(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - testName string - collectionIdOrName string - recordIds []string - expands []string - fetchFunc daos.ExpandFetchFunc - expectExpandProps int - expectExpandFailures int - }{ - { - "empty records", - "", - []string{}, - []string{"self_rel_one", "self_rel_many.self_rel_one"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 0, - }, - { - "empty expand", - "demo4", - []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, - []string{}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 0, - }, - { - "empty fetchFunc", - "demo4", - []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, - []string{"self_rel_one", "self_rel_many.self_rel_one"}, - nil, - 0, - 2, - }, - { - "fetchFunc with error", - "demo4", - []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, - []string{"self_rel_one", "self_rel_many.self_rel_one"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return nil, errors.New("test error") - }, - 0, - 2, - }, - { - "missing relation field", - "demo4", - []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, - []string{"missing"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 1, - }, - { - "existing, but non-relation type field", - "demo4", - []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, - []string{"title"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 1, - }, - { - "invalid/missing second level expand", - "demo4", - []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, - []string{"rel_one_no_cascade.title"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 1, - }, - { - "expand normalizations", - "demo4", - []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"}, - []string{ - "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade", - "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade", - "self_rel_many", "self_rel_many.", - " self_rel_many ", "", - }, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 9, - 0, - }, - { - "single expand", - "users", - []string{ - "bgs820n361vj1qd", - "4q1xlclmfloku33", - "oap640cot4yru2s", // no rels - }, - []string{"rel"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 2, - 0, - }, - { - "maxExpandDepth reached", - "demo4", - []string{"qzaqccwrmva4o1n"}, - []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 6, - 0, - }, - { - "simple indirect expand", - "demo3", - []string{"lcl9d87w22ml6jy"}, - []string{"demo4(rel_one_no_cascade_required)"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 1, - 0, - }, - { - "nested indirect expand", - "demo3", - []string{"lcl9d87w22ml6jy"}, - []string{ - "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one", - }, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 5, - 0, - }, - } - - for _, s := range scenarios { - ids := list.ToUniqueStringSlice(s.recordIds) - records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids) - failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc) - - if len(failed) != s.expectExpandFailures { - t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed) - } - - encoded, _ := json.Marshal(records) - encodedStr := string(encoded) - totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand) - - if s.expectExpandProps != totalExpandProps { - t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr) - } - } -} - -func TestExpandRecord(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - testName string - collectionIdOrName string - recordId string - expands []string - fetchFunc daos.ExpandFetchFunc - expectExpandProps int - expectExpandFailures int - }{ - { - "empty expand", - "demo4", - "i9naidtvr6qsgb4", - []string{}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 0, - }, - { - "empty fetchFunc", - "demo4", - "i9naidtvr6qsgb4", - []string{"self_rel_one", "self_rel_many.self_rel_one"}, - nil, - 0, - 2, - }, - { - "fetchFunc with error", - "demo4", - "i9naidtvr6qsgb4", - []string{"self_rel_one", "self_rel_many.self_rel_one"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return nil, errors.New("test error") - }, - 0, - 2, - }, - { - "missing relation field", - "demo4", - "i9naidtvr6qsgb4", - []string{"missing"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 1, - }, - { - "existing, but non-relation type field", - "demo4", - "i9naidtvr6qsgb4", - []string{"title"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 1, - }, - { - "invalid/missing second level expand", - "demo4", - "qzaqccwrmva4o1n", - []string{"rel_one_no_cascade.title"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 1, - }, - { - "expand normalizations", - "demo4", - "qzaqccwrmva4o1n", - []string{ - "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade", - "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade", - "self_rel_many", "self_rel_many.", - " self_rel_many ", "", - }, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 8, - 0, - }, - { - "no rels to expand", - "users", - "oap640cot4yru2s", - []string{"rel"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 0, - 0, - }, - { - "maxExpandDepth reached", - "demo4", - "qzaqccwrmva4o1n", - []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 6, - 0, - }, - { - "simple indirect expand", - "demo3", - "lcl9d87w22ml6jy", - []string{"demo4(rel_one_no_cascade_required)"}, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 1, - 0, - }, - { - "nested indirect expand", - "demo3", - "lcl9d87w22ml6jy", - []string{ - "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one", - }, - func(c *models.Collection, ids []string) ([]*models.Record, error) { - return app.Dao().FindRecordsByIds(c.Id, ids, nil) - }, - 5, - 0, - }, - } - - for _, s := range scenarios { - record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId) - failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc) - - if len(failed) != s.expectExpandFailures { - t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed) - } - - encoded, _ := json.Marshal(record) - encodedStr := string(encoded) - totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand) - - if s.expectExpandProps != totalExpandProps { - t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr) - } - } -} diff --git a/daos/record_test.go b/daos/record_test.go deleted file mode 100644 index f21004d2cc452f40ca52d200613b82816c55d432..0000000000000000000000000000000000000000 --- a/daos/record_test.go +++ /dev/null @@ -1,754 +0,0 @@ -package daos_test - -import ( - "errors" - "fmt" - "regexp" - "strings" - "testing" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/list" -) - -func TestRecordQuery(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo1") - if err != nil { - t.Fatal(err) - } - - expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name) - - sql := app.Dao().RecordQuery(collection).Build().SQL() - if sql != expected { - t.Errorf("Expected sql %s, got %s", expected, sql) - } -} - -func TestFindRecordById(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionIdOrName string - id string - filter1 func(q *dbx.SelectQuery) error - filter2 func(q *dbx.SelectQuery) error - expectError bool - }{ - {"demo2", "missing", nil, nil, true}, - {"missing", "0yxhwia2amd8gec", nil, nil, true}, - {"demo2", "0yxhwia2amd8gec", nil, nil, false}, - {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"title": "missing"}) - return nil - }, nil, true}, - {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error { - return errors.New("test error") - }, nil, true}, - {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"title": "test3"}) - return nil - }, nil, false}, - {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"title": "test3"}) - return nil - }, func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"active": false}) - return nil - }, true}, - {"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"title": "test3"}) - return nil - }, func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"active": true}) - return nil - }, false}, - } - - for i, scenario := range scenarios { - record, err := app.Dao().FindRecordById( - scenario.collectionIdOrName, - scenario.id, - scenario.filter1, - scenario.filter2, - ) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if record != nil && record.Id != scenario.id { - t.Errorf("(%d) Expected record with id %s, got %s", i, scenario.id, record.Id) - } - } -} - -func TestFindRecordsByIds(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionIdOrName string - ids []string - filter1 func(q *dbx.SelectQuery) error - filter2 func(q *dbx.SelectQuery) error - expectTotal int - expectError bool - }{ - {"demo2", []string{}, nil, nil, 0, false}, - {"demo2", []string{""}, nil, nil, 0, false}, - {"demo2", []string{"missing"}, nil, nil, 0, false}, - {"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true}, - {"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false}, - {"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false}, - { - "demo2", - []string{"0yxhwia2amd8gec", "llvuca81nly1qls"}, - nil, - nil, - 2, - false, - }, - { - "demo2", - []string{"0yxhwia2amd8gec", "llvuca81nly1qls"}, - func(q *dbx.SelectQuery) error { - return nil // empty filter - }, - func(q *dbx.SelectQuery) error { - return errors.New("test error") - }, - 0, - true, - }, - { - "demo2", - []string{"0yxhwia2amd8gec", "llvuca81nly1qls"}, - func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"active": true}) - return nil - }, - nil, - 1, - false, - }, - { - "sz5l5z67tg7gku0", - []string{"0yxhwia2amd8gec", "llvuca81nly1qls"}, - func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.HashExp{"active": true}) - return nil - }, - func(q *dbx.SelectQuery) error { - q.AndWhere(dbx.Not(dbx.HashExp{"title": ""})) - return nil - }, - 1, - false, - }, - } - - for i, scenario := range scenarios { - records, err := app.Dao().FindRecordsByIds( - scenario.collectionIdOrName, - scenario.ids, - scenario.filter1, - scenario.filter2, - ) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if len(records) != scenario.expectTotal { - t.Errorf("(%d) Expected %d records, got %d", i, scenario.expectTotal, len(records)) - continue - } - - for _, r := range records { - if !list.ExistInSlice(r.Id, scenario.ids) { - t.Errorf("(%d) Couldn't find id %s in %v", i, r.Id, scenario.ids) - } - } - } -} - -func TestFindRecordsByExpr(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionIdOrName string - expressions []dbx.Expression - expectIds []string - expectError bool - }{ - { - "missing", - nil, - []string{}, - true, - }, - { - "demo2", - nil, - []string{ - "achvryl401bhse3", - "llvuca81nly1qls", - "0yxhwia2amd8gec", - }, - false, - }, - { - "demo2", - []dbx.Expression{ - nil, - dbx.HashExp{"id": "123"}, - }, - []string{}, - false, - }, - { - "sz5l5z67tg7gku0", - []dbx.Expression{ - dbx.Like("title", "test").Match(true, true), - dbx.HashExp{"active": true}, - }, - []string{ - "achvryl401bhse3", - "0yxhwia2amd8gec", - }, - false, - }, - } - - for i, scenario := range scenarios { - records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if len(records) != len(scenario.expectIds) { - t.Errorf("(%d) Expected %d records, got %d", i, len(scenario.expectIds), len(records)) - continue - } - - for _, r := range records { - if !list.ExistInSlice(r.Id, scenario.expectIds) { - t.Errorf("(%d) Couldn't find id %s in %v", i, r.Id, scenario.expectIds) - } - } - } -} - -func TestFindFirstRecordByData(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionIdOrName string - key string - value any - expectId string - expectError bool - }{ - { - "missing", - "id", - "llvuca81nly1qls", - "llvuca81nly1qls", - true, - }, - { - "demo2", - "", - "llvuca81nly1qls", - "", - true, - }, - { - "demo2", - "id", - "invalid", - "", - true, - }, - { - "demo2", - "id", - "llvuca81nly1qls", - "llvuca81nly1qls", - false, - }, - { - "sz5l5z67tg7gku0", - "title", - "test3", - "0yxhwia2amd8gec", - false, - }, - } - - for i, scenario := range scenarios { - record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - continue - } - - if !scenario.expectError && record.Id != scenario.expectId { - t.Errorf("(%d) Expected record with id %s, got %v", i, scenario.expectId, record.Id) - } - } -} - -func TestIsRecordValueUnique(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - testManyRelsId1 := "bgs820n361vj1qd" - testManyRelsId2 := "4q1xlclmfloku33" - testManyRelsId3 := "oap640cot4yru2s" - - scenarios := []struct { - collectionIdOrName string - key string - value any - excludeIds []string - expected bool - }{ - {"demo2", "", "", nil, false}, - {"demo2", "", "", []string{""}, false}, - {"demo2", "missing", "unique", nil, false}, - {"demo2", "title", "unique", nil, true}, - {"demo2", "title", "unique", []string{}, true}, - {"demo2", "title", "unique", []string{""}, true}, - {"demo2", "title", "test1", []string{""}, false}, - {"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true}, - {"demo1", "rel_many", []string{testManyRelsId3}, nil, false}, - {"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false}, - {"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true}, - // mixed json array order - {"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true}, - // username special case-insensitive match - {"users", "username", "test2_username", nil, false}, - {"users", "username", "TEST2_USERNAME", nil, false}, - {"users", "username", "new_username", nil, true}, - {"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true}, - } - - for i, scenario := range scenarios { - result := app.Dao().IsRecordValueUnique( - scenario.collectionIdOrName, - scenario.key, - scenario.value, - scenario.excludeIds..., - ) - - if result != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) - } - } -} - -func TestFindAuthRecordByToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - token string - baseKey string - expectedEmail string - expectError bool - }{ - // invalid auth token - { - "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw", - app.Settings().RecordAuthToken.Secret, - "", - true, - }, - // expired token - { - "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w", - app.Settings().RecordAuthToken.Secret, - "", - true, - }, - // wrong base key (password reset token secret instead of auth secret) - { - "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - app.Settings().RecordPasswordResetToken.Secret, - "", - true, - }, - // valid token and base key but with deleted/missing collection - { - "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU", - app.Settings().RecordAuthToken.Secret, - "test@example.com", - true, - }, - // valid token - { - "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc", - app.Settings().RecordAuthToken.Secret, - "test@example.com", - false, - }, - } - - for i, scenario := range scenarios { - record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - continue - } - - if !scenario.expectError && record.Email() != scenario.expectedEmail { - t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email()) - } - } -} - -func TestFindAuthRecordByEmail(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionIdOrName string - email string - expectError bool - }{ - {"missing", "test@example.com", true}, - {"demo2", "test@example.com", true}, - {"users", "missing@example.com", true}, - {"users", "test@example.com", false}, - {"clients", "test2@example.com", false}, - } - - for i, scenario := range scenarios { - record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, scenario.email) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - continue - } - - if !scenario.expectError && record.Email() != scenario.email { - t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email()) - } - } -} - -func TestFindAuthRecordByUsername(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionIdOrName string - username string - expectError bool - }{ - {"missing", "test_username", true}, - {"demo2", "test_username", true}, - {"users", "missing", true}, - {"users", "test2_username", false}, - {"users", "TEST2_USERNAME", false}, // case insensitive check - {"clients", "clients43362", false}, - } - - for i, scenario := range scenarios { - record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - continue - } - - if !scenario.expectError && !strings.EqualFold(record.Username(), scenario.username) { - t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username()) - } - } -} - -func TestSuggestUniqueAuthRecordUsername(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - collectionIdOrName string - baseUsername string - expectedPattern string - }{ - // missing collection - {"missing", "test2_username", `^test2_username\d{12}$`}, - // not an auth collection - {"demo2", "test2_username", `^test2_username\d{12}$`}, - // auth collection with unique base username - {"users", "new_username", `^new_username$`}, - {"users", "NEW_USERNAME", `^NEW_USERNAME$`}, - // auth collection with existing username - {"users", "test2_username", `^test2_username\d{3}$`}, - {"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`}, - } - - for i, scenario := range scenarios { - username := app.Dao().SuggestUniqueAuthRecordUsername( - scenario.collectionIdOrName, - scenario.baseUsername, - ) - - pattern, err := regexp.Compile(scenario.expectedPattern) - if err != nil { - t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err) - } - if !pattern.MatchString(username) { - t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username) - } - } -} - -func TestSaveRecord(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, _ := app.Dao().FindCollectionByNameOrId("demo2") - - // create - // --- - r1 := models.NewRecord(collection) - r1.Set("title", "test_new") - err1 := app.Dao().SaveRecord(r1) - if err1 != nil { - t.Fatal(err1) - } - newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new") - if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") { - t.Fatalf("Expected to find record %v, got %v", r1, newR1) - } - - // update - // --- - r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec") - r2.Set("title", "test_update") - err2 := app.Dao().SaveRecord(r2) - if err2 != nil { - t.Fatal(err2) - } - newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update") - if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") { - t.Fatalf("Expected to find record %v, got %v", r2, newR2) - } -} - -func TestSaveRecordWithIdFromOtherCollection(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2") - authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin") - - // base collection test - r1 := models.NewRecord(baseCollection) - r1.Set("title", "test_new") - r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record - r1.MarkAsNew() - if err := app.Dao().SaveRecord(r1); err != nil { - t.Fatalf("Expected nil, got error %v", err) - } - - // auth collection test - r2 := models.NewRecord(authCollection) - r2.Set("username", "test_new") - r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record - r2.MarkAsNew() - if err := app.Dao().SaveRecord(r2); err == nil { - t.Fatal("Expected error, got nil") - } - - // try again with unique id - r2.Set("id", "unique_id") - if err := app.Dao().SaveRecord(r2); err != nil { - t.Fatalf("Expected nil, got error %v", err) - } -} - -func TestDeleteRecord(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - demoCollection, _ := app.Dao().FindCollectionByNameOrId("demo2") - - // delete unsaved record - // --- - rec0 := models.NewRecord(demoCollection) - if err := app.Dao().DeleteRecord(rec0); err == nil { - t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record") - } - - // delete existing record + external auths - // --- - rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33") - if err := app.Dao().DeleteRecord(rec1); err != nil { - t.Fatalf("(rec1) Expected nil, got error %v", err) - } - // check if it was really deleted - if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil { - t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed) - } - // check if the external auths were deleted - if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 { - t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths) - } - - // delete existing record while being part of a non-cascade required relation - // --- - rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm") - if err := app.Dao().DeleteRecord(rec2); err == nil { - t.Fatalf("(rec2) Expected error, got nil") - } - - // delete existing record + cascade - // --- - rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s") - if err := app.Dao().DeleteRecord(rec3); err != nil { - t.Fatalf("(rec3) Expected nil, got error %v", err) - } - // check if it was really deleted - rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id) - if rec3 != nil { - t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3) - } - // check if the operation cascaded - rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t") - if rel != nil { - t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel) - } -} - -func TestSyncRecordTableSchema(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2") - if err != nil { - t.Fatal(err) - } - updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2") - if err != nil { - t.Fatal(err) - } - updatedCollection.Name = "demo_renamed" - updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id) - updatedCollection.Schema.AddField( - &schema.SchemaField{ - Name: "new_field", - Type: schema.FieldTypeEmail, - }, - ) - updatedCollection.Schema.AddField( - &schema.SchemaField{ - Id: updatedCollection.Schema.GetFieldByName("title").Id, - Name: "title_renamed", - Type: schema.FieldTypeEmail, - }, - ) - - scenarios := []struct { - newCollection *models.Collection - oldCollection *models.Collection - expectedTableName string - expectedColumns []string - }{ - // new base collection - { - &models.Collection{ - Name: "new_table", - Schema: schema.NewSchema( - &schema.SchemaField{ - Name: "test", - Type: schema.FieldTypeText, - }, - ), - }, - nil, - "new_table", - []string{"id", "created", "updated", "test"}, - }, - // new auth collection - { - &models.Collection{ - Name: "new_table_auth", - Type: models.CollectionTypeAuth, - Schema: schema.NewSchema( - &schema.SchemaField{ - Name: "test", - Type: schema.FieldTypeText, - }, - ), - }, - nil, - "new_table_auth", - []string{ - "id", "created", "updated", "test", - "username", "email", "verified", "emailVisibility", - "tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt", - }, - }, - // no changes - { - oldCollection, - oldCollection, - "demo3", - []string{"id", "created", "updated", "title", "active"}, - }, - // renamed table, deleted column, renamed columnd and new column - { - updatedCollection, - oldCollection, - "demo_renamed", - []string{"id", "created", "updated", "title_renamed", "new_field"}, - }, - } - - for i, scenario := range scenarios { - err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection) - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - - if !app.Dao().HasTable(scenario.newCollection.Name) { - t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name) - } - - cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name) - if len(cols) != len(scenario.expectedColumns) { - t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols) - } - - for _, c := range cols { - if !list.ExistInSlice(c, scenario.expectedColumns) { - t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns) - } - } - } -} diff --git a/daos/request.go b/daos/request.go deleted file mode 100644 index 6616b9f335988dbb2b72285e4f30f87e7893e148..0000000000000000000000000000000000000000 --- a/daos/request.go +++ /dev/null @@ -1,70 +0,0 @@ -package daos - -import ( - "time" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/types" -) - -// RequestQuery returns a new Request logs select query. -func (dao *Dao) RequestQuery() *dbx.SelectQuery { - return dao.ModelQuery(&models.Request{}) -} - -// FindRequestById finds a single Request log by its id. -func (dao *Dao) FindRequestById(id string) (*models.Request, error) { - model := &models.Request{} - - err := dao.RequestQuery(). - AndWhere(dbx.HashExp{"id": id}). - Limit(1). - One(model) - - if err != nil { - return nil, err - } - - return model, nil -} - -type RequestsStatsItem struct { - Total int `db:"total" json:"total"` - Date types.DateTime `db:"date" json:"date"` -} - -// RequestsStats returns hourly grouped requests logs statistics. -func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) { - result := []*RequestsStatsItem{} - - query := dao.RequestQuery(). - Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date"). - GroupBy("date") - - if expr != nil { - query.AndWhere(expr) - } - - err := query.All(&result) - - return result, err -} - -// DeleteOldRequests delete all requests that are created before createdBefore. -func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error { - m := models.Request{} - tableName := m.TableName() - - formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout) - expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate}) - - _, err := dao.DB().Delete(tableName, expr).Execute() - - return err -} - -// SaveRequest upserts the provided Request model. -func (dao *Dao) SaveRequest(request *models.Request) error { - return dao.Save(request) -} diff --git a/daos/request_test.go b/daos/request_test.go deleted file mode 100644 index e41b8e399d390c8145ddef52f4c52f620c3a6670..0000000000000000000000000000000000000000 --- a/daos/request_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package daos_test - -import ( - "encoding/json" - "testing" - "time" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/types" -) - -func TestRequestQuery(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - expected := "SELECT {{_requests}}.* FROM `_requests`" - - sql := app.Dao().RequestQuery().Build().SQL() - if sql != expected { - t.Errorf("Expected sql %s, got %s", expected, sql) - } -} - -func TestFindRequestById(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - tests.MockRequestLogsData(app) - - scenarios := []struct { - id string - expectError bool - }{ - {"", true}, - {"invalid", true}, - {"00000000-9f38-44fb-bf82-c8f53b310d91", true}, - {"873f2133-9f38-44fb-bf82-c8f53b310d91", false}, - } - - for i, scenario := range scenarios { - admin, err := app.LogsDao().FindRequestById(scenario.id) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - } - - if admin != nil && admin.Id != scenario.id { - t.Errorf("(%d) Expected admin with id %s, got %s", i, scenario.id, admin.Id) - } - } -} - -func TestRequestsStats(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - tests.MockRequestLogsData(app) - - expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]` - - now := time.Now().UTC().Format(types.DefaultDateLayout) - exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now}) - result, err := app.LogsDao().RequestsStats(exp) - if err != nil { - t.Fatal(err) - } - - encoded, _ := json.Marshal(result) - if string(encoded) != expected { - t.Fatalf("Expected %s, got %s", expected, string(encoded)) - } -} - -func TestDeleteOldRequests(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - tests.MockRequestLogsData(app) - - scenarios := []struct { - date string - expectedTotal int - }{ - {"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time - {"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left - {"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left - {"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left - } - - for i, scenario := range scenarios { - date, dateErr := time.Parse(types.DefaultDateLayout, scenario.date) - if dateErr != nil { - t.Errorf("(%d) Date error %v", i, dateErr) - } - - deleteErr := app.LogsDao().DeleteOldRequests(date) - if deleteErr != nil { - t.Errorf("(%d) Delete error %v", i, deleteErr) - } - - // check total remaining requests - var total int - countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total) - if countErr != nil { - t.Errorf("(%d) Count error %v", i, countErr) - } - - if total != scenario.expectedTotal { - t.Errorf("(%d) Expected %d remaining requests, got %d", i, scenario.expectedTotal, total) - } - } -} - -func TestSaveRequest(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - tests.MockRequestLogsData(app) - - // create new request - newRequest := &models.Request{} - newRequest.Method = "get" - newRequest.Meta = types.JsonMap{} - createErr := app.LogsDao().SaveRequest(newRequest) - if createErr != nil { - t.Fatal(createErr) - } - - // check if it was really created - existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id) - if fetchErr != nil { - t.Fatal(fetchErr) - } - - existingRequest.Method = "post" - updateErr := app.LogsDao().SaveRequest(existingRequest) - if updateErr != nil { - t.Fatal(updateErr) - } - // refresh instance to check if it was really updated - existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id) - if existingRequest.Method != "post" { - t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method) - } -} diff --git a/daos/settings.go b/daos/settings.go deleted file mode 100644 index f4830e7be7f22d52c9d0de1377dbd6aca2cb0fdc..0000000000000000000000000000000000000000 --- a/daos/settings.go +++ /dev/null @@ -1,63 +0,0 @@ -package daos - -import ( - "encoding/json" - "errors" - - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/settings" - "github.com/pocketbase/pocketbase/tools/security" -) - -// FindSettings returns and decode the serialized app settings param value. -// -// The method will first try to decode the param value without decryption. -// If it fails and optEncryptionKey is set, it will try again by first -// decrypting the value and then decode it again. -// -// Returns an error if it fails to decode the stored serialized param value. -func (dao *Dao) FindSettings(optEncryptionKey ...string) (*settings.Settings, error) { - param, err := dao.FindParamByKey(models.ParamAppSettings) - if err != nil { - return nil, err - } - - result := settings.New() - - // try first without decryption - plainDecodeErr := json.Unmarshal(param.Value, result) - - // failed, try to decrypt - if plainDecodeErr != nil { - var encryptionKey string - if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" { - encryptionKey = optEncryptionKey[0] - } - - // load without decrypt has failed and there is no encryption key to use for decrypt - if encryptionKey == "" { - return nil, errors.New("failed to load the stored app settings - missing or invalid encryption key") - } - - // decrypt - decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey) - if decryptErr != nil { - return nil, decryptErr - } - - // decode again - decryptedDecodeErr := json.Unmarshal(decrypted, result) - if decryptedDecodeErr != nil { - return nil, decryptedDecodeErr - } - } - - return result, nil -} - -// SaveSettings persists the specified settings configuration. -// -// If optEncryptionKey is set, then the stored serialized value will be encrypted with it. -func (dao *Dao) SaveSettings(newSettings *settings.Settings, optEncryptionKey ...string) error { - return dao.SaveParam(models.ParamAppSettings, newSettings, optEncryptionKey...) -} diff --git a/daos/settings_test.go b/daos/settings_test.go deleted file mode 100644 index 3791f42b8b25127ba25c58aa30697604cac10c19..0000000000000000000000000000000000000000 --- a/daos/settings_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package daos_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" -) - -func TestSaveAndFindSettings(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - encryptionKey := security.PseudorandomString(32) - - // change unencrypted app settings - app.Settings().Meta.AppName = "save_unencrypted" - if err := app.Dao().SaveSettings(app.Settings()); err != nil { - t.Fatal(err) - } - - // check if the change was persisted - s1, err := app.Dao().FindSettings() - if err != nil { - t.Fatalf("Failed to fetch settings: %v", err) - } - if s1.Meta.AppName != "save_unencrypted" { - t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_unencrypted", s1) - } - - // make another change but this time provide an encryption key - app.Settings().Meta.AppName = "save_encrypted" - if err := app.Dao().SaveSettings(app.Settings(), encryptionKey); err != nil { - t.Fatal(err) - } - - // try to fetch the settings without encryption key (should fail) - if s2, err := app.Dao().FindSettings(); err == nil { - t.Fatalf("Expected FindSettings to fail without an encryption key, got \n%v", s2) - } - - // try again but this time with an encryption key - s3, err := app.Dao().FindSettings(encryptionKey) - if err != nil { - t.Fatalf("Failed to fetch settings with an encryption key %s: %v", encryptionKey, err) - } - if s3.Meta.AppName != "save_encrypted" { - t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_encrypted", s3) - } -} diff --git a/daos/table.go b/daos/table.go deleted file mode 100644 index b2f11876aab4925bd5b05a7d6ee38885e7f01d80..0000000000000000000000000000000000000000 --- a/daos/table.go +++ /dev/null @@ -1,45 +0,0 @@ -package daos - -import ( - "github.com/pocketbase/dbx" -) - -// HasTable checks if a table with the provided name exists (case insensitive). -func (dao *Dao) HasTable(tableName string) bool { - var exists bool - - err := dao.DB().Select("count(*)"). - From("sqlite_schema"). - AndWhere(dbx.HashExp{"type": "table"}). - AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})). - Limit(1). - Row(&exists) - - return err == nil && exists -} - -// GetTableColumns returns all column names of a single table by its name. -func (dao *Dao) GetTableColumns(tableName string) ([]string, error) { - columns := []string{} - - err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})"). - Bind(dbx.Params{"tableName": tableName}). - Column(&columns) - - return columns, err -} - -// DeleteTable drops the specified table. -func (dao *Dao) DeleteTable(tableName string) error { - _, err := dao.DB().DropTable(tableName).Execute() - - return err -} - -// Vacuum executes VACUUM on the current dao.DB() instance in order to -// reclaim unused db disk space. -func (dao *Dao) Vacuum() error { - _, err := dao.DB().NewQuery("VACUUM").Execute() - - return err -} diff --git a/daos/table_test.go b/daos/table_test.go deleted file mode 100644 index b3845277de4926bfcb6b63bea14de8379d301b16..0000000000000000000000000000000000000000 --- a/daos/table_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package daos_test - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/list" -) - -func TestHasTable(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - tableName string - expected bool - }{ - {"", false}, - {"test", false}, - {"_admins", true}, - {"demo3", true}, - {"DEMO3", true}, // table names are case insensitives by default - } - - for i, scenario := range scenarios { - result := app.Dao().HasTable(scenario.tableName) - if result != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) - } - } -} - -func TestGetTableColumns(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - tableName string - expected []string - }{ - {"", nil}, - {"_params", []string{"id", "key", "value", "created", "updated"}}, - } - - for i, scenario := range scenarios { - columns, _ := app.Dao().GetTableColumns(scenario.tableName) - - if len(columns) != len(scenario.expected) { - t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expected, columns) - } - - for _, c := range columns { - if !list.ExistInSlice(c, scenario.expected) { - t.Errorf("(%d) Didn't expect column %s", i, c) - } - } - } -} - -func TestDeleteTable(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - tableName string - expectError bool - }{ - {"", true}, - {"test", true}, - {"_admins", false}, - {"demo3", false}, - } - - for i, scenario := range scenarios { - err := app.Dao().DeleteTable(scenario.tableName) - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr) - } - } -} - -func TestVacuum(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - calledQueries := []string{} - app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { - calledQueries = append(calledQueries, sql) - } - app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { - calledQueries = append(calledQueries, sql) - } - - if err := app.Dao().Vacuum(); err != nil { - t.Fatal(err) - } - - if total := len(calledQueries); total != 1 { - t.Fatalf("Expected 1 query, got %d", total) - } - - if calledQueries[0] != "VACUUM" { - t.Fatalf("Expected VACUUM query, got %s", calledQueries[0]) - } -} diff --git a/forms/admin_login.go b/forms/admin_login.go deleted file mode 100644 index a88d1ad58e712dd9860c3a593a0d414b75b7053b..0000000000000000000000000000000000000000 --- a/forms/admin_login.go +++ /dev/null @@ -1,64 +0,0 @@ -package forms - -import ( - "errors" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" -) - -// AdminLogin is an admin email/pass login form. -type AdminLogin struct { - app core.App - dao *daos.Dao - - Identity string `form:"identity" json:"identity"` - Password string `form:"password" json:"password"` -} - -// NewAdminLogin creates a new [AdminLogin] form initialized with -// the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewAdminLogin(app core.App) *AdminLogin { - return &AdminLogin{ - app: app, - dao: app.Dao(), - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *AdminLogin) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *AdminLogin) Validate() error { - return validation.ValidateStruct(form, - validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat), - validation.Field(&form.Password, validation.Required, validation.Length(1, 255)), - ) -} - -// Submit validates and submits the admin form. -// On success returns the authorized admin model. -func (form *AdminLogin) Submit() (*models.Admin, error) { - if err := form.Validate(); err != nil { - return nil, err - } - - admin, err := form.dao.FindAdminByEmail(form.Identity) - if err != nil { - return nil, err - } - - if admin.ValidatePassword(form.Password) { - return admin, nil - } - - return nil, errors.New("Invalid login credentials.") -} diff --git a/forms/admin_login_test.go b/forms/admin_login_test.go deleted file mode 100644 index bd63e7c271cf4479d2bf2399f8d3c1ddab367460..0000000000000000000000000000000000000000 --- a/forms/admin_login_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package forms_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tests" -) - -func TestAdminLoginValidateAndSubmit(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - form := forms.NewAdminLogin(app) - - scenarios := []struct { - email string - password string - expectError bool - }{ - {"", "", true}, - {"", "1234567890", true}, - {"test@example.com", "", true}, - {"test", "test", true}, - {"missing@example.com", "1234567890", true}, - {"test@example.com", "123456789", true}, - {"test@example.com", "1234567890", false}, - } - - for i, s := range scenarios { - form.Identity = s.email - form.Password = s.password - - admin, err := form.Submit() - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - - if !s.expectError && admin == nil { - t.Errorf("(%d) Expected admin model to be returned, got nil", i) - } - - if admin != nil && admin.Email != s.email { - t.Errorf("(%d) Expected admin with email %s to be returned, got %v", i, s.email, admin) - } - } -} diff --git a/forms/admin_password_reset_confirm.go b/forms/admin_password_reset_confirm.go deleted file mode 100644 index 134abc3f2773a6f693ffcdb6f068259f9b726875..0000000000000000000000000000000000000000 --- a/forms/admin_password_reset_confirm.go +++ /dev/null @@ -1,88 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms/validators" - "github.com/pocketbase/pocketbase/models" -) - -// AdminPasswordResetConfirm is an admin password reset confirmation form. -type AdminPasswordResetConfirm struct { - app core.App - dao *daos.Dao - - Token string `form:"token" json:"token"` - Password string `form:"password" json:"password"` - PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` -} - -// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm] -// form initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm { - return &AdminPasswordResetConfirm{ - app: app, - dao: app.Dao(), - } -} - -// SetDao replaces the form Dao instance with the provided one. -// -// This is useful if you want to use a specific transaction Dao instance -// instead of the default app.Dao(). -func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *AdminPasswordResetConfirm) Validate() error { - return validation.ValidateStruct(form, - validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)), - validation.Field(&form.Password, validation.Required, validation.Length(10, 72)), - validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))), - ) -} - -func (form *AdminPasswordResetConfirm) checkToken(value any) error { - v, _ := value.(string) - if v == "" { - return nil // nothing to check - } - - admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret) - if err != nil || admin == nil { - return validation.NewError("validation_invalid_token", "Invalid or expired token.") - } - - return nil -} - -// Submit validates and submits the admin password reset confirmation form. -// On success returns the updated admin model associated to `form.Token`. -func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) { - if err := form.Validate(); err != nil { - return nil, err - } - - admin, err := form.dao.FindAdminByToken( - form.Token, - form.app.Settings().AdminPasswordResetToken.Secret, - ) - if err != nil { - return nil, err - } - - if err := admin.SetPassword(form.Password); err != nil { - return nil, err - } - - if err := form.dao.SaveAdmin(admin); err != nil { - return nil, err - } - - return admin, nil -} diff --git a/forms/admin_password_reset_confirm_test.go b/forms/admin_password_reset_confirm_test.go deleted file mode 100644 index fc825838b7b44ad6f1f51fcea9de20654c3a9530..0000000000000000000000000000000000000000 --- a/forms/admin_password_reset_confirm_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package forms_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" -) - -func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - form := forms.NewAdminPasswordResetConfirm(app) - - scenarios := []struct { - token string - password string - passwordConfirm string - expectError bool - }{ - {"", "", "", true}, - {"", "123", "", true}, - {"", "", "123", true}, - {"test", "", "", true}, - {"test", "123", "", true}, - {"test", "123", "123", true}, - { - // expired - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg", - "1234567890", - "1234567890", - true, - }, - { - // valid with mismatched passwords - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc", - "1234567890", - "1234567891", - true, - }, - { - // valid with matching passwords - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc", - "1234567891", - "1234567891", - false, - }, - } - - for i, s := range scenarios { - form.Token = s.token - form.Password = s.password - form.PasswordConfirm = s.passwordConfirm - - admin, err := form.Submit() - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - continue - } - - if s.expectError { - continue - } - - claims, _ := security.ParseUnverifiedJWT(s.token) - tokenAdminId := claims["id"] - - if admin.Id != tokenAdminId { - t.Errorf("(%d) Expected admin with id %s to be returned, got %v", i, tokenAdminId, admin) - } - - if !admin.ValidatePassword(form.Password) { - t.Errorf("(%d) Expected the admin password to have been updated to %q", i, form.Password) - } - } -} diff --git a/forms/admin_password_reset_request.go b/forms/admin_password_reset_request.go deleted file mode 100644 index 1abfd9d800d13c3df7ca6b8c5150891cead98ac1..0000000000000000000000000000000000000000 --- a/forms/admin_password_reset_request.go +++ /dev/null @@ -1,82 +0,0 @@ -package forms - -import ( - "errors" - "time" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/mails" - "github.com/pocketbase/pocketbase/tools/types" -) - -// AdminPasswordResetRequest is an admin password reset request form. -type AdminPasswordResetRequest struct { - app core.App - dao *daos.Dao - resendThreshold float64 // in seconds - - Email string `form:"email" json:"email"` -} - -// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest] -// form initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest { - return &AdminPasswordResetRequest{ - app: app, - dao: app.Dao(), - resendThreshold: 120, // 2min - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -// -// This method doesn't verify that admin with `form.Email` exists (this is done on Submit). -func (form *AdminPasswordResetRequest) Validate() error { - return validation.ValidateStruct(form, - validation.Field( - &form.Email, - validation.Required, - validation.Length(1, 255), - is.EmailFormat, - ), - ) -} - -// Submit validates and submits the form. -// On success sends a password reset email to the `form.Email` admin. -func (form *AdminPasswordResetRequest) Submit() error { - if err := form.Validate(); err != nil { - return err - } - - admin, err := form.dao.FindAdminByEmail(form.Email) - if err != nil { - return err - } - - now := time.Now().UTC() - lastResetSentAt := admin.LastResetSentAt.Time() - if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold { - return errors.New("You have already requested a password reset.") - } - - if err := mails.SendAdminPasswordReset(form.app, admin); err != nil { - return err - } - - // update last sent timestamp - admin.LastResetSentAt = types.NowDateTime() - - return form.dao.SaveAdmin(admin) -} diff --git a/forms/admin_password_reset_request_test.go b/forms/admin_password_reset_request_test.go deleted file mode 100644 index 0261c9357416b8084d11392efb0cb25641742bcc..0000000000000000000000000000000000000000 --- a/forms/admin_password_reset_request_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package forms_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tests" -) - -func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - form := forms.NewAdminPasswordResetRequest(testApp) - - scenarios := []struct { - email string - expectError bool - }{ - {"", true}, - {"", true}, - {"invalid", true}, - {"missing@example.com", true}, - {"test@example.com", false}, - {"test@example.com", true}, // already requested - } - - for i, s := range scenarios { - testApp.TestMailer.TotalSend = 0 // reset - form.Email = s.email - - adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email) - - err := form.Submit() - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - - adminAfter, _ := testApp.Dao().FindAdminByEmail(s.email) - - if !s.expectError && (adminBefore.LastResetSentAt == adminAfter.LastResetSentAt || adminAfter.LastResetSentAt.IsZero()) { - t.Errorf("(%d) Expected admin.LastResetSentAt to change, got %q", i, adminAfter.LastResetSentAt) - } - - expectedMails := 1 - if s.expectError { - expectedMails = 0 - } - if testApp.TestMailer.TotalSend != expectedMails { - t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) - } - } -} diff --git a/forms/admin_upsert.go b/forms/admin_upsert.go deleted file mode 100644 index b1212c09b45685729dd9d44e5f8ee2634c8c695c..0000000000000000000000000000000000000000 --- a/forms/admin_upsert.go +++ /dev/null @@ -1,122 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms/validators" - "github.com/pocketbase/pocketbase/models" -) - -// AdminUpsert is a [models.Admin] upsert (create/update) form. -type AdminUpsert struct { - app core.App - dao *daos.Dao - admin *models.Admin - - Id string `form:"id" json:"id"` - Avatar int `form:"avatar" json:"avatar"` - Email string `form:"email" json:"email"` - Password string `form:"password" json:"password"` - PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` -} - -// NewAdminUpsert creates a new [AdminUpsert] form with initializer -// config created from the provided [core.App] and [models.Admin] instances -// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`). -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert { - form := &AdminUpsert{ - app: app, - dao: app.Dao(), - admin: admin, - } - - // load defaults - form.Id = admin.Id - form.Avatar = admin.Avatar - form.Email = admin.Email - - return form -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *AdminUpsert) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *AdminUpsert) Validate() error { - return validation.ValidateStruct(form, - validation.Field( - &form.Id, - validation.When( - form.admin.IsNew(), - validation.Length(models.DefaultIdLength, models.DefaultIdLength), - validation.Match(idRegex), - ).Else(validation.In(form.admin.Id)), - ), - validation.Field( - &form.Avatar, - validation.Min(0), - validation.Max(9), - ), - validation.Field( - &form.Email, - validation.Required, - validation.Length(1, 255), - is.EmailFormat, - validation.By(form.checkUniqueEmail), - ), - validation.Field( - &form.Password, - validation.When(form.admin.IsNew(), validation.Required), - validation.Length(10, 72), - ), - validation.Field( - &form.PasswordConfirm, - validation.When(form.Password != "", validation.Required), - validation.By(validators.Compare(form.Password)), - ), - ) -} - -func (form *AdminUpsert) checkUniqueEmail(value any) error { - v, _ := value.(string) - - if form.dao.IsAdminEmailUnique(v, form.admin.Id) { - return nil - } - - return validation.NewError("validation_admin_email_exists", "Admin email already exists.") -} - -// Submit validates the form and upserts the form admin model. -// -// You can optionally provide a list of InterceptorFunc to further -// modify the form behavior before persisting it. -func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error { - if err := form.Validate(); err != nil { - return err - } - - // custom insertion id can be set only on create - if form.admin.IsNew() && form.Id != "" { - form.admin.MarkAsNew() - form.admin.SetId(form.Id) - } - - form.admin.Avatar = form.Avatar - form.admin.Email = form.Email - - if form.Password != "" { - form.admin.SetPassword(form.Password) - } - - return runInterceptors(func() error { - return form.dao.SaveAdmin(form.admin) - }, interceptors...) -} diff --git a/forms/admin_upsert_test.go b/forms/admin_upsert_test.go deleted file mode 100644 index e92f029eff407f07fea9080df7d8e1ece142d0de..0000000000000000000000000000000000000000 --- a/forms/admin_upsert_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "fmt" - "testing" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestNewAdminUpsert(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - admin := &models.Admin{} - admin.Avatar = 3 - admin.Email = "new@example.com" - - form := forms.NewAdminUpsert(app, admin) - - // test defaults - if form.Avatar != admin.Avatar { - t.Errorf("Expected Avatar %d, got %d", admin.Avatar, form.Avatar) - } - if form.Email != admin.Email { - t.Errorf("Expected Email %q, got %q", admin.Email, form.Email) - } -} - -func TestAdminUpsertValidateAndSubmit(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - id string - jsonData string - expectError bool - }{ - { - // create empty - "", - `{}`, - true, - }, - { - // update empty - "sywbhecnh46rhm0", - `{}`, - false, - }, - { - // create failure - existing email - "", - `{ - "email": "test@example.com", - "password": "1234567890", - "passwordConfirm": "1234567890" - }`, - true, - }, - { - // create failure - passwords mismatch - "", - `{ - "email": "test_new@example.com", - "password": "1234567890", - "passwordConfirm": "1234567891" - }`, - true, - }, - { - // create success - "", - `{ - "email": "test_new@example.com", - "password": "1234567890", - "passwordConfirm": "1234567890" - }`, - false, - }, - { - // update failure - existing email - "sywbhecnh46rhm0", - `{ - "email": "test2@example.com" - }`, - true, - }, - { - // update failure - mismatching passwords - "sywbhecnh46rhm0", - `{ - "password": "1234567890", - "passwordConfirm": "1234567891" - }`, - true, - }, - { - // update success - new email - "sywbhecnh46rhm0", - `{ - "email": "test_update@example.com" - }`, - false, - }, - { - // update success - new password - "sywbhecnh46rhm0", - `{ - "password": "1234567890", - "passwordConfirm": "1234567890" - }`, - false, - }, - } - - for i, s := range scenarios { - isCreate := true - admin := &models.Admin{} - if s.id != "" { - isCreate = false - admin, _ = app.Dao().FindAdminById(s.id) - } - initialTokenKey := admin.TokenKey - - form := forms.NewAdminUpsert(app, admin) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - - err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorCalls++ - return next() - } - }) - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err) - } - - foundAdmin, _ := app.Dao().FindAdminByEmail(form.Email) - - if !s.expectError && isCreate && foundAdmin == nil { - t.Errorf("(%d) Expected admin to be created, got nil", i) - continue - } - - expectInterceptorCall := 1 - if s.expectError { - expectInterceptorCall = 0 - } - if interceptorCalls != expectInterceptorCall { - t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls) - } - - if s.expectError { - continue // skip persistence check - } - - if foundAdmin.Email != form.Email { - t.Errorf("(%d) Expected email %s, got %s", i, form.Email, foundAdmin.Email) - } - - if foundAdmin.Avatar != form.Avatar { - t.Errorf("(%d) Expected avatar %d, got %d", i, form.Avatar, foundAdmin.Avatar) - } - - if form.Password != "" && initialTokenKey == foundAdmin.TokenKey { - t.Errorf("(%d) Expected token key to be renewed when setting a new password", i) - } - } -} - -func TestAdminUpsertSubmitInterceptors(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - admin := &models.Admin{} - form := forms.NewAdminUpsert(app, admin) - form.Email = "test_new@example.com" - form.Password = "1234567890" - form.PasswordConfirm = form.Password - - testErr := errors.New("test_error") - interceptorAdminEmail := "" - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptor1Called = true - return next() - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorAdminEmail = admin.Email // to check if the record was filled - interceptor2Called = true - return testErr - } - } - - err := form.Submit(interceptor1, interceptor2) - if err != testErr { - t.Fatalf("Expected error %v, got %v", testErr, err) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorAdminEmail != form.Email { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} - -func TestAdminUpsertWithCustomId(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - name string - jsonData string - collection *models.Admin - expectError bool - }{ - { - "empty data", - "{}", - &models.Admin{}, - false, - }, - { - "empty id", - `{"id":""}`, - &models.Admin{}, - false, - }, - { - "id < 15 chars", - `{"id":"a23"}`, - &models.Admin{}, - true, - }, - { - "id > 15 chars", - `{"id":"a234567890123456"}`, - &models.Admin{}, - true, - }, - { - "id = 15 chars (invalid chars)", - `{"id":"a@3456789012345"}`, - &models.Admin{}, - true, - }, - { - "id = 15 chars (valid chars)", - `{"id":"a23456789012345"}`, - &models.Admin{}, - false, - }, - { - "changing the id of an existing item", - `{"id":"b23456789012345"}`, - existingAdmin, - true, - }, - { - "using the same existing item id", - `{"id":"` + existingAdmin.Id + `"}`, - existingAdmin, - false, - }, - { - "skipping the id for existing item", - `{}`, - existingAdmin, - false, - }, - } - - for i, scenario := range scenarios { - form := forms.NewAdminUpsert(app, scenario.collection) - if form.Email == "" { - form.Email = fmt.Sprintf("test_id_%d@example.com", i) - } - form.Password = "1234567890" - form.PasswordConfirm = form.Password - - // load data - loadErr := json.Unmarshal([]byte(scenario.jsonData), form) - if loadErr != nil { - t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr) - continue - } - - submitErr := form.Submit() - hasErr := submitErr != nil - - if hasErr != scenario.expectError { - t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr) - } - - if !hasErr && form.Id != "" { - _, err := app.Dao().FindAdminById(form.Id) - if err != nil { - t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err) - } - } - } -} diff --git a/forms/base.go b/forms/base.go deleted file mode 100644 index 698feaa16a506567c1e282b4026ebd62247d89b6..0000000000000000000000000000000000000000 --- a/forms/base.go +++ /dev/null @@ -1,44 +0,0 @@ -// Package models implements various services used for request data -// validation and applying changes to existing DB models through the app Dao. -package forms - -import ( - "regexp" - - "github.com/pocketbase/pocketbase/models" -) - -// base ID value regex pattern -var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`) - -// InterceptorNextFunc is a interceptor handler function. -// Usually used in combination with InterceptorFunc. -type InterceptorNextFunc = func() error - -// InterceptorFunc defines a single interceptor function that -// will execute the provided next func handler. -type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc - -// runInterceptors executes the provided list of interceptors. -func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error { - for i := len(interceptors) - 1; i >= 0; i-- { - next = interceptors[i](next) - } - return next() -} - -// InterceptorWithRecordNextFunc is a Record interceptor handler function. -// Usually used in combination with InterceptorWithRecordFunc. -type InterceptorWithRecordNextFunc = func(record *models.Record) error - -// InterceptorWithRecordFunc defines a single Record interceptor function -// that will execute the provided next func handler. -type InterceptorWithRecordFunc func(next InterceptorWithRecordNextFunc) InterceptorWithRecordNextFunc - -// runInterceptorsWithRecord executes the provided list of Record interceptors. -func runInterceptorsWithRecord(record *models.Record, next InterceptorWithRecordNextFunc, interceptors ...InterceptorWithRecordFunc) error { - for i := len(interceptors) - 1; i >= 0; i-- { - next = interceptors[i](next) - } - return next(record) -} diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go deleted file mode 100644 index d2a99ee73358191137497652e5386a764c716a33..0000000000000000000000000000000000000000 --- a/forms/collection_upsert.go +++ /dev/null @@ -1,381 +0,0 @@ -package forms - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/resolvers" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/search" - "github.com/pocketbase/pocketbase/tools/types" -) - -var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`) - -// CollectionUpsert is a [models.Collection] upsert (create/update) form. -type CollectionUpsert struct { - app core.App - dao *daos.Dao - collection *models.Collection - - Id string `form:"id" json:"id"` - Type string `form:"type" json:"type"` - Name string `form:"name" json:"name"` - System bool `form:"system" json:"system"` - Schema schema.Schema `form:"schema" json:"schema"` - ListRule *string `form:"listRule" json:"listRule"` - ViewRule *string `form:"viewRule" json:"viewRule"` - CreateRule *string `form:"createRule" json:"createRule"` - UpdateRule *string `form:"updateRule" json:"updateRule"` - DeleteRule *string `form:"deleteRule" json:"deleteRule"` - Options types.JsonMap `form:"options" json:"options"` -} - -// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer -// config created from the provided [core.App] and [models.Collection] instances -// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`). -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert { - form := &CollectionUpsert{ - app: app, - dao: app.Dao(), - collection: collection, - } - - // load defaults - form.Id = form.collection.Id - form.Type = form.collection.Type - form.Name = form.collection.Name - form.System = form.collection.System - form.ListRule = form.collection.ListRule - form.ViewRule = form.collection.ViewRule - form.CreateRule = form.collection.CreateRule - form.UpdateRule = form.collection.UpdateRule - form.DeleteRule = form.collection.DeleteRule - form.Options = form.collection.Options - - if form.Type == "" { - form.Type = models.CollectionTypeBase - } - - clone, _ := form.collection.Schema.Clone() - if clone != nil { - form.Schema = *clone - } else { - form.Schema = schema.Schema{} - } - - return form -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *CollectionUpsert) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *CollectionUpsert) Validate() error { - isAuth := form.Type == models.CollectionTypeAuth - - return validation.ValidateStruct(form, - validation.Field( - &form.Id, - validation.When( - form.collection.IsNew(), - validation.Length(models.DefaultIdLength, models.DefaultIdLength), - validation.Match(idRegex), - ).Else(validation.In(form.collection.Id)), - ), - validation.Field( - &form.System, - validation.By(form.ensureNoSystemFlagChange), - ), - validation.Field( - &form.Type, - validation.Required, - validation.In(models.CollectionTypeAuth, models.CollectionTypeBase), - validation.By(form.ensureNoTypeChange), - ), - validation.Field( - &form.Name, - validation.Required, - validation.Length(1, 255), - validation.Match(collectionNameRegex), - validation.By(form.ensureNoSystemNameChange), - validation.By(form.checkUniqueName), - ), - // validates using the type's own validation rules + some collection's specific - validation.Field( - &form.Schema, - validation.By(form.checkMinSchemaFields), - validation.By(form.ensureNoSystemFieldsChange), - validation.By(form.ensureNoFieldsTypeChange), - validation.By(form.ensureExistingRelationCollectionId), - validation.When( - isAuth, - validation.By(form.ensureNoAuthFieldName), - ), - ), - validation.Field(&form.ListRule, validation.By(form.checkRule)), - validation.Field(&form.ViewRule, validation.By(form.checkRule)), - validation.Field(&form.CreateRule, validation.By(form.checkRule)), - validation.Field(&form.UpdateRule, validation.By(form.checkRule)), - validation.Field(&form.DeleteRule, validation.By(form.checkRule)), - validation.Field(&form.Options, validation.By(form.checkOptions)), - ) -} - -func (form *CollectionUpsert) checkUniqueName(value any) error { - v, _ := value.(string) - - // ensure unique collection name - if !form.dao.IsCollectionNameUnique(v, form.collection.Id) { - return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).") - } - - // ensure that the collection name doesn't collide with the id of any collection - if form.dao.FindById(&models.Collection{}, v) == nil { - return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.") - } - - // ensure that there is no existing table name with the same name - if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) { - return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.") - } - - return nil -} - -func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error { - v, _ := value.(string) - - if !form.collection.IsNew() && form.collection.System && v != form.collection.Name { - return validation.NewError("validation_collection_system_name_change", "System collections cannot be renamed.") - } - - return nil -} - -func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error { - v, _ := value.(bool) - - if !form.collection.IsNew() && v != form.collection.System { - return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.") - } - - return nil -} - -func (form *CollectionUpsert) ensureNoTypeChange(value any) error { - v, _ := value.(string) - - if !form.collection.IsNew() && v != form.collection.Type { - return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.") - } - - return nil -} - -func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error { - v, _ := value.(schema.Schema) - - for i, field := range v.Fields() { - oldField := form.collection.Schema.GetFieldById(field.Id) - - if oldField != nil && oldField.Type != field.Type { - return validation.Errors{fmt.Sprint(i): validation.NewError( - "validation_field_type_change", - "Field type cannot be changed.", - )} - } - } - - return nil -} - -func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error { - v, _ := value.(schema.Schema) - - for i, field := range v.Fields() { - if field.Type != schema.FieldTypeRelation { - continue - } - - options, _ := field.Options.(*schema.RelationOptions) - if options == nil { - continue - } - - if _, err := form.dao.FindCollectionByNameOrId(options.CollectionId); err != nil { - return validation.Errors{fmt.Sprint(i): validation.NewError( - "validation_field_invalid_relation", - "The relation collection doesn't exist.", - )} - } - } - - return nil -} - -func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error { - v, _ := value.(schema.Schema) - - if form.Type != models.CollectionTypeAuth { - return nil // not an auth collection - } - - authFieldNames := schema.AuthFieldNames() - // exclude the meta RecordUpsert form fields - authFieldNames = append(authFieldNames, "password", "passwordConfirm", "oldPassword") - - errs := validation.Errors{} - for i, field := range v.Fields() { - if list.ExistInSlice(field.Name, authFieldNames) { - errs[fmt.Sprint(i)] = validation.Errors{ - "name": validation.NewError( - "validation_reserved_auth_field_name", - "The field name is reserved and cannot be used.", - ), - } - } - } - - if len(errs) > 0 { - return errs - } - - return nil -} - -func (form *CollectionUpsert) checkMinSchemaFields(value any) error { - if form.Type == models.CollectionTypeAuth { - return nil // auth collections doesn't require having additional schema fields - } - - v, ok := value.(schema.Schema) - if !ok || len(v.Fields()) == 0 { - return validation.ErrRequired - } - - return nil -} - -func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error { - v, _ := value.(schema.Schema) - - for _, oldField := range form.collection.Schema.Fields() { - if !oldField.System { - continue - } - - newField := v.GetFieldById(oldField.Id) - - if newField == nil || oldField.String() != newField.String() { - return validation.NewError("validation_system_field_change", "System fields cannot be deleted or changed.") - } - } - - return nil -} - -func (form *CollectionUpsert) checkRule(value any) error { - v, _ := value.(*string) - if v == nil || *v == "" { - return nil // nothing to check - } - - dummy := *form.collection - dummy.Type = form.Type - dummy.Schema = form.Schema - dummy.System = form.System - dummy.Options = form.Options - - r := resolvers.NewRecordFieldResolver(form.dao, &dummy, nil, true) - - _, err := search.FilterData(*v).BuildExpr(r) - if err != nil { - return validation.NewError("validation_invalid_rule", "Invalid filter rule.") - } - - return nil -} - -func (form *CollectionUpsert) checkOptions(value any) error { - v, _ := value.(types.JsonMap) - - if form.Type == models.CollectionTypeAuth { - raw, err := v.MarshalJSON() - if err != nil { - return validation.NewError("validation_invalid_options", "Invalid options.") - } - - options := models.CollectionAuthOptions{} - if err := json.Unmarshal(raw, &options); err != nil { - return validation.NewError("validation_invalid_options", "Invalid options.") - } - - // check the generic validations - if err := options.Validate(); err != nil { - return err - } - - // additional form specific validations - if err := form.checkRule(options.ManageRule); err != nil { - return validation.Errors{"manageRule": err} - } - } - - return nil -} - -// Submit validates the form and upserts the form's Collection model. -// -// On success the related record table schema will be auto updated. -// -// You can optionally provide a list of InterceptorFunc to further -// modify the form behavior before persisting it. -func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error { - if err := form.Validate(); err != nil { - return err - } - - if form.collection.IsNew() { - // type can be set only on create - form.collection.Type = form.Type - - // system flag can be set only on create - form.collection.System = form.System - - // custom insertion id can be set only on create - if form.Id != "" { - form.collection.MarkAsNew() - form.collection.SetId(form.Id) - } - } - - // system collections cannot be renamed - if form.collection.IsNew() || !form.collection.System { - form.collection.Name = form.Name - } - - form.collection.Schema = form.Schema - form.collection.ListRule = form.ListRule - form.collection.ViewRule = form.ViewRule - form.collection.CreateRule = form.CreateRule - form.collection.UpdateRule = form.UpdateRule - form.collection.DeleteRule = form.DeleteRule - form.collection.SetOptions(form.Options) - - return runInterceptors(func() error { - return form.dao.SaveCollection(form.collection) - }, interceptors...) -} diff --git a/forms/collection_upsert_test.go b/forms/collection_upsert_test.go deleted file mode 100644 index adbe1c778999e8060b2fa5195721f92896b6c70a..0000000000000000000000000000000000000000 --- a/forms/collection_upsert_test.go +++ /dev/null @@ -1,590 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/spf13/cast" -) - -func TestNewCollectionUpsert(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection := &models.Collection{} - collection.Name = "test_name" - collection.Type = "test_type" - collection.System = true - listRule := "testview" - collection.ListRule = &listRule - viewRule := "test_view" - collection.ViewRule = &viewRule - createRule := "test_create" - collection.CreateRule = &createRule - updateRule := "test_update" - collection.UpdateRule = &updateRule - deleteRule := "test_delete" - collection.DeleteRule = &deleteRule - collection.Schema = schema.NewSchema(&schema.SchemaField{ - Name: "test", - Type: schema.FieldTypeText, - }) - - form := forms.NewCollectionUpsert(app, collection) - - if form.Name != collection.Name { - t.Errorf("Expected Name %q, got %q", collection.Name, form.Name) - } - - if form.Type != collection.Type { - t.Errorf("Expected Type %q, got %q", collection.Type, form.Type) - } - - if form.System != collection.System { - t.Errorf("Expected System %v, got %v", collection.System, form.System) - } - - if form.ListRule != collection.ListRule { - t.Errorf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule) - } - - if form.ViewRule != collection.ViewRule { - t.Errorf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule) - } - - if form.CreateRule != collection.CreateRule { - t.Errorf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule) - } - - if form.UpdateRule != collection.UpdateRule { - t.Errorf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule) - } - - if form.DeleteRule != collection.DeleteRule { - t.Errorf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule) - } - - // store previous state and modify the collection schema to verify - // that the form.Schema is a deep clone - loadedSchema, _ := collection.Schema.MarshalJSON() - collection.Schema.AddField(&schema.SchemaField{ - Name: "new_field", - Type: schema.FieldTypeBool, - }) - - formSchema, _ := form.Schema.MarshalJSON() - - if string(formSchema) != string(loadedSchema) { - t.Errorf("Expected Schema %v, got %v", string(loadedSchema), string(formSchema)) - } -} - -func TestCollectionUpsertValidateAndSubmit(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - testName string - existingName string - jsonData string - expectedErrors []string - }{ - {"empty create (base)", "", "{}", []string{"name", "schema"}}, - {"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}}, - {"empty update", "demo2", "{}", []string{}}, - { - "create failure", - "", - `{ - "name": "test ?!@#$", - "type": "invalid", - "system": true, - "schema": [ - {"name":"","type":"text"} - ], - "listRule": "missing = '123'", - "viewRule": "missing = '123'", - "createRule": "missing = '123'", - "updateRule": "missing = '123'", - "deleteRule": "missing = '123'" - }`, - []string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, - }, - { - "create failure - existing name", - "", - `{ - "name": "demo1", - "system": true, - "schema": [ - {"name":"test","type":"text"} - ], - "listRule": "test='123'", - "viewRule": "test='123'", - "createRule": "test='123'", - "updateRule": "test='123'", - "deleteRule": "test='123'" - }`, - []string{"name"}, - }, - { - "create failure - existing internal table", - "", - `{ - "name": "_admins", - "schema": [ - {"name":"test","type":"text"} - ] - }`, - []string{"name"}, - }, - { - "create failure - name starting with underscore", - "", - `{ - "name": "_test_new", - "schema": [ - {"name":"test","type":"text"} - ] - }`, - []string{"name"}, - }, - { - "create failure - duplicated field names (case insensitive)", - "", - `{ - "name": "test_new", - "schema": [ - {"name":"test","type":"text"}, - {"name":"tESt","type":"text"} - ] - }`, - []string{"schema"}, - }, - { - "create failure - check type options validators", - "", - `{ - "name": "test_new", - "type": "auth", - "schema": [ - {"name":"test","type":"text"} - ], - "options": { "minPasswordLength": 3 } - }`, - []string{"options"}, - }, - { - "create success", - "", - `{ - "name": "test_new", - "type": "auth", - "system": true, - "schema": [ - {"id":"a123456","name":"test1","type":"text"}, - {"id":"b123456","name":"test2","type":"email"} - ], - "listRule": "test1='123' && verified = true", - "viewRule": "test1='123' && emailVisibility = true", - "createRule": "test1='123' && email != ''", - "updateRule": "test1='123' && username != ''", - "deleteRule": "test1='123' && id != ''" - }`, - []string{}, - }, - { - "update failure - changing field type", - "test_new", - `{ - "schema": [ - {"id":"a123456","name":"test1","type":"url"}, - {"id":"b123456","name":"test2","type":"bool"} - ] - }`, - []string{"schema"}, - }, - { - "update success - rename fields to existing field names (aka. reusing field names)", - "test_new", - `{ - "schema": [ - {"id":"a123456","name":"test2","type":"text"}, - {"id":"b123456","name":"test1","type":"email"} - ] - }`, - []string{}, - }, - { - "update failure - existing name", - "demo2", - `{"name": "demo3"}`, - []string{"name"}, - }, - { - "update failure - changing system collection", - "nologin", - `{ - "name": "update", - "system": false, - "schema": [ - {"id":"koih1lqx","name":"abc","type":"text"} - ], - "listRule": "abc = '123'", - "viewRule": "abc = '123'", - "createRule": "abc = '123'", - "updateRule": "abc = '123'", - "deleteRule": "abc = '123'" - }`, - []string{"name", "system"}, - }, - { - "update failure - changing collection type", - "demo3", - `{ - "type": "auth" - }`, - []string{"type"}, - }, - { - "update failure - all fields", - "demo2", - `{ - "name": "test ?!@#$", - "type": "invalid", - "system": true, - "schema": [ - {"name":"","type":"text"} - ], - "listRule": "missing = '123'", - "viewRule": "missing = '123'", - "createRule": "missing = '123'", - "updateRule": "missing = '123'", - "deleteRule": "missing = '123'", - "options": {"test": 123} - }`, - []string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, - }, - { - "update success - update all fields", - "clients", - `{ - "name": "demo_update", - "type": "auth", - "schema": [ - {"id":"_2hlxbmp","name":"test","type":"text"} - ], - "listRule": "test='123' && verified = true", - "viewRule": "test='123' && emailVisibility = true", - "createRule": "test='123' && email != ''", - "updateRule": "test='123' && username != ''", - "deleteRule": "test='123' && id != ''", - "options": {"minPasswordLength": 10} - }`, - []string{}, - }, - // (fail due to filters old field references) - { - "update failure - rename the schema field of the last updated collection", - "demo_update", - `{ - "schema": [ - {"id":"_2hlxbmp","name":"test_renamed","type":"text"} - ] - }`, - []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, - }, - // (cleared filter references) - { - "update success - rename the schema field of the last updated collection", - "demo_update", - `{ - "schema": [ - {"id":"_2hlxbmp","name":"test_renamed","type":"text"} - ], - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null - }`, - []string{}, - }, - { - "update success - system collection", - "nologin", - `{ - "listRule": "name='123'", - "viewRule": "name='123'", - "createRule": "name='123'", - "updateRule": "name='123'", - "deleteRule": "name='123'" - }`, - []string{}, - }, - } - - for _, s := range scenarios { - collection := &models.Collection{} - if s.existingName != "" { - var err error - collection, err = app.Dao().FindCollectionByNameOrId(s.existingName) - if err != nil { - t.Fatal(err) - } - } - - form := forms.NewCollectionUpsert(app, collection) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorCalls++ - return next() - } - } - - // parse errors - result := form.Submit(interceptor) - errs, ok := result.(validation.Errors) - if !ok && result != nil { - t.Errorf("[%s] Failed to parse errors %v", s.testName, result) - continue - } - - // check interceptor calls - expectInterceptorCalls := 1 - if len(s.expectedErrors) > 0 { - expectInterceptorCalls = 0 - } - if interceptorCalls != expectInterceptorCalls { - t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls) - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs) - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs) - } - } - - if len(s.expectedErrors) > 0 { - continue - } - - collection, _ = app.Dao().FindCollectionByNameOrId(form.Name) - if collection == nil { - t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name) - continue - } - - if form.Name != collection.Name { - t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name) - } - - if form.Type != collection.Type { - t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type) - } - - if form.System != collection.System { - t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System) - } - - if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) { - t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule) - } - - if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) { - t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule) - } - - if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) { - t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule) - } - - if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) { - t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule) - } - - if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) { - t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule) - } - - formSchema, _ := form.Schema.MarshalJSON() - collectionSchema, _ := collection.Schema.MarshalJSON() - if string(formSchema) != string(collectionSchema) { - t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema)) - } - } -} - -func TestCollectionUpsertSubmitInterceptors(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo2") - if err != nil { - t.Fatal(err) - } - - form := forms.NewCollectionUpsert(app, collection) - form.Name = "test_new" - - testErr := errors.New("test_error") - interceptorCollectionName := "" - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptor1Called = true - return next() - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorCollectionName = collection.Name // to check if the record was filled - interceptor2Called = true - return testErr - } - } - - submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorCollectionName != form.Name { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} - -func TestCollectionUpsertWithCustomId(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2") - if err != nil { - t.Fatal(err) - } - - newCollection := func() *models.Collection { - return &models.Collection{ - Name: "c_" + security.PseudorandomString(4), - Schema: existingCollection.Schema, - } - } - - scenarios := []struct { - name string - jsonData string - collection *models.Collection - expectError bool - }{ - { - "empty data", - "{}", - newCollection(), - false, - }, - { - "empty id", - `{"id":""}`, - newCollection(), - false, - }, - { - "id < 15 chars", - `{"id":"a23"}`, - newCollection(), - true, - }, - { - "id > 15 chars", - `{"id":"a234567890123456"}`, - newCollection(), - true, - }, - { - "id = 15 chars (invalid chars)", - `{"id":"a@3456789012345"}`, - newCollection(), - true, - }, - { - "id = 15 chars (valid chars)", - `{"id":"a23456789012345"}`, - newCollection(), - false, - }, - { - "changing the id of an existing item", - `{"id":"b23456789012345"}`, - existingCollection, - true, - }, - { - "using the same existing item id", - `{"id":"` + existingCollection.Id + `"}`, - existingCollection, - false, - }, - { - "skipping the id for existing item", - `{}`, - existingCollection, - false, - }, - } - - for _, s := range scenarios { - form := forms.NewCollectionUpsert(app, s.collection) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr) - continue - } - - submitErr := form.Submit() - hasErr := submitErr != nil - - if hasErr != s.expectError { - t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr) - } - - if !hasErr && form.Id != "" { - _, err := app.Dao().FindCollectionByNameOrId(form.Id) - if err != nil { - t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err) - } - } - } -} diff --git a/forms/collections_import.go b/forms/collections_import.go deleted file mode 100644 index 083f4d1497cdf89232b866377dda97cf8f2a632d..0000000000000000000000000000000000000000 --- a/forms/collections_import.go +++ /dev/null @@ -1,136 +0,0 @@ -package forms - -import ( - "encoding/json" - "fmt" - "log" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" -) - -// CollectionsImport is a form model to bulk import -// (create, replace and delete) collections from a user provided list. -type CollectionsImport struct { - app core.App - dao *daos.Dao - - Collections []*models.Collection `form:"collections" json:"collections"` - DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"` -} - -// NewCollectionsImport creates a new [CollectionsImport] form with -// initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewCollectionsImport(app core.App) *CollectionsImport { - return &CollectionsImport{ - app: app, - dao: app.Dao(), - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *CollectionsImport) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *CollectionsImport) Validate() error { - return validation.ValidateStruct(form, - validation.Field(&form.Collections, validation.Required), - ) -} - -// Submit applies the import, aka.: -// - imports the form collections (create or replace) -// - sync the collection changes with their related records table -// - ensures the integrity of the imported structure (aka. run validations for each collection) -// - if [form.DeleteMissing] is set, deletes all local collections that are not found in the imports list -// -// All operations are wrapped in a single transaction that are -// rollbacked on the first encountered error. -// -// You can optionally provide a list of InterceptorFunc to further -// modify the form behavior before persisting it. -func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error { - if err := form.Validate(); err != nil { - return err - } - - return runInterceptors(func() error { - return form.dao.RunInTransaction(func(txDao *daos.Dao) error { - importErr := txDao.ImportCollections( - form.Collections, - form.DeleteMissing, - form.beforeRecordsSync, - ) - if importErr == nil { - return nil - } - - // validation failure - if err, ok := importErr.(validation.Errors); ok { - return err - } - - // generic/db failure - if form.app.IsDebug() { - log.Println("Internal import failure:", importErr) - } - return validation.Errors{"collections": validation.NewError( - "collections_import_failure", - "Failed to import the collections configuration.", - )} - }) - }, interceptors...) -} - -func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error { - // refresh the actual persisted collections list - refreshedCollections := []*models.Collection{} - if err := txDao.CollectionQuery().OrderBy("created ASC").All(&refreshedCollections); err != nil { - return err - } - - // trigger the validator for each existing collection to - // ensure that the app is not left in a broken state - for _, collection := range refreshedCollections { - upsertModel := mappedOld[collection.GetId()] - if upsertModel == nil { - upsertModel = collection - } - upsertModel.MarkAsNotNew() - - upsertForm := NewCollectionUpsert(form.app, upsertModel) - upsertForm.SetDao(txDao) - - // load form fields with the refreshed collection state - upsertForm.Id = collection.Id - upsertForm.Type = collection.Type - upsertForm.Name = collection.Name - upsertForm.System = collection.System - upsertForm.ListRule = collection.ListRule - upsertForm.ViewRule = collection.ViewRule - upsertForm.CreateRule = collection.CreateRule - upsertForm.UpdateRule = collection.UpdateRule - upsertForm.DeleteRule = collection.DeleteRule - upsertForm.Schema = collection.Schema - upsertForm.Options = collection.Options - - if err := upsertForm.Validate(); err != nil { - // serialize the validation error(s) - serializedErr, _ := json.MarshalIndent(err, "", " ") - - return validation.Errors{"collections": validation.NewError( - "collections_import_validate_failure", - fmt.Sprintf("Data validations failed for collection %q (%s):\n%s", collection.Name, collection.Id, serializedErr), - )} - } - } - - return nil -} diff --git a/forms/collections_import_test.go b/forms/collections_import_test.go deleted file mode 100644 index f6812a38a536c51826350c3f014719616aef82d9..0000000000000000000000000000000000000000 --- a/forms/collections_import_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestCollectionsImportValidate(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - form := forms.NewCollectionsImport(app) - - scenarios := []struct { - collections []*models.Collection - expectError bool - }{ - {nil, true}, - {[]*models.Collection{}, true}, - {[]*models.Collection{{}}, false}, - } - - for i, s := range scenarios { - form.Collections = s.collections - - err := form.Validate() - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - } -} - -func TestCollectionsImportSubmit(t *testing.T) { - scenarios := []struct { - name string - jsonData string - expectError bool - expectCollectionsCount int - expectEvents map[string]int - }{ - { - name: "empty collections", - jsonData: `{ - "deleteMissing": true, - "collections": [] - }`, - expectError: true, - expectCollectionsCount: 7, - expectEvents: nil, - }, - { - name: "one of the collections has invalid data", - jsonData: `{ - "collections": [ - { - "name": "import1", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - }, - { - "name": "import 2", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - } - ] - }`, - expectError: true, - expectCollectionsCount: 7, - expectEvents: map[string]int{ - "OnModelBeforeCreate": 2, - }, - }, - { - name: "test empty base collection schema", - jsonData: `{ - "collections": [ - { - "name": "import1" - }, - { - "name": "import2", - "type": "auth" - } - ] - }`, - expectError: true, - expectCollectionsCount: 7, - expectEvents: map[string]int{ - "OnModelBeforeCreate": 2, - }, - }, - { - name: "all imported collections has valid data", - jsonData: `{ - "collections": [ - { - "name": "import1", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - }, - { - "name": "import2", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - }, - { - "name": "import3", - "type": "auth" - } - ] - }`, - expectError: false, - expectCollectionsCount: 10, - expectEvents: map[string]int{ - "OnModelBeforeCreate": 3, - "OnModelAfterCreate": 3, - }, - }, - { - name: "new collection with existing name", - jsonData: `{ - "collections": [ - { - "name": "demo2", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - } - ] - }`, - expectError: true, - expectCollectionsCount: 7, - expectEvents: map[string]int{ - "OnModelBeforeCreate": 1, - }, - }, - { - name: "delete system + modified + new collection", - jsonData: `{ - "deleteMissing": true, - "collections": [ - { - "id":"sz5l5z67tg7gku0", - "name":"demo2", - "schema":[ - { - "id":"_2hlxbmp", - "name":"title", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - } - ] - }, - { - "name": "import1", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - } - ] - }`, - expectError: true, - expectCollectionsCount: 7, - expectEvents: map[string]int{ - "OnModelBeforeDelete": 5, - }, - }, - { - name: "modified + new collection", - jsonData: `{ - "collections": [ - { - "id":"sz5l5z67tg7gku0", - "name":"demo2", - "schema":[ - { - "id":"_2hlxbmp", - "name":"title_new", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - } - ] - }, - { - "name": "import1", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - }, - { - "name": "import2", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - } - ] - }`, - expectError: false, - expectCollectionsCount: 9, - expectEvents: map[string]int{ - "OnModelBeforeUpdate": 1, - "OnModelAfterUpdate": 1, - "OnModelBeforeCreate": 2, - "OnModelAfterCreate": 2, - }, - }, - { - name: "delete non-system + modified + new collection", - jsonData: `{ - "deleteMissing": true, - "collections": [ - { - "id": "kpv709sk2lqbqk8", - "system": true, - "name": "nologin", - "type": "auth", - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": [], - "manageRule": "@request.auth.collectionName = 'users'", - "minPasswordLength": 8, - "onlyEmailDomains": [], - "requireEmail": true - }, - "listRule": "", - "viewRule": "", - "createRule": "", - "updateRule": "", - "deleteRule": "", - "schema": [ - { - "id": "x8zzktwe", - "name": "name", - "type": "text", - "system": false, - "required": false, - "unique": false, - "options": { - "min": null, - "max": null, - "pattern": "" - } - } - ] - }, - { - "id":"sz5l5z67tg7gku0", - "name":"demo2", - "schema":[ - { - "id":"_2hlxbmp", - "name":"title", - "type":"text", - "system":false, - "required":true, - "unique":false, - "options":{ - "min":3, - "max":null, - "pattern":"" - } - } - ] - }, - { - "id": "test_deleted_collection_name_reuse", - "name": "demo1", - "schema": [ - { - "id":"fz6iql2m", - "name":"active", - "type":"bool" - } - ] - } - ] - }`, - expectError: false, - expectCollectionsCount: 3, - expectEvents: map[string]int{ - "OnModelBeforeUpdate": 2, - "OnModelAfterUpdate": 2, - "OnModelBeforeCreate": 1, - "OnModelAfterCreate": 1, - "OnModelBeforeDelete": 5, - "OnModelAfterDelete": 5, - }, - }, - } - - for _, s := range scenarios { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - form := forms.NewCollectionsImport(testApp) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr) - continue - } - - err := form.Submit() - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err) - } - - // check collections count - collections := []*models.Collection{} - if err := testApp.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - if len(collections) != s.expectCollectionsCount { - t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections)) - } - - // check events - if len(testApp.EventCalls) > len(s.expectEvents) { - t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls) - } - for event, expectedCalls := range s.expectEvents { - actualCalls := testApp.EventCalls[event] - if actualCalls != expectedCalls { - t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls) - } - } - } -} - -func TestCollectionsImportSubmitInterceptors(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collections := []*models.Collection{} - if err := app.Dao().CollectionQuery().All(&collections); err != nil { - t.Fatal(err) - } - - form := forms.NewCollectionsImport(app) - form.Collections = collections - - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptor1Called = true - return next() - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptor2Called = true - return testErr - } - } - - submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } -} diff --git a/forms/realtime_subscribe.go b/forms/realtime_subscribe.go deleted file mode 100644 index fc852fc807decc9e6657b0a7dc36c311916216fd..0000000000000000000000000000000000000000 --- a/forms/realtime_subscribe.go +++ /dev/null @@ -1,23 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" -) - -// RealtimeSubscribe is a realtime subscriptions request form. -type RealtimeSubscribe struct { - ClientId string `form:"clientId" json:"clientId"` - Subscriptions []string `form:"subscriptions" json:"subscriptions"` -} - -// NewRealtimeSubscribe creates new RealtimeSubscribe request form. -func NewRealtimeSubscribe() *RealtimeSubscribe { - return &RealtimeSubscribe{} -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RealtimeSubscribe) Validate() error { - return validation.ValidateStruct(form, - validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)), - ) -} diff --git a/forms/realtime_subscribe_test.go b/forms/realtime_subscribe_test.go deleted file mode 100644 index d1df830b52ff193e91d73258aa2881efe42aac8c..0000000000000000000000000000000000000000 --- a/forms/realtime_subscribe_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package forms_test - -import ( - "strings" - "testing" - - "github.com/pocketbase/pocketbase/forms" -) - -func TestRealtimeSubscribeValidate(t *testing.T) { - scenarios := []struct { - clientId string - expectError bool - }{ - {"", true}, - {strings.Repeat("a", 256), true}, - {"test", false}, - } - - for i, s := range scenarios { - form := forms.NewRealtimeSubscribe() - form.ClientId = s.clientId - - err := form.Validate() - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - } -} diff --git a/forms/record_email_change_confirm.go b/forms/record_email_change_confirm.go deleted file mode 100644 index 033252d8de2a3131388860672db2a7c420d38f33..0000000000000000000000000000000000000000 --- a/forms/record_email_change_confirm.go +++ /dev/null @@ -1,142 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/security" -) - -// RecordEmailChangeConfirm is an auth record email change confirmation form. -type RecordEmailChangeConfirm struct { - app core.App - dao *daos.Dao - collection *models.Collection - - Token string `form:"token" json:"token"` - Password string `form:"password" json:"password"` -} - -// NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form -// initialized with from the provided [core.App] and [models.Collection] instances. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordEmailChangeConfirm(app core.App, collection *models.Collection) *RecordEmailChangeConfirm { - return &RecordEmailChangeConfirm{ - app: app, - dao: app.Dao(), - collection: collection, - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordEmailChangeConfirm) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RecordEmailChangeConfirm) Validate() error { - return validation.ValidateStruct(form, - validation.Field( - &form.Token, - validation.Required, - validation.By(form.checkToken), - ), - validation.Field( - &form.Password, - validation.Required, - validation.Length(1, 100), - validation.By(form.checkPassword), - ), - ) -} - -func (form *RecordEmailChangeConfirm) checkToken(value any) error { - v, _ := value.(string) - if v == "" { - return nil // nothing to check - } - - authRecord, _, err := form.parseToken(v) - if err != nil { - return err - } - - if authRecord.Collection().Id != form.collection.Id { - return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.") - } - - return nil -} - -func (form *RecordEmailChangeConfirm) checkPassword(value any) error { - v, _ := value.(string) - if v == "" { - return nil // nothing to check - } - - authRecord, _, _ := form.parseToken(form.Token) - if authRecord == nil || !authRecord.ValidatePassword(v) { - return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.") - } - - return nil -} - -func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, string, error) { - // check token payload - claims, _ := security.ParseUnverifiedJWT(token) - newEmail, _ := claims["newEmail"].(string) - if newEmail == "" { - return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.") - } - - // ensure that there aren't other users with the new email - if !form.dao.IsRecordValueUnique(form.collection.Id, schema.FieldNameEmail, newEmail) { - return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail) - } - - // verify that the token is not expired and its signature is valid - authRecord, err := form.dao.FindAuthRecordByToken( - token, - form.app.Settings().RecordEmailChangeToken.Secret, - ) - if err != nil || authRecord == nil { - return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.") - } - - return authRecord, newEmail, nil -} - -// Submit validates and submits the auth record email change confirmation form. -// On success returns the updated auth record associated to `form.Token`. -// -// You can optionally provide a list of InterceptorWithRecordFunc to -// further modify the form behavior before persisting it. -func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) { - if err := form.Validate(); err != nil { - return nil, err - } - - authRecord, newEmail, err := form.parseToken(form.Token) - if err != nil { - return nil, err - } - - authRecord.SetEmail(newEmail) - authRecord.SetVerified(true) - authRecord.RefreshTokenKey() // invalidate old tokens - - interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error { - return form.dao.SaveRecord(m) - }, interceptors...) - - if interceptorsErr != nil { - return nil, interceptorsErr - } - - return authRecord, nil -} diff --git a/forms/record_email_change_confirm_test.go b/forms/record_email_change_confirm_test.go deleted file mode 100644 index 9615228298fd7b44d3e7d20a87442900e0ee287e..0000000000000000000000000000000000000000 --- a/forms/record_email_change_confirm_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" -) - -func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - jsonData string - expectedErrors []string - }{ - // empty payload - {"{}", []string{"token", "password"}}, - // empty data - { - `{"token": "", "password": ""}`, - []string{"token", "password"}, - }, - // invalid token payload - { - `{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.quDgaCi2rGTRx3qO06CrFvHdeCua_5J7CCVWSaFhkus", - "password": "123456" - }`, - []string{"token", "password"}, - }, - // expired token - { - `{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTYwOTQ1NTY2MX0.n1OJXJEACMNPT9aMTO48cVJexIiZEtHsz4UNBIfMcf4", - "password": "123456" - }`, - []string{"token", "password"}, - }, - // existing new email - { - `{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.Q_o6zpc2URggTU0mWv2CS0rIPbQhFdmrjZ-ASwHh1Ww", - "password": "1234567890" - }`, - []string{"token", "password"}, - }, - // wrong confirmation password - { - `{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY", - "password": "123456" - }`, - []string{"password"}, - }, - // valid data - { - `{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY", - "password": "1234567890" - }`, - []string{}, - }, - } - - for i, s := range scenarios { - form := forms.NewRecordEmailChangeConfirm(testApp, authCollection) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(r *models.Record) error { - interceptorCalls++ - return next(r) - } - } - - record, err := form.Submit(interceptor) - - // check interceptor calls - expectInterceptorCalls := 1 - if len(s.expectedErrors) > 0 { - expectInterceptorCalls = 0 - } - if interceptorCalls != expectInterceptorCalls { - t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) - } - - // parse errors - errs, ok := err.(validation.Errors) - if !ok && err != nil { - t.Errorf("(%d) Failed to parse errors %v", i, err) - continue - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) - } - } - - if len(errs) > 0 { - continue - } - - claims, _ := security.ParseUnverifiedJWT(form.Token) - newEmail, _ := claims["newEmail"].(string) - - // check whether the user was updated - // --- - if record.Email() != newEmail { - t.Errorf("(%d) Expected record email %q, got %q", i, newEmail, record.Email()) - } - - if !record.Verified() { - t.Errorf("(%d) Expected record to be verified, got false", i) - } - - // shouldn't validate second time due to refreshed record token - if err := form.Validate(); err == nil { - t.Errorf("(%d) Expected error, got nil", i) - } - } -} - -func TestRecordEmailChangeConfirmInterceptors(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordEmailChangeConfirm(testApp, authCollection) - form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY" - form.Password = "1234567890" - interceptorEmail := authRecord.Email() - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptor1Called = true - return next(record) - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptorEmail = record.Email() - interceptor2Called = true - return testErr - } - } - - _, submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorEmail == authRecord.Email() { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} diff --git a/forms/record_email_change_request.go b/forms/record_email_change_request.go deleted file mode 100644 index 8e77932c26e1b3cd32e821d84cfbaac50d814581..0000000000000000000000000000000000000000 --- a/forms/record_email_change_request.go +++ /dev/null @@ -1,75 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/mails" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" -) - -// RecordEmailChangeRequest is an auth record email change request form. -type RecordEmailChangeRequest struct { - app core.App - dao *daos.Dao - record *models.Record - - NewEmail string `form:"newEmail" json:"newEmail"` -} - -// NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form -// initialized with from the provided [core.App] and [models.Record] instances. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordEmailChangeRequest(app core.App, record *models.Record) *RecordEmailChangeRequest { - return &RecordEmailChangeRequest{ - app: app, - dao: app.Dao(), - record: record, - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordEmailChangeRequest) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RecordEmailChangeRequest) Validate() error { - return validation.ValidateStruct(form, - validation.Field( - &form.NewEmail, - validation.Required, - validation.Length(1, 255), - is.EmailFormat, - validation.By(form.checkUniqueEmail), - ), - ) -} - -func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error { - v, _ := value.(string) - - if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) { - return validation.NewError("validation_record_email_exists", "User email already exists.") - } - - return nil -} - -// Submit validates and sends the change email request. -// -// You can optionally provide a list of InterceptorWithRecordFunc to -// further modify the form behavior before persisting it. -func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error { - if err := form.Validate(); err != nil { - return err - } - - return runInterceptorsWithRecord(form.record, func(m *models.Record) error { - return mails.SendRecordChangeEmail(form.app, m, form.NewEmail) - }, interceptors...) -} diff --git a/forms/record_email_change_request_test.go b/forms/record_email_change_request_test.go deleted file mode 100644 index daec3ffd300300e6ecf59f894880a27b02613a76..0000000000000000000000000000000000000000 --- a/forms/record_email_change_request_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" -) - -func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - user, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - jsonData string - expectedErrors []string - }{ - // empty payload - {"{}", []string{"newEmail"}}, - // empty data - { - `{"newEmail": ""}`, - []string{"newEmail"}, - }, - // invalid email - { - `{"newEmail": "invalid"}`, - []string{"newEmail"}, - }, - // existing email token - { - `{"newEmail": "test2@example.com"}`, - []string{"newEmail"}, - }, - // valid new email - { - `{"newEmail": "test_new@example.com"}`, - []string{}, - }, - } - - for i, s := range scenarios { - testApp.TestMailer.TotalSend = 0 // reset - form := forms.NewRecordEmailChangeRequest(testApp, user) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(r *models.Record) error { - interceptorCalls++ - return next(r) - } - } - - err := form.Submit(interceptor) - - // check interceptor calls - expectInterceptorCalls := 1 - if len(s.expectedErrors) > 0 { - expectInterceptorCalls = 0 - } - if interceptorCalls != expectInterceptorCalls { - t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) - } - - // parse errors - errs, ok := err.(validation.Errors) - if !ok && err != nil { - t.Errorf("(%d) Failed to parse errors %v", i, err) - continue - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) - } - } - - expectedMails := 1 - if len(s.expectedErrors) > 0 { - expectedMails = 0 - } - if testApp.TestMailer.TotalSend != expectedMails { - t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) - } - } -} - -func TestRecordEmailChangeRequestInterceptors(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordEmailChangeRequest(testApp, authRecord) - form.NewEmail = "test_new@example.com" - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptor1Called = true - return next(record) - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptor2Called = true - return testErr - } - } - - submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } -} diff --git a/forms/record_oauth2_login.go b/forms/record_oauth2_login.go deleted file mode 100644 index 8eb6bc7ffb643a7482f8fab32f719871e3effbb0..0000000000000000000000000000000000000000 --- a/forms/record_oauth2_login.go +++ /dev/null @@ -1,234 +0,0 @@ -package forms - -import ( - "errors" - "fmt" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/auth" - "github.com/pocketbase/pocketbase/tools/security" - "golang.org/x/oauth2" -) - -// RecordOAuth2Login is an auth record OAuth2 login form. -type RecordOAuth2Login struct { - app core.App - dao *daos.Dao - collection *models.Collection - - // Optional auth record that will be used if no external - // auth relation is found (if it is from the same collection) - loggedAuthRecord *models.Record - - // The name of the OAuth2 client provider (eg. "google") - Provider string `form:"provider" json:"provider"` - - // The authorization code returned from the initial request. - Code string `form:"code" json:"code"` - - // The code verifier sent with the initial request as part of the code_challenge. - CodeVerifier string `form:"codeVerifier" json:"codeVerifier"` - - // The redirect url sent with the initial request. - RedirectUrl string `form:"redirectUrl" json:"redirectUrl"` - - // Additional data that will be used for creating a new auth record - // if an existing OAuth2 account doesn't exist. - CreateData map[string]any `form:"createData" json:"createData"` -} - -// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with -// initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login { - form := &RecordOAuth2Login{ - app: app, - dao: app.Dao(), - collection: collection, - loggedAuthRecord: optAuthRecord, - } - - return form -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RecordOAuth2Login) Validate() error { - return validation.ValidateStruct(form, - validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)), - validation.Field(&form.Code, validation.Required), - validation.Field(&form.CodeVerifier, validation.Required), - validation.Field(&form.RedirectUrl, validation.Required, is.URL), - ) -} - -func (form *RecordOAuth2Login) checkProviderName(value any) error { - name, _ := value.(string) - - config, ok := form.app.Settings().NamedAuthProviderConfigs()[name] - if !ok || !config.Enabled { - return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name)) - } - - return nil -} - -// Submit validates and submits the form. -// -// If an auth record doesn't exist, it will make an attempt to create it -// based on the fetched OAuth2 profile data via a local [RecordUpsert] form. -// You can intercept/modify the create form by setting the optional beforeCreateFuncs argument. -// -// On success returns the authorized record model and the fetched provider's data. -func (form *RecordOAuth2Login) Submit( - beforeCreateFuncs ...func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error, -) (*models.Record, *auth.AuthUser, error) { - if err := form.Validate(); err != nil { - return nil, nil, err - } - - if !form.collection.AuthOptions().AllowOAuth2Auth { - return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.") - } - - provider, err := auth.NewProviderByName(form.Provider) - if err != nil { - return nil, nil, err - } - - // load provider configuration - providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider] - if err := providerConfig.SetupProvider(provider); err != nil { - return nil, nil, err - } - - provider.SetRedirectUrl(form.RedirectUrl) - - // fetch token - token, err := provider.FetchToken( - form.Code, - oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier), - ) - if err != nil { - return nil, nil, err - } - - // fetch external auth user - authUser, err := provider.FetchAuthUser(token) - if err != nil { - return nil, nil, err - } - - var authRecord *models.Record - - // check for existing relation with the auth record - rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id) - switch { - case rel != nil: - authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId) - if err != nil { - return nil, authUser, err - } - case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id: - // fallback to the logged auth record (if any) - authRecord = form.loggedAuthRecord - case authUser.Email != "": - // look for an existing auth record by the external auth record's email - authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email) - } - - saveErr := form.dao.RunInTransaction(func(txDao *daos.Dao) error { - if authRecord == nil { - authRecord = models.NewRecord(form.collection) - authRecord.RefreshId() - authRecord.MarkAsNew() - createForm := NewRecordUpsert(form.app, authRecord) - createForm.SetFullManageAccess(true) - createForm.SetDao(txDao) - if authUser.Username != "" && usernameRegex.MatchString(authUser.Username) { - createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username) - } - - // load custom data - createForm.LoadData(form.CreateData) - - // load the OAuth2 profile data as fallback - if createForm.Email == "" { - createForm.Email = authUser.Email - } - createForm.Verified = false - if createForm.Email == authUser.Email { - // mark as verified as long as it matches the OAuth2 data (even if the email is empty) - createForm.Verified = true - } - if createForm.Password == "" { - createForm.Password = security.RandomString(30) - createForm.PasswordConfirm = createForm.Password - } - - for _, f := range beforeCreateFuncs { - if f == nil { - continue - } - if err := f(createForm, authRecord, authUser); err != nil { - return err - } - } - - // create the new auth record - if err := createForm.Submit(); err != nil { - return err - } - } else { - // update the existing auth record empty email if the authUser has one - // (this is in case previously the auth record was created - // with an OAuth2 provider that didn't return an email address) - if authRecord.Email() == "" && authUser.Email != "" { - authRecord.SetEmail(authUser.Email) - if err := txDao.SaveRecord(authRecord); err != nil { - return err - } - } - - // update the existing auth record verified state - // (only if the auth record doesn't have an email or the auth record email match with the one in authUser) - if !authRecord.Verified() && (authRecord.Email() == "" || authRecord.Email() == authUser.Email) { - authRecord.SetVerified(true) - if err := txDao.SaveRecord(authRecord); err != nil { - return err - } - } - } - - // create ExternalAuth relation if missing - if rel == nil { - rel = &models.ExternalAuth{ - CollectionId: authRecord.Collection().Id, - RecordId: authRecord.Id, - Provider: form.Provider, - ProviderId: authUser.Id, - } - if err := txDao.SaveExternalAuth(rel); err != nil { - return err - } - } - - return nil - }) - - if saveErr != nil { - return nil, authUser, saveErr - } - - return authRecord, authUser, nil -} diff --git a/forms/record_oauth2_login_test.go b/forms/record_oauth2_login_test.go deleted file mode 100644 index 637ed0837772daedf6e5f1eb14df61e955479ffc..0000000000000000000000000000000000000000 --- a/forms/record_oauth2_login_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tests" -) - -func TestUserOauth2LoginValidate(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - testName string - collectionName string - jsonData string - expectedErrors []string - }{ - { - "empty payload", - "users", - "{}", - []string{"provider", "code", "codeVerifier", "redirectUrl"}, - }, - { - "empty data", - "users", - `{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`, - []string{"provider", "code", "codeVerifier", "redirectUrl"}, - }, - { - "missing provider", - "users", - `{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, - []string{"provider"}, - }, - { - "disabled provider", - "users", - `{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, - []string{"provider"}, - }, - { - "enabled provider", - "users", - `{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, - []string{}, - }, - } - - for _, s := range scenarios { - authCollection, _ := app.Dao().FindCollectionByNameOrId(s.collectionName) - if authCollection == nil { - t.Errorf("[%s] Failed to fetch auth collection", s.testName) - } - - form := forms.NewRecordOAuth2Login(app, authCollection, nil) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr) - continue - } - - err := form.Validate() - - // parse errors - errs, ok := err.(validation.Errors) - if !ok && err != nil { - t.Errorf("[%s] Failed to parse errors %v", s.testName, err) - continue - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs) - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs) - } - } - } -} - -// @todo consider mocking a Oauth2 provider to test Submit diff --git a/forms/record_password_login.go b/forms/record_password_login.go deleted file mode 100644 index 2c01e9a805a8d6c7631af9e7dc7fcc5a1053c8ba..0000000000000000000000000000000000000000 --- a/forms/record_password_login.go +++ /dev/null @@ -1,77 +0,0 @@ -package forms - -import ( - "errors" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" -) - -// RecordPasswordLogin is record username/email + password login form. -type RecordPasswordLogin struct { - app core.App - dao *daos.Dao - collection *models.Collection - - Identity string `form:"identity" json:"identity"` - Password string `form:"password" json:"password"` -} - -// NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized -// with from the provided [core.App] and [models.Collection] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordPasswordLogin(app core.App, collection *models.Collection) *RecordPasswordLogin { - return &RecordPasswordLogin{ - app: app, - dao: app.Dao(), - collection: collection, - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordPasswordLogin) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RecordPasswordLogin) Validate() error { - return validation.ValidateStruct(form, - validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)), - validation.Field(&form.Password, validation.Required, validation.Length(1, 255)), - ) -} - -// Submit validates and submits the form. -// On success returns the authorized record model. -func (form *RecordPasswordLogin) Submit() (*models.Record, error) { - if err := form.Validate(); err != nil { - return nil, err - } - - authOptions := form.collection.AuthOptions() - - if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth { - return nil, errors.New("Password authentication is not allowed for the collection.") - } - - var record *models.Record - var fetchErr error - - if authOptions.AllowEmailAuth && - (!authOptions.AllowUsernameAuth || is.EmailFormat.Validate(form.Identity) == nil) { - record, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity) - } else { - record, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity) - } - - if fetchErr != nil || !record.ValidatePassword(form.Password) { - return nil, errors.New("Invalid login credentials.") - } - - return record, nil -} diff --git a/forms/record_password_login_test.go b/forms/record_password_login_test.go deleted file mode 100644 index c36dc72dd48f677509387c5dc2b82e7e56379f6b..0000000000000000000000000000000000000000 --- a/forms/record_password_login_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package forms_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tests" -) - -func TestRecordEmailLoginValidateAndSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - scenarios := []struct { - testName string - collectionName string - identity string - password string - expectError bool - }{ - { - "empty data", - "users", - "", - "", - true, - }, - - // username - { - "existing username + wrong password", - "users", - "users75657", - "invalid", - true, - }, - { - "missing username + valid password", - "users", - "clients57772", // not in the "users" collection - "1234567890", - true, - }, - { - "existing username + valid password but in restricted username auth collection", - "clients", - "clients57772", - "1234567890", - true, - }, - { - "existing username + valid password but in restricted username and email auth collection", - "nologin", - "test_username", - "1234567890", - true, - }, - { - "existing username + valid password", - "users", - "users75657", - "1234567890", - false, - }, - - // email - { - "existing email + wrong password", - "users", - "test@example.com", - "invalid", - true, - }, - { - "missing email + valid password", - "users", - "test_missing@example.com", - "1234567890", - true, - }, - { - "existing username + valid password but in restricted username auth collection", - "clients", - "test@example.com", - "1234567890", - false, - }, - { - "existing username + valid password but in restricted username and email auth collection", - "nologin", - "test@example.com", - "1234567890", - true, - }, - { - "existing email + valid password", - "users", - "test@example.com", - "1234567890", - false, - }, - } - - for _, s := range scenarios { - authCollection, err := testApp.Dao().FindCollectionByNameOrId(s.collectionName) - if err != nil { - t.Errorf("[%s] Failed to fetch auth collection: %v", s.testName, err) - } - - form := forms.NewRecordPasswordLogin(testApp, authCollection) - form.Identity = s.identity - form.Password = s.password - - record, err := form.Submit() - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.testName, s.expectError, hasErr, err) - continue - } - - if hasErr { - continue - } - - if record.Email() != s.identity && record.Username() != s.identity { - t.Errorf("[%s] Expected record with identity %q, got \n%v", s.testName, s.identity, record) - } - } -} diff --git a/forms/record_password_reset_confirm.go b/forms/record_password_reset_confirm.go deleted file mode 100644 index 89722c5d592f6b12c5fbc52bc916bd9584586e36..0000000000000000000000000000000000000000 --- a/forms/record_password_reset_confirm.go +++ /dev/null @@ -1,103 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms/validators" - "github.com/pocketbase/pocketbase/models" -) - -// RecordPasswordResetConfirm is an auth record password reset confirmation form. -type RecordPasswordResetConfirm struct { - app core.App - collection *models.Collection - dao *daos.Dao - - Token string `form:"token" json:"token"` - Password string `form:"password" json:"password"` - PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` -} - -// NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm] -// form initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordPasswordResetConfirm(app core.App, collection *models.Collection) *RecordPasswordResetConfirm { - return &RecordPasswordResetConfirm{ - app: app, - dao: app.Dao(), - collection: collection, - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordPasswordResetConfirm) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RecordPasswordResetConfirm) Validate() error { - minPasswordLength := form.collection.AuthOptions().MinPasswordLength - - return validation.ValidateStruct(form, - validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)), - validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)), - validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))), - ) -} - -func (form *RecordPasswordResetConfirm) checkToken(value any) error { - v, _ := value.(string) - if v == "" { - return nil // nothing to check - } - - record, err := form.dao.FindAuthRecordByToken( - v, - form.app.Settings().RecordPasswordResetToken.Secret, - ) - if err != nil || record == nil { - return validation.NewError("validation_invalid_token", "Invalid or expired token.") - } - - if record.Collection().Id != form.collection.Id { - return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.") - } - - return nil -} - -// Submit validates and submits the form. -// On success returns the updated auth record associated to `form.Token`. -// -// You can optionally provide a list of InterceptorWithRecordFunc to -// further modify the form behavior before persisting it. -func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) { - if err := form.Validate(); err != nil { - return nil, err - } - - authRecord, err := form.dao.FindAuthRecordByToken( - form.Token, - form.app.Settings().RecordPasswordResetToken.Secret, - ) - if err != nil { - return nil, err - } - - if err := authRecord.SetPassword(form.Password); err != nil { - return nil, err - } - - interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error { - return form.dao.SaveRecord(m) - }, interceptors...) - - if interceptorsErr != nil { - return nil, interceptorsErr - } - - return authRecord, nil -} diff --git a/forms/record_password_reset_confirm_test.go b/forms/record_password_reset_confirm_test.go deleted file mode 100644 index 543a9c9ccbd0f75003ad421431946990957adc7c..0000000000000000000000000000000000000000 --- a/forms/record_password_reset_confirm_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" -) - -func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - jsonData string - expectedErrors []string - }{ - // empty data (Validate call check) - { - `{}`, - []string{"token", "password", "passwordConfirm"}, - }, - // expired token - { - `{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY", - "password":"12345678", - "passwordConfirm":"12345678" - }`, - []string{"token"}, - }, - // valid token but invalid passwords lengths - { - `{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", - "password":"1234567", - "passwordConfirm":"1234567" - }`, - []string{"password"}, - }, - // valid token but mismatched passwordConfirm - { - `{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", - "password":"12345678", - "passwordConfirm":"12345679" - }`, - []string{"passwordConfirm"}, - }, - // valid token and password - { - `{ - "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg", - "password":"12345678", - "passwordConfirm":"12345678" - }`, - []string{}, - }, - } - - for i, s := range scenarios { - form := forms.NewRecordPasswordResetConfirm(testApp, authCollection) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(r *models.Record) error { - interceptorCalls++ - return next(r) - } - } - - record, submitErr := form.Submit(interceptor) - - // parse errors - errs, ok := submitErr.(validation.Errors) - if !ok && submitErr != nil { - t.Errorf("(%d) Failed to parse errors %v", i, submitErr) - continue - } - - // check interceptor calls - expectInterceptorCalls := 1 - if len(s.expectedErrors) > 0 { - expectInterceptorCalls = 0 - } - if interceptorCalls != expectInterceptorCalls { - t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) - } - } - - if len(errs) > 0 || len(s.expectedErrors) > 0 { - continue - } - - claims, _ := security.ParseUnverifiedJWT(form.Token) - tokenRecordId := claims["id"] - - if record.Id != tokenRecordId { - t.Errorf("(%d) Expected record with id %s, got %v", i, tokenRecordId, record) - } - - if !record.LastResetSentAt().IsZero() { - t.Errorf("(%d) Expected record.LastResetSentAt to be empty, got %v", i, record.LastResetSentAt()) - } - - if !record.ValidatePassword(form.Password) { - t.Errorf("(%d) Expected the record password to have been updated to %q", i, form.Password) - } - } -} - -func TestRecordPasswordResetConfirmInterceptors(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordPasswordResetConfirm(testApp, authCollection) - form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg" - form.Password = "1234567890" - form.PasswordConfirm = "1234567890" - interceptorTokenKey := authRecord.TokenKey() - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptor1Called = true - return next(record) - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptorTokenKey = record.TokenKey() - interceptor2Called = true - return testErr - } - } - - _, submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorTokenKey == authRecord.TokenKey() { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} diff --git a/forms/record_password_reset_request.go b/forms/record_password_reset_request.go deleted file mode 100644 index 9b5c4d1ab77bb6c944a32c46ad5413f282da5a4c..0000000000000000000000000000000000000000 --- a/forms/record_password_reset_request.go +++ /dev/null @@ -1,91 +0,0 @@ -package forms - -import ( - "errors" - "time" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/mails" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/types" -) - -// RecordPasswordResetRequest is an auth record reset password request form. -type RecordPasswordResetRequest struct { - app core.App - dao *daos.Dao - collection *models.Collection - resendThreshold float64 // in seconds - - Email string `form:"email" json:"email"` -} - -// NewRecordPasswordResetRequest creates a new [RecordPasswordResetRequest] -// form initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordPasswordResetRequest(app core.App, collection *models.Collection) *RecordPasswordResetRequest { - return &RecordPasswordResetRequest{ - app: app, - dao: app.Dao(), - collection: collection, - resendThreshold: 120, // 2 min - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordPasswordResetRequest) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -// -// This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit). -func (form *RecordPasswordResetRequest) Validate() error { - return validation.ValidateStruct(form, - validation.Field( - &form.Email, - validation.Required, - validation.Length(1, 255), - is.EmailFormat, - ), - ) -} - -// Submit validates and submits the form. -// On success, sends a password reset email to the `form.Email` auth record. -// -// You can optionally provide a list of InterceptorWithRecordFunc to -// further modify the form behavior before persisting it. -func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) error { - if err := form.Validate(); err != nil { - return err - } - - authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email) - if err != nil { - return err - } - - now := time.Now().UTC() - lastResetSentAt := authRecord.LastResetSentAt().Time() - if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold { - return errors.New("You've already requested a password reset.") - } - - // update last sent timestamp - authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime()) - - return runInterceptorsWithRecord(authRecord, func(m *models.Record) error { - if err := mails.SendRecordPasswordReset(form.app, m); err != nil { - return err - } - - return form.dao.SaveRecord(m) - }, interceptors...) -} diff --git a/forms/record_password_reset_request_test.go b/forms/record_password_reset_request_test.go deleted file mode 100644 index ff0db1fa9fe74f9634ad787099d90cabcfe83904..0000000000000000000000000000000000000000 --- a/forms/record_password_reset_request_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - "time" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/types" -) - -func TestRecordPasswordResetRequestSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - jsonData string - expectError bool - }{ - // empty field (Validate call check) - { - `{"email":""}`, - true, - }, - // invalid email field (Validate call check) - { - `{"email":"invalid"}`, - true, - }, - // nonexisting user - { - `{"email":"missing@example.com"}`, - true, - }, - // existing user - { - `{"email":"test@example.com"}`, - false, - }, - // existing user - reached send threshod - { - `{"email":"test@example.com"}`, - true, - }, - } - - now := types.NowDateTime() - time.Sleep(1 * time.Millisecond) - - for i, s := range scenarios { - testApp.TestMailer.TotalSend = 0 // reset - form := forms.NewRecordPasswordResetRequest(testApp, authCollection) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(r *models.Record) error { - interceptorCalls++ - return next(r) - } - } - - err := form.Submit(interceptor) - - // check interceptor calls - expectInterceptorCalls := 1 - if s.expectError { - expectInterceptorCalls = 0 - } - if interceptorCalls != expectInterceptorCalls { - t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) - } - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - - expectedMails := 1 - if s.expectError { - expectedMails = 0 - } - if testApp.TestMailer.TotalSend != expectedMails { - t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) - } - - if s.expectError { - continue - } - - // check whether LastResetSentAt was updated - user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email) - if err != nil { - t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email) - continue - } - - if user.LastResetSentAt().Time().Sub(now.Time()) < 0 { - t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt()) - } - } -} - -func TestRecordPasswordResetRequestInterceptors(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordPasswordResetRequest(testApp, authCollection) - form.Email = authRecord.Email() - interceptorLastResetSentAt := authRecord.LastResetSentAt() - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptor1Called = true - return next(record) - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptorLastResetSentAt = record.LastResetSentAt() - interceptor2Called = true - return testErr - } - } - - submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} diff --git a/forms/record_upsert.go b/forms/record_upsert.go deleted file mode 100644 index 0944c827517e0204542b6c3d37ac688c4c93ffb3..0000000000000000000000000000000000000000 --- a/forms/record_upsert.go +++ /dev/null @@ -1,781 +0,0 @@ -package forms - -import ( - "encoding/json" - "errors" - "fmt" - "log" - "net/http" - "regexp" - "strconv" - "strings" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms/validators" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/rest" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/spf13/cast" -) - -// username value regex pattern -var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`) - -// RecordUpsert is a [models.Record] upsert (create/update) form. -type RecordUpsert struct { - app core.App - dao *daos.Dao - manageAccess bool - record *models.Record - - filesToUpload map[string][]*rest.UploadedFile - filesToDelete []string // names list - - // base model fields - Id string `json:"id"` - - // auth collection fields - // --- - Username string `json:"username"` - Email string `json:"email"` - EmailVisibility bool `json:"emailVisibility"` - Verified bool `json:"verified"` - Password string `json:"password"` - PasswordConfirm string `json:"passwordConfirm"` - OldPassword string `json:"oldPassword"` - // --- - - Data map[string]any `json:"data"` -} - -// NewRecordUpsert creates a new [RecordUpsert] form with initializer -// config created from the provided [core.App] and [models.Record] instances -// (for create you could pass a pointer to an empty Record - models.NewRecord(collection)). -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert { - form := &RecordUpsert{ - app: app, - dao: app.Dao(), - record: record, - filesToDelete: []string{}, - filesToUpload: map[string][]*rest.UploadedFile{}, - } - - form.loadFormDefaults() - - return form -} - -// SetFullManageAccess sets the manageAccess bool flag of the current -// form to enable/disable directly changing some system record fields -// (often used with auth collection records). -func (form *RecordUpsert) SetFullManageAccess(fullManageAccess bool) { - form.manageAccess = fullManageAccess -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordUpsert) SetDao(dao *daos.Dao) { - form.dao = dao -} - -func (form *RecordUpsert) loadFormDefaults() { - form.Id = form.record.Id - - if form.record.Collection().IsAuth() { - form.Username = form.record.Username() - form.Email = form.record.Email() - form.EmailVisibility = form.record.EmailVisibility() - form.Verified = form.record.Verified() - } - - form.Data = map[string]any{} - for _, field := range form.record.Collection().Schema.Fields() { - form.Data[field.Name] = form.record.Get(field.Name) - } -} - -func (form *RecordUpsert) getContentType(r *http.Request) string { - t := r.Header.Get("Content-Type") - for i, c := range t { - if c == ' ' || c == ';' { - return t[:i] - } - } - return t -} - -func (form *RecordUpsert) extractRequestData(r *http.Request, keyPrefix string) (map[string]any, error) { - switch form.getContentType(r) { - case "application/json": - return form.extractJsonData(r, keyPrefix) - case "multipart/form-data": - return form.extractMultipartFormData(r, keyPrefix) - default: - return nil, errors.New("Unsupported request Content-Type.") - } -} - -func (form *RecordUpsert) extractJsonData(r *http.Request, keyPrefix string) (map[string]any, error) { - result := map[string]any{} - - err := rest.CopyJsonBody(r, &result) - - if keyPrefix != "" { - parts := strings.Split(keyPrefix, ".") - for _, part := range parts { - if result[part] == nil { - break - } - if v, ok := result[part].(map[string]any); ok { - result = v - } - } - } - - return result, err -} - -func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix string) (map[string]any, error) { - result := map[string]any{} - - // parse form data (if not already) - if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil { - return result, err - } - - arrayValueSupportTypes := schema.ArraybleFieldTypes() - - form.filesToUpload = map[string][]*rest.UploadedFile{} - - for fullKey, values := range r.PostForm { - key := fullKey - if keyPrefix != "" { - key = strings.TrimPrefix(key, keyPrefix+".") - } - - if len(values) == 0 { - result[key] = nil - continue - } - - field := form.record.Collection().Schema.GetFieldByName(key) - if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) { - result[key] = values - } else { - result[key] = values[0] - } - } - - // load uploaded files (if any) - for _, field := range form.record.Collection().Schema.Fields() { - if field.Type != schema.FieldTypeFile { - continue // not a file field - } - - key := field.Name - fullKey := key - if keyPrefix != "" { - fullKey = keyPrefix + "." + key - } - - files, err := rest.FindUploadedFiles(r, fullKey) - if err != nil || len(files) == 0 { - if err != nil && err != http.ErrMissingFile && form.app.IsDebug() { - log.Printf("%q uploaded file error: %v\n", fullKey, err) - } - - // skip invalid or missing file(s) - continue - } - - options, ok := field.Options.(*schema.FileOptions) - if !ok { - continue - } - - if form.filesToUpload[key] == nil { - form.filesToUpload[key] = []*rest.UploadedFile{} - } - - if options.MaxSelect == 1 { - form.filesToUpload[key] = append(form.filesToUpload[key], files[0]) - } else if options.MaxSelect > 1 { - form.filesToUpload[key] = append(form.filesToUpload[key], files...) - } - } - - return result, nil -} - -func (form *RecordUpsert) normalizeData() error { - for _, field := range form.record.Collection().Schema.Fields() { - if v, ok := form.Data[field.Name]; ok { - form.Data[field.Name] = field.PrepareValue(v) - } - } - return nil -} - -// LoadRequest extracts the json or multipart/form-data request data -// and lods it into the form. -// -// File upload is supported only via multipart/form-data. -// -// To DELETE previously uploaded file(s) you can suffix the field name -// with the file index or filename (eg. `myfile.0`) and set it to null or empty string. -// For single file upload fields, you can skip the index and directly -// reset the field using its field name (eg. `myfile = null`). -func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error { - requestData, err := form.extractRequestData(r, keyPrefix) - if err != nil { - return err - } - - return form.LoadData(requestData) -} - -// LoadData loads and normalizes the provided data into the form. -// -// To DELETE previously uploaded file(s) you can suffix the field name -// with the file index or filename (eg. `myfile.0`) and set it to null or empty string. -// For single file upload fields, you can skip the index and directly -// reset the field using its field name (eg. `myfile = null`). -func (form *RecordUpsert) LoadData(requestData map[string]any) error { - // load base system fields - if v, ok := requestData["id"]; ok { - form.Id = cast.ToString(v) - } - - // load auth system fields - if form.record.Collection().IsAuth() { - if v, ok := requestData["username"]; ok { - form.Username = cast.ToString(v) - } - if v, ok := requestData["email"]; ok { - form.Email = cast.ToString(v) - } - if v, ok := requestData["emailVisibility"]; ok { - form.EmailVisibility = cast.ToBool(v) - } - if v, ok := requestData["verified"]; ok { - form.Verified = cast.ToBool(v) - } - if v, ok := requestData["password"]; ok { - form.Password = cast.ToString(v) - } - if v, ok := requestData["passwordConfirm"]; ok { - form.PasswordConfirm = cast.ToString(v) - } - if v, ok := requestData["oldPassword"]; ok { - form.OldPassword = cast.ToString(v) - } - } - - // extend the record schema data with the request data - extendedData := form.record.SchemaData() - rawData, err := json.Marshal(requestData) - if err != nil { - return err - } - if err := json.Unmarshal(rawData, &extendedData); err != nil { - return err - } - - for _, field := range form.record.Collection().Schema.Fields() { - key := field.Name - value := extendedData[key] - value = field.PrepareValue(value) - - if field.Type != schema.FieldTypeFile { - form.Data[key] = value - continue - } - - options, _ := field.Options.(*schema.FileOptions) - oldNames := list.ToUniqueStringSlice(form.Data[key]) - - // ----------------------------------------------------------- - // Delete previously uploaded file(s) - // ----------------------------------------------------------- - - // if empty value was set, mark all previously uploaded files for deletion - if len(list.ToUniqueStringSlice(value)) == 0 && len(oldNames) > 0 { - form.filesToDelete = append(form.filesToDelete, oldNames...) - form.Data[key] = []string{} - } else if len(oldNames) > 0 { - indexesToDelete := make([]int, 0, len(extendedData)) - - // search for individual file name to delete (eg. "file.test.png = null") - for i, name := range oldNames { - if v, ok := extendedData[key+"."+name]; ok && cast.ToString(v) == "" { - indexesToDelete = append(indexesToDelete, i) - } - } - - // search for individual file index to delete (eg. "file.0 = null") - keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`) - for indexedKey := range extendedData { - if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" { - index, indexErr := strconv.Atoi(indexedKey[len(key)+1:]) - if indexErr != nil || index >= len(oldNames) { - continue - } - indexesToDelete = append(indexesToDelete, index) - } - } - - // slice to fill only with the non-deleted indexes - nonDeleted := make([]string, 0, len(oldNames)) - for i, name := range oldNames { - // not marked for deletion - if !list.ExistInSlice(i, indexesToDelete) { - nonDeleted = append(nonDeleted, name) - continue - } - - // store the id to actually delete the file later - form.filesToDelete = append(form.filesToDelete, name) - } - form.Data[key] = nonDeleted - } - - // ----------------------------------------------------------- - // Check for new uploaded file - // ----------------------------------------------------------- - - if len(form.filesToUpload[key]) == 0 { - continue - } - - // refresh oldNames list - oldNames = list.ToUniqueStringSlice(form.Data[key]) - - if options.MaxSelect == 1 { - // delete previous file(s) before replacing - if len(oldNames) > 0 { - form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...)) - } - form.Data[key] = form.filesToUpload[key][0].Name() - } else if options.MaxSelect > 1 { - // append the id of each uploaded file instance - for _, file := range form.filesToUpload[key] { - oldNames = append(oldNames, file.Name()) - } - form.Data[key] = oldNames - } - } - - return form.normalizeData() -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RecordUpsert) Validate() error { - // base form fields validator - baseFieldsRules := []*validation.FieldRules{ - validation.Field( - &form.Id, - validation.When( - form.record.IsNew(), - validation.Length(models.DefaultIdLength, models.DefaultIdLength), - validation.Match(idRegex), - ).Else(validation.In(form.record.Id)), - ), - } - - // auth fields validators - if form.record.Collection().IsAuth() { - baseFieldsRules = append(baseFieldsRules, - validation.Field( - &form.Username, - // require only on update, because on create we fallback to auto generated username - validation.When(!form.record.IsNew(), validation.Required), - validation.Length(3, 100), - validation.Match(usernameRegex), - validation.By(form.checkUniqueUsername), - ), - validation.Field( - &form.Email, - validation.When( - form.record.Collection().AuthOptions().RequireEmail, - validation.Required, - ), - // don't allow direct email change (or unset) if the form doesn't have manage access permissions - // (aka. allow only admin or authorized auth models to directly update the field) - validation.When( - !form.record.IsNew() && !form.manageAccess, - validation.In(form.record.Email()), - ), - validation.Length(1, 255), - is.EmailFormat, - validation.By(form.checkEmailDomain), - validation.By(form.checkUniqueEmail), - ), - validation.Field( - &form.Verified, - // don't allow changing verified if the form doesn't have manage access permissions - // (aka. allow only admin or authorized auth models to directly change the field) - validation.When( - !form.manageAccess, - validation.In(form.record.Verified()), - ), - ), - validation.Field( - &form.Password, - validation.When(form.record.IsNew(), validation.Required), - validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72), - ), - validation.Field( - &form.PasswordConfirm, - validation.When( - (form.record.IsNew() || form.Password != ""), - validation.Required, - ), - validation.By(validators.Compare(form.Password)), - ), - validation.Field( - &form.OldPassword, - // require old password only on update when: - // - form.manageAccess is not set - // - changing the existing password - validation.When( - !form.record.IsNew() && !form.manageAccess && form.Password != "", - validation.Required, - validation.By(form.checkOldPassword), - ), - ), - ) - } - - if err := validation.ValidateStruct(form, baseFieldsRules...); err != nil { - return err - } - - // record data validator - return validators.NewRecordDataValidator( - form.dao, - form.record, - form.filesToUpload, - ).Validate(form.Data) -} - -func (form *RecordUpsert) checkUniqueUsername(value any) error { - v, _ := value.(string) - if v == "" { - return nil - } - - isUnique := form.dao.IsRecordValueUnique( - form.record.Collection().Id, - schema.FieldNameUsername, - v, - form.record.Id, - ) - if !isUnique { - return validation.NewError("validation_invalid_username", "The username is invalid or already in use.") - } - - return nil -} - -func (form *RecordUpsert) checkUniqueEmail(value any) error { - v, _ := value.(string) - if v == "" { - return nil - } - - isUnique := form.dao.IsRecordValueUnique( - form.record.Collection().Id, - schema.FieldNameEmail, - v, - form.record.Id, - ) - if !isUnique { - return validation.NewError("validation_invalid_email", "The email is invalid or already in use.") - } - - return nil -} - -func (form *RecordUpsert) checkEmailDomain(value any) error { - val, _ := value.(string) - if val == "" { - return nil // nothing to check - } - - domain := val[strings.LastIndex(val, "@")+1:] - only := form.record.Collection().AuthOptions().OnlyEmailDomains - except := form.record.Collection().AuthOptions().ExceptEmailDomains - - // only domains check - if len(only) > 0 && !list.ExistInSlice(domain, only) { - return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.") - } - - // except domains check - if len(except) > 0 && list.ExistInSlice(domain, except) { - return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.") - } - - return nil -} - -func (form *RecordUpsert) checkOldPassword(value any) error { - v, _ := value.(string) - if v == "" { - return nil // nothing to check - } - - if !form.record.ValidatePassword(v) { - return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.") - } - - return nil -} - -func (form *RecordUpsert) ValidateAndFill() error { - if err := form.Validate(); err != nil { - return err - } - - isNew := form.record.IsNew() - - // custom insertion id can be set only on create - if isNew && form.Id != "" { - form.record.SetId(form.Id) - form.record.MarkAsNew() - } - - // set auth fields - if form.record.Collection().IsAuth() { - // generate a default username during create (if missing) - if form.record.IsNew() && form.Username == "" { - baseUsername := form.record.Collection().Name + security.RandomStringWithAlphabet(5, "123456789") - form.Username = form.dao.SuggestUniqueAuthRecordUsername(form.record.Collection().Id, baseUsername) - } - - if form.Username != "" { - if err := form.record.SetUsername(form.Username); err != nil { - return err - } - } - - if isNew || form.manageAccess { - if err := form.record.SetEmail(form.Email); err != nil { - return err - } - } - - if err := form.record.SetEmailVisibility(form.EmailVisibility); err != nil { - return err - } - - if form.manageAccess { - if err := form.record.SetVerified(form.Verified); err != nil { - return err - } - } - - if form.Password != "" { - if err := form.record.SetPassword(form.Password); err != nil { - return err - } - } - } - - // bulk load the remaining form data - form.record.Load(form.Data) - - return nil -} - -// DrySubmit performs a form submit within a transaction and reverts it. -// For actual record persistence, check the `form.Submit()` method. -// -// This method doesn't handle file uploads/deletes or trigger any app events! -func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error { - isNew := form.record.IsNew() - - if err := form.ValidateAndFill(); err != nil { - return err - } - - // use the default app.Dao to prevent changing the transaction form.Dao - // and causing "transaction has already been committed or rolled back" error - return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error { - tx, ok := txDao.DB().(*dbx.Tx) - if !ok { - return errors.New("failed to get transaction db") - } - defer tx.Rollback() - - txDao.BeforeCreateFunc = nil - txDao.AfterCreateFunc = nil - txDao.BeforeUpdateFunc = nil - txDao.AfterUpdateFunc = nil - - if err := txDao.SaveRecord(form.record); err != nil { - return err - } - - // restore record isNew state - if isNew { - form.record.MarkAsNew() - } - - if callback != nil { - return callback(txDao) - } - - return nil - }) -} - -// Submit validates the form and upserts the form Record model. -// -// You can optionally provide a list of InterceptorFunc to further -// modify the form behavior before persisting it. -func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error { - if err := form.ValidateAndFill(); err != nil { - return err - } - - return runInterceptors(func() error { - if !form.record.HasId() { - form.record.RefreshId() - form.record.MarkAsNew() - } - - // upload new files (if any) - if err := form.processFilesToUpload(); err != nil { - return fmt.Errorf("failed to process the uploaded files: %w", err) - } - - // persist the record model - if saveErr := form.dao.SaveRecord(form.record); saveErr != nil { - // try to cleanup the successfully uploaded files - if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() { - log.Println(err) - } - - return fmt.Errorf("failed to save the record: %w", saveErr) - } - - // delete old files (if any) - // - // for now fail silently to avoid reupload when `form.Submit()` - // is called manually (aka. not from an api request)... - if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() { - log.Println(err) - } - - return nil - }, interceptors...) -} - -func (form *RecordUpsert) getFilesToUploadNames() []string { - names := []string{} - - for fieldKey := range form.filesToUpload { - for _, file := range form.filesToUpload[fieldKey] { - names = append(names, file.Name()) - } - } - - return names -} - -func (form *RecordUpsert) processFilesToUpload() error { - if len(form.filesToUpload) == 0 { - return nil // no parsed file fields - } - - if !form.record.HasId() { - return errors.New("the record is not persisted yet") - } - - fs, err := form.app.NewFilesystem() - if err != nil { - return err - } - defer fs.Close() - - var uploadErrors []error // list of upload errors - var uploaded []string // list of uploaded file paths - - for fieldKey := range form.filesToUpload { - for i, file := range form.filesToUpload[fieldKey] { - path := form.record.BaseFilesPath() + "/" + file.Name() - if err := fs.UploadMultipart(file.Header(), path); err == nil { - // keep track of the already uploaded file - uploaded = append(uploaded, path) - } else { - // store the upload error - uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err)) - } - } - } - - if len(uploadErrors) > 0 { - // cleanup - try to delete the successfully uploaded files (if any) - form.deleteFilesByNamesList(uploaded) - - return fmt.Errorf("failed to upload all files: %v", uploadErrors) - } - - return nil -} - -func (form *RecordUpsert) processFilesToDelete() (err error) { - form.filesToDelete, err = form.deleteFilesByNamesList(form.filesToDelete) - return -} - -// deleteFiles deletes a list of record files by their names. -// Returns the failed/remaining files. -func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string, error) { - if len(filenames) == 0 { - return filenames, nil // nothing to delete - } - - if !form.record.HasId() { - return filenames, errors.New("the record doesn't have a unique ID") - } - - fs, err := form.app.NewFilesystem() - if err != nil { - return filenames, err - } - defer fs.Close() - - var deleteErrors []error - - for i := len(filenames) - 1; i >= 0; i-- { - filename := filenames[i] - path := form.record.BaseFilesPath() + "/" + filename - - if err := fs.Delete(path); err == nil { - // remove the deleted file from the list - filenames = append(filenames[:i], filenames[i+1:]...) - - // try to delete the related file thumbs (if any) - fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/") - } else { - // store the delete error - deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err)) - } - } - - if len(deleteErrors) > 0 { - return filenames, fmt.Errorf("failed to delete all files: %v", deleteErrors) - } - - return filenames, nil -} diff --git a/forms/record_upsert_test.go b/forms/record_upsert_test.go deleted file mode 100644 index 834813a732f0e69dae873acd7dfc331e5d3d48f2..0000000000000000000000000000000000000000 --- a/forms/record_upsert_test.go +++ /dev/null @@ -1,865 +0,0 @@ -package forms_test - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/list" -) - -func hasRecordFile(app core.App, record *models.Record, filename string) bool { - fs, _ := app.NewFilesystem() - defer fs.Close() - - fileKey := filepath.Join( - record.Collection().Id, - record.Id, - filename, - ) - - exists, _ := fs.Exists(fileKey) - - return exists -} - -func TestNewRecordUpsert(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, _ := app.Dao().FindCollectionByNameOrId("demo2") - record := models.NewRecord(collection) - record.Set("title", "test_value") - - form := forms.NewRecordUpsert(app, record) - - val := form.Data["title"] - if val != "test_value" { - t.Errorf("Expected record data to be loaded, got %v", form.Data) - } -} - -func TestRecordUpsertLoadRequestUnsupported(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - record, err := app.Dao().FindRecordById("demo2", "0yxhwia2amd8gec") - if err != nil { - t.Fatal(err) - } - - testData := "title=test123" - - form := forms.NewRecordUpsert(app, record) - req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(testData)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm) - - if err := form.LoadRequest(req, ""); err == nil { - t.Fatal("Expected LoadRequest to fail, got nil") - } -} - -func TestRecordUpsertLoadRequestJson(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t") - if err != nil { - t.Fatal(err) - } - - testData := map[string]any{ - "a": map[string]any{ - "b": map[string]any{ - "id": "test_id", - "text": "test123", - "unknown": "test456", - // file fields unset/delete - "file_one": nil, - "file_many.0": "", // delete by index - "file_many.1": "test.png", // should be ignored - "file_many.300_WlbFWSGmW9.png": nil, // delete by filename - }, - }, - } - - form := forms.NewRecordUpsert(app, record) - jsonBody, _ := json.Marshal(testData) - req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader(jsonBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - loadErr := form.LoadRequest(req, "a.b") - if loadErr != nil { - t.Fatal(loadErr) - } - - if form.Id != "test_id" { - t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id) - } - - if v, ok := form.Data["text"]; !ok || v != "test123" { - t.Fatalf("Expect title field to be %q, got %q", "test123", v) - } - - if v, ok := form.Data["unknown"]; ok { - t.Fatalf("Didn't expect unknown field to be set, got %v", v) - } - - fileOne, ok := form.Data["file_one"] - if !ok { - t.Fatal("Expect file_one field to be set") - } - if fileOne != "" { - t.Fatalf("Expect file_one field to be empty string, got %v", fileOne) - } - - fileMany, ok := form.Data["file_many"] - if !ok || fileMany == nil { - t.Fatal("Expect file_many field to be set") - } - manyfilesRemains := len(list.ToUniqueStringSlice(fileMany)) - if manyfilesRemains != 1 { - t.Fatalf("Expect only 1 file_many to remain, got \n%v", fileMany) - } -} - -func TestRecordUpsertLoadRequestMultipart(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t") - if err != nil { - t.Fatal(err) - } - - formData, mp, err := tests.MockMultipartData(map[string]string{ - "a.b.id": "test_id", - "a.b.text": "test123", - "a.b.unknown": "test456", - // file fields unset/delete - "a.b.file_one": "", - "a.b.file_many.0": "", - "a.b.file_many.300_WlbFWSGmW9.png": "test.png", // delete by name - "a.b.file_many.1": "test.png", // should be ignored - }, "file_many") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, record) - req := httptest.NewRequest(http.MethodGet, "/", formData) - req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) - loadErr := form.LoadRequest(req, "a.b") - if loadErr != nil { - t.Fatal(loadErr) - } - - if form.Id != "test_id" { - t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id) - } - - if v, ok := form.Data["text"]; !ok || v != "test123" { - t.Fatalf("Expect text field to be %q, got %q", "test123", v) - } - - if v, ok := form.Data["unknown"]; ok { - t.Fatalf("Didn't expect unknown field to be set, got %v", v) - } - - fileOne, ok := form.Data["file_one"] - if !ok { - t.Fatal("Expect file_one field to be set") - } - if fileOne != "" { - t.Fatalf("Expect file_one field to be empty string, got %v", fileOne) - } - - fileMany, ok := form.Data["file_many"] - if !ok || fileMany == nil { - t.Fatal("Expect file_many field to be set") - } - manyfilesRemains := len(list.ToUniqueStringSlice(fileMany)) - expectedRemains := 2 // -2 from 3 removed + 1 new upload - if manyfilesRemains != expectedRemains { - t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany) - } -} - -func TestRecordUpsertLoadData(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - record, err := app.Dao().FindRecordById("demo2", "llvuca81nly1qls") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, record) - - loadErr := form.LoadData(map[string]any{ - "title": "test_new", - "active": true, - }) - if loadErr != nil { - t.Fatal(loadErr) - } - - if v, ok := form.Data["title"]; !ok || v != "test_new" { - t.Fatalf("Expect title field to be %v, got %v", "test_new", v) - } - - if v, ok := form.Data["active"]; !ok || v != true { - t.Fatalf("Expect active field to be %v, got %v", true, v) - } -} - -func TestRecordUpsertDrySubmitFailure(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, _ := app.Dao().FindCollectionByNameOrId("demo1") - recordBefore, err := app.Dao().FindRecordById(collection.Id, "al1h9ijdeojtsjy") - if err != nil { - t.Fatal(err) - } - - formData, mp, err := tests.MockMultipartData(map[string]string{ - "title": "abc", - "rel_one": "missing", - }) - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, recordBefore) - req := httptest.NewRequest(http.MethodGet, "/", formData) - req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) - form.LoadRequest(req, "") - - callbackCalls := 0 - - // ensure that validate is triggered - // --- - result := form.DrySubmit(func(txDao *daos.Dao) error { - callbackCalls++ - return nil - }) - if result == nil { - t.Fatal("Expected error, got nil") - } - if callbackCalls != 0 { - t.Fatalf("Expected callbackCalls to be 0, got %d", callbackCalls) - } - - // ensure that the record changes weren't persisted - // --- - recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id) - if err != nil { - t.Fatal(err) - } - - if recordAfter.GetString("title") == "abc" { - t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "abc") - } - - if recordAfter.GetString("rel_one") == "missing" { - t.Fatalf("Expected record.rel_one to be %s, got %s", recordBefore.GetString("rel_one"), "missing") - } -} - -func TestRecordUpsertDrySubmitSuccess(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, _ := app.Dao().FindCollectionByNameOrId("demo1") - recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t") - if err != nil { - t.Fatal(err) - } - - formData, mp, err := tests.MockMultipartData(map[string]string{ - "title": "dry_test", - "file_one": "", - }, "file_many") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, recordBefore) - req := httptest.NewRequest(http.MethodGet, "/", formData) - req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) - form.LoadRequest(req, "") - - callbackCalls := 0 - - result := form.DrySubmit(func(txDao *daos.Dao) error { - callbackCalls++ - return nil - }) - if result != nil { - t.Fatalf("Expected nil, got error %v", result) - } - - // ensure callback was called - if callbackCalls != 1 { - t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls) - } - - // ensure that the record changes weren't persisted - // --- - recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id) - if err != nil { - t.Fatal(err) - } - - if recordAfter.GetString("title") == "dry_test" { - t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "dry_test") - } - if recordAfter.GetString("file_one") == "" { - t.Fatal("Expected record.file_one to not be changed, got empty string") - } - - // file wasn't removed - if !hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) { - t.Fatal("file_one file should not have been deleted") - } -} - -func TestRecordUpsertSubmitFailure(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo1") - if err != nil { - t.Fatal(err) - } - - recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t") - if err != nil { - t.Fatal(err) - } - - formData, mp, err := tests.MockMultipartData(map[string]string{ - "text": "abc", - "bool": "false", - "select_one": "invalid", - "file_many": "invalid", - "email": "invalid", - }, "file_one") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, recordBefore) - req := httptest.NewRequest(http.MethodGet, "/", formData) - req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) - form.LoadRequest(req, "") - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorCalls++ - return next() - } - } - - // ensure that validate is triggered - // --- - result := form.Submit(interceptor) - if result == nil { - t.Fatal("Expected error, got nil") - } - - // check interceptor calls - // --- - if interceptorCalls != 0 { - t.Fatalf("Expected interceptor to be called 0 times, got %d", interceptorCalls) - } - - // ensure that the record changes weren't persisted - // --- - recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id) - if err != nil { - t.Fatal(err) - } - - if v := recordAfter.Get("text"); v == "abc" { - t.Fatalf("Expected record.text not to change, got %v", v) - } - if v := recordAfter.Get("bool"); v == false { - t.Fatalf("Expected record.bool not to change, got %v", v) - } - if v := recordAfter.Get("select_one"); v == "invalid" { - t.Fatalf("Expected record.select_one not to change, got %v", v) - } - if v := recordAfter.Get("email"); v == "invalid" { - t.Fatalf("Expected record.email not to change, got %v", v) - } - if v := recordAfter.GetStringSlice("file_many"); len(v) != 3 { - t.Fatalf("Expected record.file_many not to change, got %v", v) - } - - // ensure the files weren't removed - for _, f := range recordAfter.GetStringSlice("file_many") { - if !hasRecordFile(app, recordAfter, f) { - t.Fatal("file_many file should not have been deleted") - } - } -} - -func TestRecordUpsertSubmitSuccess(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, _ := app.Dao().FindCollectionByNameOrId("demo1") - recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t") - if err != nil { - t.Fatal(err) - } - - formData, mp, err := tests.MockMultipartData(map[string]string{ - "text": "test_save", - "bool": "true", - "select_one": "optionA", - "file_one": "", - }, "file_many.1", "file_many") // replace + new file - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, recordBefore) - req := httptest.NewRequest(http.MethodGet, "/", formData) - req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) - form.LoadRequest(req, "") - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorCalls++ - return next() - } - } - - result := form.Submit(interceptor) - if result != nil { - t.Fatalf("Expected nil, got error %v", result) - } - - // check interceptor calls - // --- - if interceptorCalls != 1 { - t.Fatalf("Expected interceptor to be called 1 time, got %d", interceptorCalls) - } - - // ensure that the record changes were persisted - // --- - recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id) - if err != nil { - t.Fatal(err) - } - - if v := recordAfter.GetString("text"); v != "test_save" { - t.Fatalf("Expected record.text to be %v, got %v", v, "test_save") - } - - if hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) { - t.Fatal("Expected record.file_one to be deleted") - } - - fileMany := (recordAfter.GetStringSlice("file_many")) - if len(fileMany) != 4 { // 1 replace + 1 new - t.Fatalf("Expected 4 record.file_many, got %d (%v)", len(fileMany), fileMany) - } - for _, f := range fileMany { - if !hasRecordFile(app, recordAfter, f) { - t.Fatalf("Expected file %q to exist", f) - } - } -} - -func TestRecordUpsertSubmitInterceptors(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, _ := app.Dao().FindCollectionByNameOrId("demo3") - record, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, record) - form.Data["title"] = "test_new" - - testErr := errors.New("test_error") - interceptorRecordTitle := "" - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptor1Called = true - return next() - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorRecordTitle = record.GetString("title") // to check if the record was filled - interceptor2Called = true - return testErr - } - } - - submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorRecordTitle != form.Data["title"].(string) { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} - -func TestRecordUpsertWithCustomId(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo3") - if err != nil { - t.Fatal(err) - } - - existingRecord, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - name string - data map[string]string - record *models.Record - expectError bool - }{ - { - "empty data", - map[string]string{}, - models.NewRecord(collection), - false, - }, - { - "empty id", - map[string]string{"id": ""}, - models.NewRecord(collection), - false, - }, - { - "id < 15 chars", - map[string]string{"id": "a23"}, - models.NewRecord(collection), - true, - }, - { - "id > 15 chars", - map[string]string{"id": "a234567890123456"}, - models.NewRecord(collection), - true, - }, - { - "id = 15 chars (invalid chars)", - map[string]string{"id": "a@3456789012345"}, - models.NewRecord(collection), - true, - }, - { - "id = 15 chars (valid chars)", - map[string]string{"id": "a23456789012345"}, - models.NewRecord(collection), - false, - }, - { - "changing the id of an existing record", - map[string]string{"id": "b23456789012345"}, - existingRecord, - true, - }, - { - "using the same existing record id", - map[string]string{"id": existingRecord.Id}, - existingRecord, - false, - }, - { - "skipping the id for existing record", - map[string]string{}, - existingRecord, - false, - }, - } - - for _, scenario := range scenarios { - formData, mp, err := tests.MockMultipartData(scenario.data) - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordUpsert(app, scenario.record) - req := httptest.NewRequest(http.MethodGet, "/", formData) - req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) - form.LoadRequest(req, "") - - dryErr := form.DrySubmit(nil) - hasDryErr := dryErr != nil - - submitErr := form.Submit() - hasSubmitErr := submitErr != nil - - if hasDryErr != hasSubmitErr { - t.Errorf("[%s] Expected hasDryErr and hasSubmitErr to have the same value, got %v vs %v", scenario.name, hasDryErr, hasSubmitErr) - } - - if hasSubmitErr != scenario.expectError { - t.Errorf("[%s] Expected hasSubmitErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasSubmitErr, submitErr) - } - - if id, ok := scenario.data["id"]; ok && id != "" && !hasSubmitErr { - _, err := app.Dao().FindRecordById(collection.Id, id) - if err != nil { - t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, id, err) - } - } - } -} - -func TestRecordUpsertAuthRecord(t *testing.T) { - scenarios := []struct { - testName string - existingId string - data map[string]any - manageAccess bool - expectError bool - }{ - { - "empty create data", - "", - map[string]any{}, - false, - true, - }, - { - "empty update data", - "4q1xlclmfloku33", - map[string]any{}, - false, - false, - }, - { - "minimum valid create data", - "", - map[string]any{ - "password": "12345678", - "passwordConfirm": "12345678", - }, - false, - false, - }, - { - "create with all allowed auth fields", - "", - map[string]any{ - "username": "test_new", - "email": "test_new@example.com", - "emailVisibility": true, - "password": "12345678", - "passwordConfirm": "12345678", - }, - false, - false, - }, - - // username - { - "invalid username characters", - "", - map[string]any{ - "username": "test abc!@#", - "password": "12345678", - "passwordConfirm": "12345678", - }, - false, - true, - }, - { - "invalid username length (less than 3)", - "", - map[string]any{ - "username": "ab", - "password": "12345678", - "passwordConfirm": "12345678", - }, - false, - true, - }, - { - "invalid username length (more than 100)", - "", - map[string]any{ - "username": strings.Repeat("a", 101), - "password": "12345678", - "passwordConfirm": "12345678", - }, - false, - true, - }, - - // verified - { - "try to set verified without managed access", - "", - map[string]any{ - "verified": true, - "password": "12345678", - "passwordConfirm": "12345678", - }, - false, - true, - }, - { - "try to update verified without managed access", - "4q1xlclmfloku33", - map[string]any{ - "verified": true, - }, - false, - true, - }, - { - "set verified with managed access", - "", - map[string]any{ - "verified": true, - "password": "12345678", - "passwordConfirm": "12345678", - }, - true, - false, - }, - { - "update verified with managed access", - "4q1xlclmfloku33", - map[string]any{ - "verified": true, - }, - true, - false, - }, - - // email - { - "try to update email without managed access", - "4q1xlclmfloku33", - map[string]any{ - "email": "test_update@example.com", - }, - false, - true, - }, - { - "update email with managed access", - "4q1xlclmfloku33", - map[string]any{ - "email": "test_update@example.com", - }, - true, - false, - }, - - // password - { - "try to update password without managed access", - "4q1xlclmfloku33", - map[string]any{ - "password": "1234567890", - "passwordConfirm": "1234567890", - }, - false, - true, - }, - { - "update password without managed access but with oldPassword", - "4q1xlclmfloku33", - map[string]any{ - "oldPassword": "1234567890", - "password": "1234567890", - "passwordConfirm": "1234567890", - }, - false, - false, - }, - { - "update email with managed access (without oldPassword)", - "4q1xlclmfloku33", - map[string]any{ - "password": "1234567890", - "passwordConfirm": "1234567890", - }, - true, - false, - }, - } - - for _, s := range scenarios { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - record := models.NewRecord(collection) - if s.existingId != "" { - var err error - record, err = app.Dao().FindRecordById(collection.Id, s.existingId) - if err != nil { - t.Errorf("[%s] Failed to fetch auth record with id %s", s.testName, s.existingId) - continue - } - } - - form := forms.NewRecordUpsert(app, record) - form.SetFullManageAccess(s.manageAccess) - if err := form.LoadData(s.data); err != nil { - t.Errorf("[%s] Failed to load form data", s.testName) - continue - } - - submitErr := form.Submit() - - hasErr := submitErr != nil - if hasErr != s.expectError { - t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.testName, s.expectError, hasErr, submitErr) - } - - if !hasErr && record.Username() == "" { - t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.testName, record) - } - } -} diff --git a/forms/record_verification_confirm.go b/forms/record_verification_confirm.go deleted file mode 100644 index a4f15f46dccfc04b75957a564fbfa02f23fe07e5..0000000000000000000000000000000000000000 --- a/forms/record_verification_confirm.go +++ /dev/null @@ -1,114 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/spf13/cast" -) - -// RecordVerificationConfirm is an auth record email verification confirmation form. -type RecordVerificationConfirm struct { - app core.App - collection *models.Collection - dao *daos.Dao - - Token string `form:"token" json:"token"` -} - -// NewRecordVerificationConfirm creates a new [RecordVerificationConfirm] -// form initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordVerificationConfirm(app core.App, collection *models.Collection) *RecordVerificationConfirm { - return &RecordVerificationConfirm{ - app: app, - dao: app.Dao(), - collection: collection, - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordVerificationConfirm) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *RecordVerificationConfirm) Validate() error { - return validation.ValidateStruct(form, - validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)), - ) -} - -func (form *RecordVerificationConfirm) checkToken(value any) error { - v, _ := value.(string) - if v == "" { - return nil // nothing to check - } - - claims, _ := security.ParseUnverifiedJWT(v) - email := cast.ToString(claims["email"]) - if email == "" { - return validation.NewError("validation_invalid_token_claims", "Missing email token claim.") - } - - record, err := form.dao.FindAuthRecordByToken( - v, - form.app.Settings().RecordVerificationToken.Secret, - ) - if err != nil || record == nil { - return validation.NewError("validation_invalid_token", "Invalid or expired token.") - } - - if record.Collection().Id != form.collection.Id { - return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.") - } - - if record.Email() != email { - return validation.NewError("validation_token_email_mismatch", "The record email doesn't match with the requested token claims.") - } - - return nil -} - -// Submit validates and submits the form. -// On success returns the verified auth record associated to `form.Token`. -// -// You can optionally provide a list of InterceptorWithRecordFunc to -// further modify the form behavior before persisting it. -func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) { - if err := form.Validate(); err != nil { - return nil, err - } - - record, err := form.dao.FindAuthRecordByToken( - form.Token, - form.app.Settings().RecordVerificationToken.Secret, - ) - if err != nil { - return nil, err - } - - wasVerified := record.Verified() - - if !wasVerified { - record.SetVerified(true) - } - - interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error { - if wasVerified { - return nil // already verified - } - - return form.dao.SaveRecord(m) - }, interceptors...) - - if interceptorsErr != nil { - return nil, interceptorsErr - } - - return record, nil -} diff --git a/forms/record_verification_confirm_test.go b/forms/record_verification_confirm_test.go deleted file mode 100644 index d927f5b70127db4f3a7fcc6813f3f8c37af5a5bc..0000000000000000000000000000000000000000 --- a/forms/record_verification_confirm_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" -) - -func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - jsonData string - expectError bool - }{ - // empty data (Validate call check) - { - `{}`, - true, - }, - // expired token (Validate call check) - { - `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.Avbt9IP8sBisVz_2AGrlxLDvangVq4PhL2zqQVYLKlE"}`, - true, - }, - // valid token (already verified record) - { - `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"}`, - false, - }, - // valid token (unverified record) - { - `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"}`, - false, - }, - } - - for i, s := range scenarios { - form := forms.NewRecordVerificationConfirm(testApp, authCollection) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(r *models.Record) error { - interceptorCalls++ - return next(r) - } - } - - record, err := form.Submit(interceptor) - - // check interceptor calls - expectInterceptorCalls := 1 - if s.expectError { - expectInterceptorCalls = 0 - } - if interceptorCalls != expectInterceptorCalls { - t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) - } - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - - if hasErr { - continue - } - - claims, _ := security.ParseUnverifiedJWT(form.Token) - tokenRecordId := claims["id"] - - if record.Id != tokenRecordId { - t.Errorf("(%d) Expected record.Id %q, got %q", i, tokenRecordId, record.Id) - } - - if !record.Verified() { - t.Errorf("(%d) Expected record.Verified() to be true, got false", i) - } - } -} - -func TestRecordVerificationConfirmInterceptors(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordVerificationConfirm(testApp, authCollection) - form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc" - interceptorVerified := authRecord.Verified() - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptor1Called = true - return next(record) - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptorVerified = record.Verified() - interceptor2Called = true - return testErr - } - } - - _, submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorVerified == authRecord.Verified() { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} diff --git a/forms/record_verification_request.go b/forms/record_verification_request.go deleted file mode 100644 index 3868d6107cc297ddb63ab7f40207752d89ccf366..0000000000000000000000000000000000000000 --- a/forms/record_verification_request.go +++ /dev/null @@ -1,101 +0,0 @@ -package forms - -import ( - "errors" - "time" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/mails" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/types" -) - -// RecordVerificationRequest is an auth record email verification request form. -type RecordVerificationRequest struct { - app core.App - collection *models.Collection - dao *daos.Dao - resendThreshold float64 // in seconds - - Email string `form:"email" json:"email"` -} - -// NewRecordVerificationRequest creates a new [RecordVerificationRequest] -// form initialized with from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewRecordVerificationRequest(app core.App, collection *models.Collection) *RecordVerificationRequest { - return &RecordVerificationRequest{ - app: app, - dao: app.Dao(), - collection: collection, - resendThreshold: 120, // 2 min - } -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *RecordVerificationRequest) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -// -// // This method doesn't verify that auth record with `form.Email` exists (this is done on Submit). -func (form *RecordVerificationRequest) Validate() error { - return validation.ValidateStruct(form, - validation.Field( - &form.Email, - validation.Required, - validation.Length(1, 255), - is.EmailFormat, - ), - ) -} - -// Submit validates and sends a verification request email -// to the `form.Email` auth record. -// -// You can optionally provide a list of InterceptorWithRecordFunc to -// further modify the form behavior before persisting it. -func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRecordFunc) error { - if err := form.Validate(); err != nil { - return err - } - - record, err := form.dao.FindFirstRecordByData( - form.collection.Id, - schema.FieldNameEmail, - form.Email, - ) - if err != nil { - return err - } - - if !record.Verified() { - now := time.Now().UTC() - lastVerificationSentAt := record.LastVerificationSentAt().Time() - if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold { - return errors.New("A verification email was already sent.") - } - - // update last sent timestamp - record.SetLastVerificationSentAt(types.NowDateTime()) - } - - return runInterceptorsWithRecord(record, func(m *models.Record) error { - if m.Verified() { - return nil // already verified - } - - if err := mails.SendRecordVerification(form.app, m); err != nil { - return err - } - - return form.dao.SaveRecord(m) - }, interceptors...) -} diff --git a/forms/record_verification_request_test.go b/forms/record_verification_request_test.go deleted file mode 100644 index 82a6dc25fcaaae4a69d953e2abd68b7d8ad6cc67..0000000000000000000000000000000000000000 --- a/forms/record_verification_request_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "testing" - "time" - - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/types" -) - -func TestRecordVerificationRequestSubmit(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("clients") - if err != nil { - t.Fatal(err) - } - - scenarios := []struct { - jsonData string - expectError bool - expectMail bool - }{ - // empty field (Validate call check) - { - `{"email":""}`, - true, - false, - }, - // invalid email field (Validate call check) - { - `{"email":"invalid"}`, - true, - false, - }, - // nonexisting user - { - `{"email":"missing@example.com"}`, - true, - false, - }, - // existing user (already verified) - { - `{"email":"test@example.com"}`, - false, - false, - }, - // existing user (already verified) - repeating request to test threshod skip - { - `{"email":"test@example.com"}`, - false, - false, - }, - // existing user (unverified) - { - `{"email":"test2@example.com"}`, - false, - true, - }, - // existing user (inverified) - reached send threshod - { - `{"email":"test2@example.com"}`, - true, - false, - }, - } - - now := types.NowDateTime() - time.Sleep(1 * time.Millisecond) - - for i, s := range scenarios { - testApp.TestMailer.TotalSend = 0 // reset - form := forms.NewRecordVerificationRequest(testApp, authCollection) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("[%d] Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(r *models.Record) error { - interceptorCalls++ - return next(r) - } - } - - err := form.Submit(interceptor) - - // check interceptor calls - expectInterceptorCalls := 1 - if s.expectError { - expectInterceptorCalls = 0 - } - if interceptorCalls != expectInterceptorCalls { - t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls) - } - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - - expectedMails := 0 - if s.expectMail { - expectedMails = 1 - } - if testApp.TestMailer.TotalSend != expectedMails { - t.Errorf("[%d] Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) - } - - if s.expectError { - continue - } - - user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email) - if err != nil { - t.Errorf("[%d] Expected user with email %q to exist, got nil", i, form.Email) - continue - } - - // check whether LastVerificationSentAt was updated - if !user.Verified() && user.LastVerificationSentAt().Time().Sub(now.Time()) < 0 { - t.Errorf("[%d] Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt()) - } - } -} - -func TestRecordVerificationRequestInterceptors(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - authCollection, err := testApp.Dao().FindCollectionByNameOrId("users") - if err != nil { - t.Fatal(err) - } - - authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - form := forms.NewRecordVerificationRequest(testApp, authCollection) - form.Email = authRecord.Email() - interceptorLastVerificationSentAt := authRecord.LastVerificationSentAt() - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptor1Called = true - return next(record) - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc { - return func(record *models.Record) error { - interceptorLastVerificationSentAt = record.LastVerificationSentAt() - interceptor2Called = true - return testErr - } - } - - submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } - - if interceptorLastVerificationSentAt.String() == authRecord.LastVerificationSentAt().String() { - t.Fatalf("Expected the form model to be filled before calling the interceptors") - } -} diff --git a/forms/settings_upsert.go b/forms/settings_upsert.go deleted file mode 100644 index 6864d1c80521b7707637f76cb94fbf617c7e6814..0000000000000000000000000000000000000000 --- a/forms/settings_upsert.go +++ /dev/null @@ -1,77 +0,0 @@ -package forms - -import ( - "os" - "time" - - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models/settings" -) - -// SettingsUpsert is a [settings.Settings] upsert (create/update) form. -type SettingsUpsert struct { - *settings.Settings - - app core.App - dao *daos.Dao -} - -// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer -// config created from the provided [core.App] instance. -// -// If you want to submit the form as part of a transaction, -// you can change the default Dao via [SetDao()]. -func NewSettingsUpsert(app core.App) *SettingsUpsert { - form := &SettingsUpsert{ - app: app, - dao: app.Dao(), - } - - // load the application settings into the form - form.Settings, _ = app.Settings().Clone() - - return form -} - -// SetDao replaces the default form Dao instance with the provided one. -func (form *SettingsUpsert) SetDao(dao *daos.Dao) { - form.dao = dao -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *SettingsUpsert) Validate() error { - return form.Settings.Validate() -} - -// Submit validates the form and upserts the loaded settings. -// -// On success the app settings will be refreshed with the form ones. -// -// You can optionally provide a list of InterceptorFunc to further -// modify the form behavior before persisting it. -func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error { - if err := form.Validate(); err != nil { - return err - } - - return runInterceptors(func() error { - encryptionKey := os.Getenv(form.app.EncryptionEnv()) - if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil { - return err - } - - // explicitly trigger old logs deletion - form.app.LogsDao().DeleteOldRequests( - time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays), - ) - - if form.Settings.Logs.MaxDays == 0 { - // no logs are allowed -> reclaim preserved disk space after the previous delete operation - form.app.LogsDao().Vacuum() - } - - // merge the application settings with the form ones - return form.app.Settings().Merge(form.Settings) - }, interceptors...) -} diff --git a/forms/settings_upsert_test.go b/forms/settings_upsert_test.go deleted file mode 100644 index 494545c00dfa55e4bafe654738dcb93defd4b8ca..0000000000000000000000000000000000000000 --- a/forms/settings_upsert_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package forms_test - -import ( - "encoding/json" - "errors" - "os" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/security" -) - -func TestNewSettingsUpsert(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - app.Settings().Meta.AppName = "name_update" - - form := forms.NewSettingsUpsert(app) - - formSettings, _ := json.Marshal(form.Settings) - appSettings, _ := json.Marshal(app.Settings()) - - if string(formSettings) != string(appSettings) { - t.Errorf("Expected settings \n%s, got \n%s", string(appSettings), string(formSettings)) - } -} - -func TestSettingsUpsertValidateAndSubmit(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - scenarios := []struct { - jsonData string - encryption bool - expectedErrors []string - }{ - // empty (plain) - {"{}", false, nil}, - // empty (encrypt) - {"{}", true, nil}, - // failure - invalid data - { - `{"meta": {"appName": ""}, "logs": {"maxDays": -1}}`, - false, - []string{"meta", "logs"}, - }, - // success - valid data (plain) - { - `{"meta": {"appName": "test"}, "logs": {"maxDays": 0}}`, - false, - nil, - }, - // success - valid data (encrypt) - { - `{"meta": {"appName": "test"}, "logs": {"maxDays": 7}}`, - true, - nil, - }, - } - - for i, s := range scenarios { - if s.encryption { - os.Setenv(app.EncryptionEnv(), security.RandomString(32)) - } else { - os.Unsetenv(app.EncryptionEnv()) - } - - form := forms.NewSettingsUpsert(app) - - // load data - loadErr := json.Unmarshal([]byte(s.jsonData), form) - if loadErr != nil { - t.Errorf("(%d) Failed to load form data: %v", i, loadErr) - continue - } - - interceptorCalls := 0 - interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptorCalls++ - return next() - } - } - - // parse errors - result := form.Submit(interceptor) - errs, ok := result.(validation.Errors) - if !ok && result != nil { - t.Errorf("(%d) Failed to parse errors %v", i, result) - continue - } - - // check interceptor calls - expectInterceptorCall := 1 - if len(s.expectedErrors) > 0 { - expectInterceptorCall = 0 - } - if interceptorCalls != expectInterceptorCall { - t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls) - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) - } - } - - if len(s.expectedErrors) > 0 { - continue - } - - formSettings, _ := json.Marshal(form.Settings) - appSettings, _ := json.Marshal(app.Settings()) - - if string(formSettings) != string(appSettings) { - t.Errorf("Expected app settings \n%s, got \n%s", string(appSettings), string(formSettings)) - } - } -} - -func TestSettingsUpsertSubmitInterceptors(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - form := forms.NewSettingsUpsert(app) - form.Meta.AppName = "test_new" - - testErr := errors.New("test_error") - - interceptor1Called := false - interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptor1Called = true - return next() - } - } - - interceptor2Called := false - interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { - return func() error { - interceptor2Called = true - return testErr - } - } - - submitErr := form.Submit(interceptor1, interceptor2) - if submitErr != testErr { - t.Fatalf("Expected submitError %v, got %v", testErr, submitErr) - } - - if !interceptor1Called { - t.Fatalf("Expected interceptor1 to be called") - } - - if !interceptor2Called { - t.Fatalf("Expected interceptor2 to be called") - } -} diff --git a/forms/test_email_send.go b/forms/test_email_send.go deleted file mode 100644 index 5dd902e41ac6044e81b9cdfe5feb6fde862da1d7..0000000000000000000000000000000000000000 --- a/forms/test_email_send.go +++ /dev/null @@ -1,77 +0,0 @@ -package forms - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/mails" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" -) - -const ( - templateVerification = "verification" - templatePasswordReset = "password-reset" - templateEmailChange = "email-change" -) - -// TestEmailSend is a email template test request form. -type TestEmailSend struct { - app core.App - - Template string `form:"template" json:"template"` - Email string `form:"email" json:"email"` -} - -// NewTestEmailSend creates and initializes new TestEmailSend form. -func NewTestEmailSend(app core.App) *TestEmailSend { - return &TestEmailSend{app: app} -} - -// Validate makes the form validatable by implementing [validation.Validatable] interface. -func (form *TestEmailSend) Validate() error { - return validation.ValidateStruct(form, - validation.Field( - &form.Email, - validation.Required, - validation.Length(1, 255), - is.EmailFormat, - ), - validation.Field( - &form.Template, - validation.Required, - validation.In(templateVerification, templatePasswordReset, templateEmailChange), - ), - ) -} - -// Submit validates and sends a test email to the form.Email address. -func (form *TestEmailSend) Submit() error { - if err := form.Validate(); err != nil { - return err - } - - // create a test auth record - collection := &models.Collection{ - BaseModel: models.BaseModel{Id: "__pb_test_collection_id__"}, - Name: "__pb_test_collection_name__", - Type: models.CollectionTypeAuth, - } - - record := models.NewRecord(collection) - record.Id = "__pb_test_id__" - record.Set(schema.FieldNameUsername, "pb_test") - record.Set(schema.FieldNameEmail, form.Email) - record.RefreshTokenKey() - - switch form.Template { - case templateVerification: - return mails.SendRecordVerification(form.app, record) - case templatePasswordReset: - return mails.SendRecordPasswordReset(form.app, record) - case templateEmailChange: - return mails.SendRecordChangeEmail(form.app, record, form.Email) - } - - return nil -} diff --git a/forms/test_email_send_test.go b/forms/test_email_send_test.go deleted file mode 100644 index 6040609887799fb391879820cb58e293e1975c08..0000000000000000000000000000000000000000 --- a/forms/test_email_send_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package forms_test - -import ( - "strings" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/forms" - "github.com/pocketbase/pocketbase/tests" -) - -func TestEmailSendValidateAndSubmit(t *testing.T) { - scenarios := []struct { - template string - email string - expectedErrors []string - }{ - {"", "", []string{"template", "email"}}, - {"invalid", "test@example.com", []string{"template"}}, - {"verification", "invalid", []string{"email"}}, - {"verification", "test@example.com", nil}, - {"password-reset", "test@example.com", nil}, - {"email-change", "test@example.com", nil}, - } - - for i, s := range scenarios { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - form := forms.NewTestEmailSend(app) - form.Email = s.email - form.Template = s.template - - result := form.Submit() - - // parse errors - errs, ok := result.(validation.Errors) - if !ok && result != nil { - t.Errorf("(%d) Failed to parse errors %v", i, result) - continue - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) - continue - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) - continue - } - } - - expectedEmails := 1 - if len(s.expectedErrors) > 0 { - expectedEmails = 0 - } - - if app.TestMailer.TotalSend != expectedEmails { - t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend) - } - - if len(s.expectedErrors) > 0 { - continue - } - - expectedContent := "Verify" - if s.template == "password-reset" { - expectedContent = "Reset password" - } else if s.template == "email-change" { - expectedContent = "Confirm new email" - } - - if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) { - t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML) - } - } -} diff --git a/forms/validators/file.go b/forms/validators/file.go deleted file mode 100644 index c1fbdca4e12d20b44825fd64bdad7b5cae25abec..0000000000000000000000000000000000000000 --- a/forms/validators/file.go +++ /dev/null @@ -1,71 +0,0 @@ -package validators - -import ( - "fmt" - "strings" - - "github.com/gabriel-vasile/mimetype" - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/tools/rest" -) - -// UploadedFileSize checks whether the validated `rest.UploadedFile` -// size is no more than the provided maxBytes. -// -// Example: -// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000))) -func UploadedFileSize(maxBytes int) validation.RuleFunc { - return func(value any) error { - v, _ := value.(*rest.UploadedFile) - if v == nil { - return nil // nothing to validate - } - - if int(v.Header().Size) > maxBytes { - return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes)) - } - - return nil - } -} - -// UploadedFileMimeType checks whether the validated `rest.UploadedFile` -// mimetype is within the provided allowed mime types. -// -// Example: -// validMimeTypes := []string{"test/plain","image/jpeg"} -// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes))) -func UploadedFileMimeType(validTypes []string) validation.RuleFunc { - return func(value any) error { - v, _ := value.(*rest.UploadedFile) - if v == nil { - return nil // nothing to validate - } - - if len(validTypes) == 0 { - return validation.NewError("validation_invalid_mime_type", "Unsupported file type.") - } - - f, err := v.Header().Open() - if err != nil { - return validation.NewError("validation_invalid_mime_type", "Unsupported file type.") - } - defer f.Close() - - filetype, err := mimetype.DetectReader(f) - if err != nil { - return validation.NewError("validation_invalid_mime_type", "Unsupported file type.") - } - - for _, t := range validTypes { - if filetype.Is(t) { - return nil // valid - } - } - - return validation.NewError("validation_invalid_mime_type", fmt.Sprintf( - "The following mime types are only allowed: %s.", - strings.Join(validTypes, ","), - )) - } -} diff --git a/forms/validators/file_test.go b/forms/validators/file_test.go deleted file mode 100644 index 2aa4929871ef121ed74762fbc31a11fc5b750e55..0000000000000000000000000000000000000000 --- a/forms/validators/file_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package validators_test - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/pocketbase/pocketbase/forms/validators" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/rest" -) - -func TestUploadedFileSize(t *testing.T) { - data, mp, err := tests.MockMultipartData(nil, "test") - if err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodPost, "/", data) - req.Header.Add("Content-Type", mp.FormDataContentType()) - - files, err := rest.FindUploadedFiles(req, "test") - if err != nil { - t.Fatal(err) - } - - if len(files) != 1 { - t.Fatalf("Expected one test file, got %d", len(files)) - } - - scenarios := []struct { - maxBytes int - file *rest.UploadedFile - expectError bool - }{ - {0, nil, false}, - {4, nil, false}, - {3, files[0], true}, // all test files have "test" as content - {4, files[0], false}, - {5, files[0], false}, - } - - for i, s := range scenarios { - err := validators.UploadedFileSize(s.maxBytes)(s.file) - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - } -} - -func TestUploadedFileMimeType(t *testing.T) { - data, mp, err := tests.MockMultipartData(nil, "test") - if err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodPost, "/", data) - req.Header.Add("Content-Type", mp.FormDataContentType()) - - files, err := rest.FindUploadedFiles(req, "test") - if err != nil { - t.Fatal(err) - } - - if len(files) != 1 { - t.Fatalf("Expected one test file, got %d", len(files)) - } - - scenarios := []struct { - types []string - file *rest.UploadedFile - expectError bool - }{ - {nil, nil, false}, - {[]string{"image/jpeg"}, nil, false}, - {[]string{}, files[0], true}, - {[]string{"image/jpeg"}, files[0], true}, - // test files are detected as "text/plain; charset=utf-8" content type - {[]string{"image/jpeg", "text/plain; charset=utf-8"}, files[0], false}, - } - - for i, s := range scenarios { - err := validators.UploadedFileMimeType(s.types)(s.file) - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - } -} diff --git a/forms/validators/record_data.go b/forms/validators/record_data.go deleted file mode 100644 index e830d6a39d2a490aaa8d6a40628c8597b0dd77b6..0000000000000000000000000000000000000000 --- a/forms/validators/record_data.go +++ /dev/null @@ -1,374 +0,0 @@ -package validators - -import ( - "fmt" - "net/url" - "regexp" - "strings" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/rest" - "github.com/pocketbase/pocketbase/tools/types" -) - -var requiredErr = validation.NewError("validation_required", "Missing required value") - -// NewRecordDataValidator creates new [models.Record] data validator -// using the provided record constraints and schema. -// -// Example: -// validator := NewRecordDataValidator(app.Dao(), record, nil) -// err := validator.Validate(map[string]any{"test":123}) -func NewRecordDataValidator( - dao *daos.Dao, - record *models.Record, - uploadedFiles map[string][]*rest.UploadedFile, -) *RecordDataValidator { - return &RecordDataValidator{ - dao: dao, - record: record, - uploadedFiles: uploadedFiles, - } -} - -// RecordDataValidator defines a model.Record data validator -// using the provided record constraints and schema. -type RecordDataValidator struct { - dao *daos.Dao - record *models.Record - uploadedFiles map[string][]*rest.UploadedFile -} - -// Validate validates the provided `data` by checking it against -// the validator record constraints and schema. -func (validator *RecordDataValidator) Validate(data map[string]any) error { - keyedSchema := validator.record.Collection().Schema.AsMap() - if len(keyedSchema) == 0 { - return nil // no fields to check - } - - if len(data) == 0 { - return validation.NewError("validation_empty_data", "No data to validate") - } - - errs := validation.Errors{} - - // check for unknown fields - for key := range data { - if _, ok := keyedSchema[key]; !ok { - errs[key] = validation.NewError("validation_unknown_field", "Unknown field") - } - } - if len(errs) > 0 { - return errs - } - - for key, field := range keyedSchema { - // normalize value to emulate the same behavior - // when fetching or persisting the record model - value := field.PrepareValue(data[key]) - - // check required constraint - if field.Required && validation.Required.Validate(value) != nil { - errs[key] = requiredErr - continue - } - - // validate field value by its field type - if err := validator.checkFieldValue(field, value); err != nil { - errs[key] = err - continue - } - - // check unique constraint - if field.Unique && !validator.dao.IsRecordValueUnique( - validator.record.Collection().Id, - key, - value, - validator.record.GetId(), - ) { - errs[key] = validation.NewError("validation_not_unique", "Value must be unique") - continue - } - } - - if len(errs) == 0 { - return nil - } - - return errs -} - -func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField, value any) error { - switch field.Type { - case schema.FieldTypeText: - return validator.checkTextValue(field, value) - case schema.FieldTypeNumber: - return validator.checkNumberValue(field, value) - case schema.FieldTypeBool: - return validator.checkBoolValue(field, value) - case schema.FieldTypeEmail: - return validator.checkEmailValue(field, value) - case schema.FieldTypeUrl: - return validator.checkUrlValue(field, value) - case schema.FieldTypeDate: - return validator.checkDateValue(field, value) - case schema.FieldTypeSelect: - return validator.checkSelectValue(field, value) - case schema.FieldTypeJson: - return validator.checkJsonValue(field, value) - case schema.FieldTypeFile: - return validator.checkFileValue(field, value) - case schema.FieldTypeRelation: - return validator.checkRelationValue(field, value) - } - - return nil -} - -func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField, value any) error { - val, _ := value.(string) - if val == "" { - return nil // nothing to check (skip zero-defaults) - } - - options, _ := field.Options.(*schema.TextOptions) - - if options.Min != nil && len(val) < *options.Min { - return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min)) - } - - if options.Max != nil && len(val) > *options.Max { - return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max)) - } - - if options.Pattern != "" { - match, _ := regexp.MatchString(options.Pattern, val) - if !match { - return validation.NewError("validation_invalid_format", "Invalid value format") - } - } - - return nil -} - -func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField, value any) error { - val, _ := value.(float64) - if val == 0 { - return nil // nothing to check (skip zero-defaults) - } - - options, _ := field.Options.(*schema.NumberOptions) - - if options.Min != nil && val < *options.Min { - return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *options.Min)) - } - - if options.Max != nil && val > *options.Max { - return validation.NewError("validation_max_number_constraint", fmt.Sprintf("Must be less than %f", *options.Max)) - } - - return nil -} - -func (validator *RecordDataValidator) checkBoolValue(field *schema.SchemaField, value any) error { - return nil -} - -func (validator *RecordDataValidator) checkEmailValue(field *schema.SchemaField, value any) error { - val, _ := value.(string) - if val == "" { - return nil // nothing to check - } - - if is.EmailFormat.Validate(val) != nil { - return validation.NewError("validation_invalid_email", "Must be a valid email") - } - - options, _ := field.Options.(*schema.EmailOptions) - domain := val[strings.LastIndex(val, "@")+1:] - - // only domains check - if len(options.OnlyDomains) > 0 && !list.ExistInSlice(domain, options.OnlyDomains) { - return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed") - } - - // except domains check - if len(options.ExceptDomains) > 0 && list.ExistInSlice(domain, options.ExceptDomains) { - return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed") - } - - return nil -} - -func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, value any) error { - val, _ := value.(string) - if val == "" { - return nil // nothing to check - } - - if is.URL.Validate(val) != nil { - return validation.NewError("validation_invalid_url", "Must be a valid url") - } - - options, _ := field.Options.(*schema.UrlOptions) - - // extract host/domain - u, _ := url.Parse(val) - host := u.Host - - // only domains check - if len(options.OnlyDomains) > 0 && !list.ExistInSlice(host, options.OnlyDomains) { - return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed") - } - - // except domains check - if len(options.ExceptDomains) > 0 && list.ExistInSlice(host, options.ExceptDomains) { - return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed") - } - - return nil -} - -func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error { - val, _ := value.(types.DateTime) - if val.IsZero() { - if field.Required { - return requiredErr - } - return nil // nothing to check - } - - options, _ := field.Options.(*schema.DateOptions) - - if !options.Min.IsZero() { - if err := validation.Min(options.Min.Time()).Validate(val.Time()); err != nil { - return err - } - } - - if !options.Max.IsZero() { - if err := validation.Max(options.Max.Time()).Validate(val.Time()); err != nil { - return err - } - } - - return nil -} - -func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField, value any) error { - normalizedVal := list.ToUniqueStringSlice(value) - if len(normalizedVal) == 0 { - if field.Required { - return requiredErr - } - return nil // nothing to check - } - - options, _ := field.Options.(*schema.SelectOptions) - - // check max selected items - if len(normalizedVal) > options.MaxSelect { - return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect)) - } - - // check against the allowed values - for _, val := range normalizedVal { - if !list.ExistInSlice(val, options.Values) { - return validation.NewError("validation_invalid_value", "Invalid value "+val) - } - } - - return nil -} - -func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error { - raw, _ := types.ParseJsonRaw(value) - if len(raw) == 0 { - return nil // nothing to check - } - - if is.JSON.Validate(value) != nil { - return validation.NewError("validation_invalid_json", "Must be a valid json value") - } - - return nil -} - -func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField, value any) error { - names := list.ToUniqueStringSlice(value) - if len(names) == 0 && field.Required { - return requiredErr - } - - options, _ := field.Options.(*schema.FileOptions) - - if len(names) > options.MaxSelect { - return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect)) - } - - // extract the uploaded files - files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles[field.Name])) - for _, file := range validator.uploadedFiles[field.Name] { - if list.ExistInSlice(file.Name(), names) { - files = append(files, file) - } - } - - for _, file := range files { - // check size - if err := UploadedFileSize(options.MaxSize)(file); err != nil { - return err - } - - // check type - if len(options.MimeTypes) > 0 { - if err := UploadedFileMimeType(options.MimeTypes)(file); err != nil { - return err - } - } - } - - return nil -} - -func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaField, value any) error { - ids := list.ToUniqueStringSlice(value) - if len(ids) == 0 { - if field.Required { - return requiredErr - } - return nil // nothing to check - } - - options, _ := field.Options.(*schema.RelationOptions) - - if options.MaxSelect != nil && len(ids) > *options.MaxSelect { - return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", *options.MaxSelect)) - } - - // check if the related records exist - // --- - relCollection, err := validator.dao.FindCollectionByNameOrId(options.CollectionId) - if err != nil { - return validation.NewError("validation_missing_rel_collection", "Relation connection is missing or cannot be accessed") - } - - var total int - validator.dao.RecordQuery(relCollection). - Select("count(*)"). - AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)). - Row(&total) - if total != len(ids) { - return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids") - } - // --- - - return nil -} diff --git a/forms/validators/record_data_test.go b/forms/validators/record_data_test.go deleted file mode 100644 index 778dceebdab3bbea8a0c958fd6adc1db4b3b7797..0000000000000000000000000000000000000000 --- a/forms/validators/record_data_test.go +++ /dev/null @@ -1,1339 +0,0 @@ -package validators_test - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/forms/validators" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/rest" - "github.com/pocketbase/pocketbase/tools/types" -) - -type testDataFieldScenario struct { - name string - data map[string]any - files map[string][]*rest.UploadedFile - expectedErrors []string -} - -func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, _ := app.Dao().FindCollectionByNameOrId("demo2") - record := models.NewRecord(collection) - validator := validators.NewRecordDataValidator(app.Dao(), record, nil) - - emptyErr := validator.Validate(map[string]any{}) - if emptyErr == nil { - t.Fatal("Expected error for empty data, got nil") - } - - unknownErr := validator.Validate(map[string]any{"unknown": 123}) - if unknownErr == nil { - t.Fatal("Expected error for unknown data, got nil") - } -} - -func TestRecordDataValidatorValidateText(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - min := 3 - max := 10 - pattern := `^\w+$` - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeText, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeText, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeText, - Options: &schema.TextOptions{ - Min: &min, - Max: &max, - Pattern: pattern, - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", "test") - dummy.Set("field2", "test") - dummy.Set("field3", "test") - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(text) check required constraint", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(text) check unique constraint", - map[string]any{ - "field1": "test", - "field2": "test", - "field3": "test", - }, - nil, - []string{"field3"}, - }, - { - "(text) check min constraint", - map[string]any{ - "field1": "test", - "field2": "test", - "field3": strings.Repeat("a", min-1), - }, - nil, - []string{"field3"}, - }, - { - "(text) check max constraint", - map[string]any{ - "field1": "test", - "field2": "test", - "field3": strings.Repeat("a", max+1), - }, - nil, - []string{"field3"}, - }, - { - "(text) check pattern constraint", - map[string]any{ - "field1": nil, - "field2": "test", - "field3": "test!", - }, - nil, - []string{"field3"}, - }, - { - "(text) valid data (only required)", - map[string]any{ - "field2": "test", - }, - nil, - []string{}, - }, - { - "(text) valid data (all)", - map[string]any{ - "field1": "test", - "field2": 12345, // test value cast - "field3": "test2", - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateNumber(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - min := 2.0 - max := 150.0 - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeNumber, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeNumber, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeNumber, - Options: &schema.NumberOptions{ - Min: &min, - Max: &max, - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", 123) - dummy.Set("field2", 123) - dummy.Set("field3", 123) - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(number) check required constraint", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(number) check required constraint + casting", - map[string]any{ - "field1": "invalid", - "field2": "invalid", - "field3": "invalid", - }, - nil, - []string{"field2"}, - }, - { - "(number) check unique constraint", - map[string]any{ - "field1": 123, - "field2": 123, - "field3": 123, - }, - nil, - []string{"field3"}, - }, - { - "(number) check min constraint", - map[string]any{ - "field1": 0.5, - "field2": 1, - "field3": min - 0.5, - }, - nil, - []string{"field3"}, - }, - { - "(number) check min with zero-default", - map[string]any{ - "field2": 1, - "field3": 0, - }, - nil, - []string{}, - }, - { - "(number) check max constraint", - map[string]any{ - "field1": nil, - "field2": max, - "field3": max + 0.5, - }, - nil, - []string{"field3"}, - }, - { - "(number) valid data (only required)", - map[string]any{ - "field2": 1, - }, - nil, - []string{}, - }, - { - "(number) valid data (all)", - map[string]any{ - "field1": nil, - "field2": 123, // test value cast - "field3": max, - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateBool(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeBool, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeBool, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeBool, - Options: &schema.BoolOptions{}, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", false) - dummy.Set("field2", true) - dummy.Set("field3", true) - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(bool) check required constraint", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(bool) check required constraint + casting", - map[string]any{ - "field1": "invalid", - "field2": "invalid", - "field3": "invalid", - }, - nil, - []string{"field2"}, - }, - { - "(bool) check unique constraint", - map[string]any{ - "field1": true, - "field2": true, - "field3": true, - }, - nil, - []string{"field3"}, - }, - { - "(bool) valid data (only required)", - map[string]any{ - "field2": 1, - }, - nil, - []string{}, - }, - { - "(bool) valid data (all)", - map[string]any{ - "field1": false, - "field2": true, - "field3": false, - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateEmail(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeEmail, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeEmail, - Options: &schema.EmailOptions{ - ExceptDomains: []string{"example.com"}, - }, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeEmail, - Options: &schema.EmailOptions{ - OnlyDomains: []string{"example.com"}, - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", "test@demo.com") - dummy.Set("field2", "test@test.com") - dummy.Set("field3", "test@example.com") - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(email) check required constraint", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(email) check email format validator", - map[string]any{ - "field1": "test", - "field2": "test.com", - "field3": 123, - }, - nil, - []string{"field1", "field2", "field3"}, - }, - { - "(email) check unique constraint", - map[string]any{ - "field1": "test@example.com", - "field2": "test@test.com", - "field3": "test@example.com", - }, - nil, - []string{"field3"}, - }, - { - "(email) check ExceptDomains constraint", - map[string]any{ - "field1": "test@example.com", - "field2": "test@example.com", - "field3": "test2@example.com", - }, - nil, - []string{"field2"}, - }, - { - "(email) check OnlyDomains constraint", - map[string]any{ - "field1": "test@test.com", - "field2": "test@test.com", - "field3": "test@test.com", - }, - nil, - []string{"field3"}, - }, - { - "(email) valid data (only required)", - map[string]any{ - "field2": "test@test.com", - }, - nil, - []string{}, - }, - { - "(email) valid data (all)", - map[string]any{ - "field1": "123@example.com", - "field2": "test@test.com", - "field3": "test2@example.com", - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateUrl(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeUrl, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeUrl, - Options: &schema.UrlOptions{ - ExceptDomains: []string{"example.com"}, - }, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeUrl, - Options: &schema.UrlOptions{ - OnlyDomains: []string{"example.com"}, - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", "http://demo.com") - dummy.Set("field2", "http://test.com") - dummy.Set("field3", "http://example.com") - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(url) check required constraint", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(url) check url format validator", - map[string]any{ - "field1": "/abc", - "field2": "test.com", // valid - "field3": "test@example.com", - }, - nil, - []string{"field1", "field3"}, - }, - { - "(url) check unique constraint", - map[string]any{ - "field1": "http://example.com", - "field2": "http://test.com", - "field3": "http://example.com", - }, - nil, - []string{"field3"}, - }, - { - "(url) check ExceptDomains constraint", - map[string]any{ - "field1": "http://example.com", - "field2": "http://example.com", - "field3": "https://example.com", - }, - nil, - []string{"field2"}, - }, - { - "(url) check OnlyDomains constraint", - map[string]any{ - "field1": "http://test.com/abc", - "field2": "http://test.com/abc", - "field3": "http://test.com/abc", - }, - nil, - []string{"field3"}, - }, - { - "(url) check subdomains constraint", - map[string]any{ - "field1": "http://test.test.com", - "field2": "http://test.example.com", - "field3": "http://test.example.com", - }, - nil, - []string{"field3"}, - }, - { - "(url) valid data (only required)", - map[string]any{ - "field2": "http://sub.test.com/abc", - }, - nil, - []string{}, - }, - { - "(url) valid data (all)", - map[string]any{ - "field1": "http://example.com/123", - "field2": "http://test.com/", - "field3": "http://example.com/test2", - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateDate(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - min, _ := types.ParseDateTime("2022-01-01 01:01:01.123") - max, _ := types.ParseDateTime("2030-01-01 01:01:01") - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeDate, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeDate, - Options: &schema.DateOptions{ - Min: min, - }, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeDate, - Options: &schema.DateOptions{ - Max: max, - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", "2022-01-01 01:01:01") - dummy.Set("field2", "2029-01-01 01:01:01.123") - dummy.Set("field3", "2029-01-01 01:01:01.123") - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(date) check required constraint", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(date) check required constraint + cast", - map[string]any{ - "field1": "invalid", - "field2": "invalid", - "field3": "invalid", - }, - nil, - []string{"field2"}, - }, - { - "(date) check required constraint + zero datetime", - map[string]any{ - "field1": "January 1, year 1, 00:00:00 UTC", - "field2": "0001-01-01 00:00:00", - "field3": "0001-01-01 00:00:00 +0000 UTC", - }, - nil, - []string{"field2"}, - }, - { - "(date) check unique constraint", - map[string]any{ - "field1": "2029-01-01 01:01:01.123", - "field2": "2029-01-01 01:01:01.123", - "field3": "2029-01-01 01:01:01.123", - }, - nil, - []string{"field3"}, - }, - { - "(date) check min date constraint", - map[string]any{ - "field1": "2021-01-01 01:01:01", - "field2": "2021-01-01 01:01:01", - "field3": "2021-01-01 01:01:01", - }, - nil, - []string{"field2"}, - }, - { - "(date) check max date constraint", - map[string]any{ - "field1": "2030-02-01 01:01:01", - "field2": "2030-02-01 01:01:01", - "field3": "2030-02-01 01:01:01", - }, - nil, - []string{"field3"}, - }, - { - "(date) valid data (only required)", - map[string]any{ - "field2": "2029-01-01 01:01:01", - }, - nil, - []string{}, - }, - { - "(date) valid data (all)", - map[string]any{ - "field1": "2029-01-01 01:01:01.000", - "field2": "2029-01-01 01:01:01", - "field3": "2029-01-01 01:01:01.456", - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateSelect(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeSelect, - Options: &schema.SelectOptions{ - Values: []string{"1", "a", "b", "c"}, - MaxSelect: 1, - }, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeSelect, - Options: &schema.SelectOptions{ - Values: []string{"a", "b", "c"}, - MaxSelect: 2, - }, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeSelect, - Options: &schema.SelectOptions{ - Values: []string{"a", "b", "c"}, - MaxSelect: 99, - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", "a") - dummy.Set("field2", []string{"a", "b"}) - dummy.Set("field3", []string{"a", "b", "c"}) - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(select) check required constraint", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(select) check required constraint - empty values", - map[string]any{ - "field1": "", - "field2": "", - "field3": "", - }, - nil, - []string{"field2"}, - }, - { - "(select) check required constraint - multiple select cast", - map[string]any{ - "field1": "a", - "field2": "a", - "field3": "a", - }, - nil, - []string{}, - }, - { - "(select) check unique constraint", - map[string]any{ - "field1": "a", - "field2": "b", - "field3": []string{"a", "b", "c"}, - }, - nil, - []string{"field3"}, - }, - { - "(select) check unique constraint - same elements but different order", - map[string]any{ - "field1": "a", - "field2": "b", - "field3": []string{"a", "c", "b"}, - }, - nil, - []string{}, - }, - { - "(select) check Values constraint", - map[string]any{ - "field1": 1, - "field2": "d", - "field3": 123, - }, - nil, - []string{"field2", "field3"}, - }, - { - "(select) check MaxSelect constraint", - map[string]any{ - "field1": []string{"a", "b"}, // this will be normalized to a single string value - "field2": []string{"a", "b", "c"}, - "field3": []string{"a", "b", "b", "b"}, // repeating values will be merged - }, - nil, - []string{"field2"}, - }, - { - "(select) valid data - only required fields", - map[string]any{ - "field2": []string{"a", "b"}, - }, - nil, - []string{}, - }, - { - "(select) valid data - all fields with normalizations", - map[string]any{ - "field1": "a", - "field2": []string{"a", "b", "b"}, // will be collapsed - "field3": "b", // will be normalzied to slice - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateJson(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeJson, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeJson, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeJson, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", `{"test":123}`) - dummy.Set("field2", `{"test":123}`) - dummy.Set("field3", `{"test":123}`) - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "(json) check required constraint - nil", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "(json) check required constraint - zero string", - map[string]any{ - "field1": "", - "field2": "", - "field3": "", - }, - nil, - []string{"field2"}, - }, - { - "(json) check required constraint - zero number", - map[string]any{ - "field1": 0, - "field2": 0, - "field3": 0, - }, - nil, - []string{}, - }, - { - "(json) check required constraint - zero slice", - map[string]any{ - "field1": []string{}, - "field2": []string{}, - "field3": []string{}, - }, - nil, - []string{}, - }, - { - "(json) check required constraint - zero map", - map[string]any{ - "field1": map[string]string{}, - "field2": map[string]string{}, - "field3": map[string]string{}, - }, - nil, - []string{}, - }, - { - "(json) check unique constraint", - map[string]any{ - "field1": `{"test":123}`, - "field2": `{"test":123}`, - "field3": map[string]any{"test": 123}, - }, - nil, - []string{"field3"}, - }, - { - "(json) check json text validator", - map[string]any{ - "field1": `[1, 2, 3`, - "field2": `invalid`, - "field3": `null`, // valid - }, - nil, - []string{"field1", "field2"}, - }, - { - "(json) valid data - only required fields", - map[string]any{ - "field2": `{"test":123}`, - }, - nil, - []string{}, - }, - { - "(json) valid data - all fields with normalizations", - map[string]any{ - "field1": []string{"a", "b", "c"}, - "field2": 123, - "field3": `"test"`, - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateFile(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeFile, - Options: &schema.FileOptions{ - MaxSelect: 1, - MaxSize: 3, - }, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeFile, - Options: &schema.FileOptions{ - MaxSelect: 2, - MaxSize: 10, - MimeTypes: []string{"image/jpeg", "text/plain; charset=utf-8"}, - }, - }, - &schema.SchemaField{ - Name: "field3", - Type: schema.FieldTypeFile, - Options: &schema.FileOptions{ - MaxSelect: 3, - MaxSize: 10, - MimeTypes: []string{"image/jpeg"}, - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // stub uploaded files - data, mp, err := tests.MockMultipartData(nil, "test", "test", "test", "test", "test") - if err != nil { - t.Fatal(err) - } - req := httptest.NewRequest(http.MethodPost, "/", data) - req.Header.Add("Content-Type", mp.FormDataContentType()) - testFiles, err := rest.FindUploadedFiles(req, "test") - if err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "check required constraint - nil", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "check MaxSelect constraint", - map[string]any{ - "field1": "test1", - "field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()}, - "field3": []string{"test1", "test2", "test3", "test4"}, - }, - map[string][]*rest.UploadedFile{ - "field2": {testFiles[0], testFiles[3]}, - }, - []string{"field2", "field3"}, - }, - { - "check MaxSize constraint", - map[string]any{ - "field1": testFiles[0].Name(), - "field2": []string{"test1", testFiles[0].Name()}, - "field3": []string{"test1", "test2", "test3"}, - }, - map[string][]*rest.UploadedFile{ - "field1": {testFiles[0]}, - "field2": {testFiles[0]}, - }, - []string{"field1"}, - }, - { - "check MimeTypes constraint", - map[string]any{ - "field1": "test1", - "field2": []string{"test1", testFiles[0].Name()}, - "field3": []string{testFiles[1].Name(), testFiles[2].Name()}, - }, - map[string][]*rest.UploadedFile{ - "field2": {testFiles[0], testFiles[1], testFiles[2]}, - "field3": {testFiles[1], testFiles[2]}, - }, - []string{"field3"}, - }, - { - "valid data - no new files (just file ids)", - map[string]any{ - "field1": "test1", - "field2": []string{"test1", "test2"}, - "field3": []string{"test1", "test2", "test3"}, - }, - nil, - []string{}, - }, - { - "valid data - just new files", - map[string]any{ - "field1": nil, - "field2": []string{testFiles[0].Name(), testFiles[1].Name()}, - "field3": nil, - }, - map[string][]*rest.UploadedFile{ - "field2": {testFiles[0], testFiles[1]}, - }, - []string{}, - }, - { - "valid data - mixed existing and new files", - map[string]any{ - "field1": "test1", - "field2": []string{"test1", testFiles[0].Name()}, - "field3": "test1", // will be casted - }, - map[string][]*rest.UploadedFile{ - "field2": {testFiles[0], testFiles[1], testFiles[2]}, - }, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func TestRecordDataValidatorValidateRelation(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - demo, _ := app.Dao().FindCollectionByNameOrId("demo3") - - // demo3 rel ids - relId1 := "mk5fmymtx4wsprk" - relId2 := "7nwo8tuiatetxdm" - relId3 := "lcl9d87w22ml6jy" - relId4 := "1tmknxy2868d869" - - // record rel ids from different collections - diffRelId1 := "0yxhwia2amd8gec" - diffRelId2 := "llvuca81nly1qls" - - // create new test collection - collection := &models.Collection{} - collection.Name = "validate_test" - collection.Schema = schema.NewSchema( - &schema.SchemaField{ - Name: "field1", - Type: schema.FieldTypeRelation, - Options: &schema.RelationOptions{ - MaxSelect: types.Pointer(1), - CollectionId: demo.Id, - }, - }, - &schema.SchemaField{ - Name: "field2", - Required: true, - Type: schema.FieldTypeRelation, - Options: &schema.RelationOptions{ - MaxSelect: types.Pointer(2), - CollectionId: demo.Id, - }, - }, - &schema.SchemaField{ - Name: "field3", - Unique: true, - Type: schema.FieldTypeRelation, - Options: &schema.RelationOptions{ - CollectionId: demo.Id, - }, - }, - &schema.SchemaField{ - Name: "field4", - Type: schema.FieldTypeRelation, - Options: &schema.RelationOptions{ - MaxSelect: types.Pointer(3), - CollectionId: "", // missing or non-existing collection id - }, - }, - ) - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatal(err) - } - - // create dummy record (used for the unique check) - dummy := models.NewRecord(collection) - dummy.Set("field1", relId1) - dummy.Set("field2", []string{relId1, relId2}) - dummy.Set("field3", []string{relId1, relId2, relId3}) - if err := app.Dao().SaveRecord(dummy); err != nil { - t.Fatal(err) - } - - scenarios := []testDataFieldScenario{ - { - "check required constraint - nil", - map[string]any{ - "field1": nil, - "field2": nil, - "field3": nil, - }, - nil, - []string{"field2"}, - }, - { - "check required constraint - zero id", - map[string]any{ - "field1": "", - "field2": "", - "field3": "", - }, - nil, - []string{"field2"}, - }, - { - "check unique constraint", - map[string]any{ - "field1": relId1, - "field2": relId2, - "field3": []string{relId1, relId2, relId3, relId3}, // repeating values are collapsed - }, - nil, - []string{"field3"}, - }, - { - "check nonexisting collection id", - map[string]any{ - "field2": relId1, - "field4": relId1, - }, - nil, - []string{"field4"}, - }, - { - "check MaxSelect constraint", - map[string]any{ - "field1": []string{relId1, relId2}, // will be normalized to relId1 only - "field2": []string{relId1, relId2, relId3}, - "field3": []string{relId1, relId2, relId3, relId4}, - }, - nil, - []string{"field2"}, - }, - { - "check with ids from different collections", - map[string]any{ - "field1": diffRelId1, - "field2": []string{relId2, diffRelId1}, - "field3": []string{diffRelId1, diffRelId2}, - }, - nil, - []string{"field1", "field2", "field3"}, - }, - { - "valid data - only required fields", - map[string]any{ - "field2": []string{relId1, relId2}, - }, - nil, - []string{}, - }, - { - "valid data - all fields with normalization", - map[string]any{ - "field1": []string{relId1, relId2}, - "field2": relId2, - "field3": []string{relId3, relId2, relId1}, // unique is not triggered because the order is different - }, - nil, - []string{}, - }, - } - - checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) -} - -func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) { - for i, s := range scenarios { - validator := validators.NewRecordDataValidator(dao, record, s.files) - result := validator.Validate(s.data) - - prefix := fmt.Sprintf("%d", i) - if s.name != "" { - prefix = s.name - } - - // parse errors - errs, ok := result.(validation.Errors) - if !ok && result != nil { - t.Errorf("[%s] Failed to parse errors %v", prefix, result) - continue - } - - // check errors - if len(errs) > len(s.expectedErrors) { - t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs) - } - for _, k := range s.expectedErrors { - if _, ok := errs[k]; !ok { - t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs) - } - } - } -} diff --git a/forms/validators/string.go b/forms/validators/string.go deleted file mode 100644 index 10f5202a9de89de67329377224437f32010a1d28..0000000000000000000000000000000000000000 --- a/forms/validators/string.go +++ /dev/null @@ -1,21 +0,0 @@ -package validators - -import ( - validation "github.com/go-ozzo/ozzo-validation/v4" -) - -// Compare checks whether the validated value matches another string. -// -// Example: -// validation.Field(&form.PasswordConfirm, validation.By(validators.Compare(form.Password))) -func Compare(valueToCompare string) validation.RuleFunc { - return func(value any) error { - v, _ := value.(string) - - if v != valueToCompare { - return validation.NewError("validation_values_mismatch", "Values don't match.") - } - - return nil - } -} diff --git a/forms/validators/string_test.go b/forms/validators/string_test.go deleted file mode 100644 index e9a0c6a0020cf85120331133fa114882aefb4706..0000000000000000000000000000000000000000 --- a/forms/validators/string_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package validators_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/forms/validators" -) - -func TestCompare(t *testing.T) { - scenarios := []struct { - valA string - valB string - expectError bool - }{ - {"", "", false}, - {"", "456", true}, - {"123", "", true}, - {"123", "456", true}, - {"123", "123", false}, - } - - for i, s := range scenarios { - err := validators.Compare(s.valA)(s.valB) - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) - } - } -} diff --git a/forms/validators/validators.go b/forms/validators/validators.go deleted file mode 100644 index ec8c21777816944e4ca8ee1d75af8978aadfb3a1..0000000000000000000000000000000000000000 --- a/forms/validators/validators.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package validators implements custom shared PocketBase validators. -package validators diff --git a/go.mod b/go.mod index 6d59394a41dfa4c5e36c8caf918fcce9ddedb96f..a175778dbfe2beebede9f2eab4b307dc294c5676 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,18 @@ -module github.com/pocketbase/pocketbase +module just/registry -go 1.18 +go 1.19 require ( - github.com/AlecAivazis/survey/v2 v2.3.6 - github.com/aws/aws-sdk-go v1.44.153 - github.com/disintegration/imaging v1.6.2 - github.com/domodwyer/mailyak/v3 v3.3.4 - github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86 - github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6 - github.com/fatih/color v1.13.0 - github.com/gabriel-vasile/mimetype v1.4.1 - github.com/ganigeorgiev/fexpr v0.1.1 - github.com/go-ozzo/ozzo-validation/v4 v4.3.0 - github.com/golang-jwt/jwt/v4 v4.4.3 github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198 - github.com/mattn/go-sqlite3 v1.14.16 github.com/pocketbase/dbx v1.8.0 - github.com/spf13/cast v1.5.0 - github.com/spf13/cobra v1.6.1 - gocloud.dev v0.27.0 - golang.org/x/crypto v0.3.0 - golang.org/x/exp v0.0.0-20221207211629-99ab8fa1c11f - golang.org/x/net v0.3.0 - golang.org/x/oauth2 v0.2.0 - modernc.org/sqlite v1.20.0 + github.com/pocketbase/pocketbase v0.9.0 + golang.org/x/exp v0.0.0-20221208044002-44028be4359e ) require ( + github.com/AlecAivazis/survey/v2 v2.3.6 // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect + github.com/aws/aws-sdk-go v1.44.153 // indirect github.com/aws/aws-sdk-go-v2 v1.17.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/config v1.18.4 // indirect @@ -48,8 +32,13 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.17.6 // indirect github.com/aws/smithy-go v1.13.5 // indirect - github.com/dlclark/regexp2 v1.7.0 // indirect - github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/domodwyer/mailyak/v3 v3.3.4 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.1 // indirect + github.com/ganigeorgiev/fexpr v0.1.1 // indirect + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect + github.com/golang-jwt/jwt/v4 v4.4.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/uuid v1.3.0 // indirect @@ -60,14 +49,21 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/cobra v1.6.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect go.opencensus.io v0.24.0 // indirect + gocloud.dev v0.27.0 // indirect + golang.org/x/crypto v0.3.0 // indirect golang.org/x/image v0.2.0 // indirect golang.org/x/mod v0.7.0 // indirect + golang.org/x/net v0.3.0 // indirect + golang.org/x/oauth2 v0.2.0 // indirect golang.org/x/sys v0.3.0 // indirect golang.org/x/term v0.3.0 // indirect golang.org/x/text v0.5.0 // indirect @@ -86,6 +82,7 @@ require ( modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect modernc.org/opt v0.1.3 // indirect + modernc.org/sqlite v1.20.0 // indirect modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index a737d999d3ea691c8d05dbfd0be8521dcd7fb9b8..ff3a058fadd1d7d89848eeda4ae90ead78c8b7bd 100644 --- a/go.sum +++ b/go.sum @@ -480,9 +480,6 @@ github.com/digitalocean/godo v1.81.0/go.mod h1:BPCqvwbjbGqxuUnIKB4EvS/AX7IDnNmt5 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= -github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= @@ -506,14 +503,6 @@ github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/domodwyer/mailyak/v3 v3.3.4 h1:AG/pvcz2/ocFqZkPEG7lPAa0MhCq1warfUEKJt6Fagk= github.com/domodwyer/mailyak/v3 v3.3.4/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= -github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= -github.com/dop251/goja v0.0.0-20220815083517-0c74f9139fd6/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs= -github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86 h1:E2wycakfddWJ26v+ZyEY91Lb/HEZyaiZhbMX+KQcdmc= -github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs= -github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= -github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= -github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6 h1:p3QZwRRfCN7Qr3GNBTMKBkLFjEm3DHR4MaJABvsiqgk= -github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6/go.mod h1:+CJy9V5cGycP5qwp6RM5jLg+TFEMyGtD7A9xUbU/BOQ= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -633,8 +622,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= -github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= -github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= @@ -1222,6 +1209,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.8.0 h1:kjf3mgmmE12t8IG48kJOeIyBmRi0A1sl6Hsezv4PoiA= github.com/pocketbase/dbx v1.8.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= +github.com/pocketbase/pocketbase v0.9.0 h1:fhZDteVmGrYntKqMzhqKyKGgV/xWm8xGEd/j8qq3kB8= +github.com/pocketbase/pocketbase v0.9.0/go.mod h1:vIANdlbf00nAlGkYQ4a+tejKFSi72bE+H4ggO9VXWFw= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= @@ -1573,8 +1562,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20221207211629-99ab8fa1c11f h1:90Jq/vvGVDsqj8QqCynjFw9MCerDguSMODLYII416Y8= -golang.org/x/exp v0.0.0-20221207211629-99ab8fa1c11f/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20221208044002-44028be4359e h1:lTjJJUAuWTLRn0pXoNLiVZIFYOIpvmg3MxmZxgO09bM= +golang.org/x/exp v0.0.0-20221208044002-44028be4359e/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= diff --git a/helpers/helpers.go b/helpers/helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..f249a2de86f71dd3d7f17d489334bc7d878f8286 --- /dev/null +++ b/helpers/helpers.go @@ -0,0 +1,18 @@ +package helpers + +import ( + "os" + "path/filepath" + "strings" +) + +func InspectRuntime() (baseDir string, withGoRun bool) { + if strings.HasPrefix(os.Args[0], os.TempDir()) { + withGoRun = true + baseDir, _ = os.Getwd() + } else { + withGoRun = false + baseDir = filepath.Dir(os.Args[0]) + } + return +} \ No newline at end of file diff --git a/mails/admin.go b/mails/admin.go deleted file mode 100644 index 142f8c4d0b3614f2f6cdc4bff54d585a79c17602..0000000000000000000000000000000000000000 --- a/mails/admin.go +++ /dev/null @@ -1,79 +0,0 @@ -package mails - -import ( - "fmt" - "net/mail" - - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/mails/templates" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tokens" - "github.com/pocketbase/pocketbase/tools/mailer" - "github.com/pocketbase/pocketbase/tools/rest" -) - -// SendAdminPasswordReset sends a password reset request email to the specified admin. -func SendAdminPasswordReset(app core.App, admin *models.Admin) error { - token, tokenErr := tokens.NewAdminResetPasswordToken(app, admin) - if tokenErr != nil { - return tokenErr - } - - actionUrl, urlErr := rest.NormalizeUrl(fmt.Sprintf( - "%s/_/#/confirm-password-reset/%s", - app.Settings().Meta.AppUrl, - token, - )) - if urlErr != nil { - return urlErr - } - - params := struct { - AppName string - AppUrl string - Admin *models.Admin - Token string - ActionUrl string - }{ - AppName: app.Settings().Meta.AppName, - AppUrl: app.Settings().Meta.AppUrl, - Admin: admin, - Token: token, - ActionUrl: actionUrl, - } - - mailClient := app.NewMailClient() - - // resolve body template - body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody) - if renderErr != nil { - return renderErr - } - - message := &mailer.Message{ - From: mail.Address{ - Name: app.Settings().Meta.SenderName, - Address: app.Settings().Meta.SenderAddress, - }, - To: mail.Address{Address: admin.Email}, - Subject: "Reset admin password", - HTML: body, - } - - event := &core.MailerAdminEvent{ - MailClient: mailClient, - Message: message, - Admin: admin, - Meta: map[string]any{"token": token}, - } - - sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error { - return e.MailClient.Send(e.Message) - }) - - if sendErr == nil { - app.OnMailerAfterAdminResetPasswordSend().Trigger(event) - } - - return sendErr -} diff --git a/mails/admin_test.go b/mails/admin_test.go deleted file mode 100644 index 32bb6fe7b54f3a99ba7234bf960cd32b25d83149..0000000000000000000000000000000000000000 --- a/mails/admin_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package mails_test - -import ( - "strings" - "testing" - - "github.com/pocketbase/pocketbase/mails" - "github.com/pocketbase/pocketbase/tests" -) - -func TestSendAdminPasswordReset(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - // ensure that action url normalization will be applied - testApp.Settings().Meta.AppUrl = "http://localhost:8090////" - - admin, _ := testApp.Dao().FindAdminByEmail("test@example.com") - - err := mails.SendAdminPasswordReset(testApp, admin) - if err != nil { - t.Fatal(err) - } - - if testApp.TestMailer.TotalSend != 1 { - t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend) - } - - expectedParts := []string{ - "http://localhost:8090/_/#/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", - } - for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) - } - } -} diff --git a/mails/base.go b/mails/base.go deleted file mode 100644 index 91a0ab7a3963b7b35f6bb4d4ff5e679c3764ad16..0000000000000000000000000000000000000000 --- a/mails/base.go +++ /dev/null @@ -1,33 +0,0 @@ -// Package mails implements various helper methods for sending user and admin -// emails like forgotten password, verification, etc. -package mails - -import ( - "bytes" - "text/template" -) - -// resolveTemplateContent resolves inline html template strings. -func resolveTemplateContent(data any, content ...string) (string, error) { - if len(content) == 0 { - return "", nil - } - - t := template.New("inline_template") - - var parseErr error - for _, v := range content { - t, parseErr = t.Parse(v) - if parseErr != nil { - return "", parseErr - } - } - - var wr bytes.Buffer - - if executeErr := t.Execute(&wr, data); executeErr != nil { - return "", executeErr - } - - return wr.String(), nil -} diff --git a/mails/record.go b/mails/record.go deleted file mode 100644 index a15ecbc6deb8892db7f15c5ccd20f7caefcad0ac..0000000000000000000000000000000000000000 --- a/mails/record.go +++ /dev/null @@ -1,167 +0,0 @@ -package mails - -import ( - "html/template" - "net/mail" - - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/mails/templates" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/settings" - "github.com/pocketbase/pocketbase/tokens" - "github.com/pocketbase/pocketbase/tools/mailer" -) - -// SendRecordPasswordReset sends a password reset request email to the specified user. -func SendRecordPasswordReset(app core.App, authRecord *models.Record) error { - token, tokenErr := tokens.NewRecordResetPasswordToken(app, authRecord) - if tokenErr != nil { - return tokenErr - } - - mailClient := app.NewMailClient() - - subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ResetPasswordTemplate) - if err != nil { - return err - } - - message := &mailer.Message{ - From: mail.Address{ - Name: app.Settings().Meta.SenderName, - Address: app.Settings().Meta.SenderAddress, - }, - To: mail.Address{Address: authRecord.Email()}, - Subject: subject, - HTML: body, - } - - event := &core.MailerRecordEvent{ - MailClient: mailClient, - Message: message, - Record: authRecord, - Meta: map[string]any{"token": token}, - } - - sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error { - return e.MailClient.Send(e.Message) - }) - - if sendErr == nil { - app.OnMailerAfterRecordResetPasswordSend().Trigger(event) - } - - return sendErr -} - -// SendRecordVerification sends a verification request email to the specified user. -func SendRecordVerification(app core.App, authRecord *models.Record) error { - token, tokenErr := tokens.NewRecordVerifyToken(app, authRecord) - if tokenErr != nil { - return tokenErr - } - - mailClient := app.NewMailClient() - - subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.VerificationTemplate) - if err != nil { - return err - } - - message := &mailer.Message{ - From: mail.Address{ - Name: app.Settings().Meta.SenderName, - Address: app.Settings().Meta.SenderAddress, - }, - To: mail.Address{Address: authRecord.Email()}, - Subject: subject, - HTML: body, - } - - event := &core.MailerRecordEvent{ - MailClient: mailClient, - Message: message, - Record: authRecord, - Meta: map[string]any{"token": token}, - } - - sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error { - return e.MailClient.Send(e.Message) - }) - - if sendErr == nil { - app.OnMailerAfterRecordVerificationSend().Trigger(event) - } - - return sendErr -} - -// SendUserChangeEmail sends a change email confirmation email to the specified user. -func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string) error { - token, tokenErr := tokens.NewRecordChangeEmailToken(app, record, newEmail) - if tokenErr != nil { - return tokenErr - } - - mailClient := app.NewMailClient() - - subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ConfirmEmailChangeTemplate) - if err != nil { - return err - } - - message := &mailer.Message{ - From: mail.Address{ - Name: app.Settings().Meta.SenderName, - Address: app.Settings().Meta.SenderAddress, - }, - To: mail.Address{Address: newEmail}, - Subject: subject, - HTML: body, - } - - event := &core.MailerRecordEvent{ - MailClient: mailClient, - Message: message, - Record: record, - Meta: map[string]any{ - "token": token, - "newEmail": newEmail, - }, - } - - sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error { - return e.MailClient.Send(e.Message) - }) - - if sendErr == nil { - app.OnMailerAfterRecordChangeEmailSend().Trigger(event) - } - - return sendErr -} - -func resolveEmailTemplate( - app core.App, - token string, - emailTemplate settings.EmailTemplate, -) (subject string, body string, err error) { - subject, rawBody, _ := emailTemplate.Resolve( - app.Settings().Meta.AppName, - app.Settings().Meta.AppUrl, - token, - ) - - params := struct { - HtmlContent template.HTML - }{ - HtmlContent: template.HTML(rawBody), - } - - body, err = resolveTemplateContent(params, templates.Layout, templates.HtmlBody) - if err != nil { - return "", "", err - } - - return subject, body, nil -} diff --git a/mails/record_test.go b/mails/record_test.go deleted file mode 100644 index f885d1f2365b42903fa10bb82f71693e02540cf2..0000000000000000000000000000000000000000 --- a/mails/record_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package mails_test - -import ( - "strings" - "testing" - - "github.com/pocketbase/pocketbase/mails" - "github.com/pocketbase/pocketbase/tests" -) - -func TestSendRecordPasswordReset(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - // ensure that action url normalization will be applied - testApp.Settings().Meta.AppUrl = "http://localhost:8090////" - - user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") - - err := mails.SendRecordPasswordReset(testApp, user) - if err != nil { - t.Fatal(err) - } - - if testApp.TestMailer.TotalSend != 1 { - t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend) - } - - expectedParts := []string{ - "http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", - } - for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) - } - } -} - -func TestSendRecordVerification(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") - - err := mails.SendRecordVerification(testApp, user) - if err != nil { - t.Fatal(err) - } - - if testApp.TestMailer.TotalSend != 1 { - t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend) - } - - expectedParts := []string{ - "http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", - } - for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) - } - } -} - -func TestSendRecordChangeEmail(t *testing.T) { - testApp, _ := tests.NewTestApp() - defer testApp.Cleanup() - - user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com") - - err := mails.SendRecordChangeEmail(testApp, user, "new_test@example.com") - if err != nil { - t.Fatal(err) - } - - if testApp.TestMailer.TotalSend != 1 { - t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend) - } - - expectedParts := []string{ - "http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", - } - for _, part := range expectedParts { - if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) { - t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML) - } - } -} diff --git a/mails/templates/admin_password_reset.go b/mails/templates/admin_password_reset.go deleted file mode 100644 index f6207c3fe181b886cda1b684fc77e6eb2ec4563a..0000000000000000000000000000000000000000 --- a/mails/templates/admin_password_reset.go +++ /dev/null @@ -1,21 +0,0 @@ -package templates - -// Available variables: -// -// ``` -// Admin *models.Admin -// AppName string -// AppUrl string -// Token string -// ActionUrl string -// ``` -const AdminPasswordResetBody = ` -{{define "content"}} -
Hello,
-Follow this link to reset your admin password for {{.AppName}}.
-- Reset password -
-If you did not request to reset your password, please ignore this email and the link will expire on its own.
-{{end}} -` diff --git a/mails/templates/html_content.go b/mails/templates/html_content.go deleted file mode 100644 index cb4127514c09f0e74cd0fead3a238469f94d16a2..0000000000000000000000000000000000000000 --- a/mails/templates/html_content.go +++ /dev/null @@ -1,8 +0,0 @@ -package templates - -// Available variables: -// -// ``` -// HtmlContent template.HTML -// ``` -const HtmlBody = `{{define "content"}}{{.HtmlContent}}{{end}}` diff --git a/mails/templates/layout.go b/mails/templates/layout.go deleted file mode 100644 index 7e3375831913def82ea746c45058e2a3ac4d1293..0000000000000000000000000000000000000000 --- a/mails/templates/layout.go +++ /dev/null @@ -1,111 +0,0 @@ -package templates - -const Layout = ` - - - - - - - - -Hello,
-Thank you for joining us at ` + EmailPlaceholderAppName + `.
-Click on the button below to verify your email address.
-- Verify -
-
- Thanks,
- ` + EmailPlaceholderAppName + ` team
-
Hello,
-Click on the button below to reset your password.
-- Reset password -
-If you didn't ask to reset your password, you can ignore this email.
-
- Thanks,
- ` + EmailPlaceholderAppName + ` team
-
Hello,
-Click on the button below to confirm your new email address.
- -If you didn't ask to change your email address, you can ignore this email.
-
- Thanks,
- ` + EmailPlaceholderAppName + ` team
-
Lorem ipsum
-Dolor sit amet
-- Verify -
-
- Thanks,
- PocketBase team
-
This method is usually called by users on page/screen reload to ensure that the previously stored
- data in pb.authStore
is still valid and up-to-date.
Authorization:TOKEN
header",Q=p(),D=a("div"),D.textContent="Query parameters",W=p(),T=a("table"),G=a("thead"),G.innerHTML=`This action usually should be called right after the provider login page redirect.
-You could also check the - OAuth2 web integration example - .
`,g=p(),re(k.$$.fragment),y=p(),C=s("h6"),C.textContent="API details",N=p(),O=s("div"),L=s("strong"),L.textContent="POST",pe=p(),M=s("div"),D=s("p"),he=v("/api/collections/"),Q=s("strong"),z=v(x),be=v("/auth-with-oauth2"),K=p(),q=s("div"),q.textContent="Body Parameters",Y=p(),I=s("table"),I.innerHTML=`Optional data that will be used when creating the auth record on OAuth2 sign-up.
-The created auth record must comply with the same requirements and validations in the
- regular create action.
-
- The data can only be in json
, aka. multipart/form-data
and files
- upload currently are not supported during OAuth2 sign-ups.
Authorization:TOKEN
header",h(e,"class","txt-hint txt-sm txt-right")},m(l,s){r(l,e,s)},d(l){l&&d(e)}}}function Tt(o){let e,l,s,b,p,c,f,y,T,w,M,g,D,V,L,I,j,F,S,N,q,C,_;function O(u,$){var ee,K;return(K=(ee=u[0])==null?void 0:ee.options)!=null&&K.requireEmail?It:Vt}let z=O(o),P=z(o);return{c(){e=a("tr"),e.innerHTML='multipart/form-data
.`,I=m(),j=a("p"),j.innerHTML=`File upload is supported only via multipart/form-data
.
- Authorization:TOKEN
header",m(l,"class","txt-hint txt-sm txt-right")},m(s,a){d(s,l,a)},d(s){s&&f(l)}}}function ye(o,l){let s,a=l[6].code+"",h,i,r,u;function $(){return l[5](l[6])}return{key:o,first:null,c(){s=c("button"),h=D(a),i=k(),m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(b,g){d(b,s,g),n(s,h),n(s,i),r||(u=Se(s,"click",$),r=!0)},p(b,g){l=b,g&20&&N(s,"active",l[2]===l[6].code)},d(b){b&&f(s),r=!1,u()}}}function De(o,l){let s,a,h,i;return a=new qe({props:{content:l[6].body}}),{key:o,first:null,c(){s=c("div"),$e(a.$$.fragment),h=k(),m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(r,u){d(r,s,u),we(a,s,null),n(s,h),i=!0},p(r,u){l=r,(!i||u&20)&&N(s,"active",l[2]===l[6].code)},i(r){i||(ee(a.$$.fragment,r),i=!0)},o(r){te(a.$$.fragment,r),i=!1},d(r){r&&f(s),ge(a)}}}function Le(o){var ue,pe;let l,s,a=o[0].name+"",h,i,r,u,$,b,g,q=o[0].name+"",z,le,F,C,K,O,Q,y,H,se,L,E,oe,G,U=o[0].name+"",J,ae,V,ne,W,T,X,B,Y,I,Z,R,M,w=[],ie=new Map,re,A,v=[],ce=new Map,P;C=new He({props:{js:`
- import PocketBase from 'pocketbase';
-
- const pb = new PocketBase('${o[3]}');
-
- ...
-
- await pb.collection('${(ue=o[0])==null?void 0:ue.name}').delete('RECORD_ID');
- `,dart:`
- import 'package:pocketbase/pocketbase.dart';
-
- final pb = PocketBase('${o[3]}');
-
- ...
-
- await pb.collection('${(pe=o[0])==null?void 0:pe.name}').delete('RECORD_ID');
- `}});let _=o[1]&&ve(),j=o[4];const de=e=>e[6].code;for(let e=0;eOPERAND
- OPERATOR
- OPERAND
, where:`,n=a(),i=l("ul"),c=l("li"),c.innerHTML=`OPERAND
- could be any of the above field literal, string (single
- or double quoted), number, null, true, false`,f=a(),m=l("li"),_=l("code"),_.textContent="OPERATOR",w=k(` - is one of:
- `),b=l("br"),$=a(),h=l("ul"),M=l("li"),W=l("code"),W.textContent="=",fe=a(),T=l("span"),T.textContent="Equal",pe=a(),O=l("li"),G=l("code"),G.textContent="!=",C=a(),H=l("span"),H.textContent="NOT equal",Fe=a(),A=l("li"),E=l("code"),E.textContent=">",Ce=a(),U=l("span"),U.textContent="Greater than",X=a(),q=l("li"),Y=l("code"),Y.textContent=">=",xe=a(),j=l("span"),j.textContent="Greater than or equal",Q=a(),D=l("li"),P=l("code"),P.textContent="<",ue=a(),Z=l("span"),Z.textContent="Less than or equal",v=a(),I=l("li"),ee=l("code"),ee.textContent="<=",me=a(),te=l("span"),te.textContent="Less than or equal",N=a(),B=l("li"),le=l("code"),le.textContent="~",be=a(),se=l("span"),se.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
- wildcard match)`,x=a(),J=l("li"),ne=l("code"),ne.textContent="!~",Le=a(),K=l("span"),K.textContent=`NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
- wildcard match)`,he=a(),V=l("p"),V.innerHTML=`To group and combine several expressions you could use brackets
- (...)
, &&
(AND) and ||
(OR) tokens.`,d(_,"class","txt-danger"),d(W,"class","filter-op svelte-1w7s5nw"),d(T,"class","txt-hint"),d(G,"class","filter-op svelte-1w7s5nw"),d(H,"class","txt-hint"),d(E,"class","filter-op svelte-1w7s5nw"),d(U,"class","txt-hint"),d(Y,"class","filter-op svelte-1w7s5nw"),d(j,"class","txt-hint"),d(P,"class","filter-op svelte-1w7s5nw"),d(Z,"class","txt-hint"),d(ee,"class","filter-op svelte-1w7s5nw"),d(te,"class","txt-hint"),d(le,"class","filter-op svelte-1w7s5nw"),d(se,"class","txt-hint"),d(ne,"class","filter-op svelte-1w7s5nw"),d(K,"class","txt-hint")},m(F,R){p(F,s,R),p(F,n,R),p(F,i,R),e(i,c),e(i,f),e(i,m),e(m,_),e(m,w),e(m,b),e(m,$),e(m,h),e(h,M),e(M,W),e(M,fe),e(M,T),e(h,pe),e(h,O),e(O,G),e(O,C),e(O,H),e(h,Fe),e(h,A),e(A,E),e(A,Ce),e(A,U),e(h,X),e(h,q),e(q,Y),e(q,xe),e(q,j),e(h,Q),e(h,D),e(D,P),e(D,ue),e(D,Z),e(h,v),e(h,I),e(I,ee),e(I,me),e(I,te),e(h,N),e(h,B),e(B,le),e(B,be),e(B,se),e(h,x),e(h,J),e(J,ne),e(J,Le),e(J,K),p(F,he,R),p(F,V,R)},d(F){F&&u(s),F&&u(n),F&&u(i),F&&u(he),F&&u(V)}}}function Kt(r){let s,n,i,c,f;function m($,h){return $[0]?Jt:Qt}let _=m(r),w=_(r),b=r[0]&&Tt();return{c(){s=l("button"),w.c(),n=a(),b&&b.c(),i=qt(),d(s,"class","btn btn-sm btn-secondary m-t-5")},m($,h){p($,s,h),w.m(s,null),p($,n,h),b&&b.m($,h),p($,i,h),c||(f=Ht(s,"click",r[1]),c=!0)},p($,[h]){_!==(_=m($))&&(w.d(1),w=_($),w&&(w.c(),w.m(s,null))),$[0]?b||(b=Tt(),b.c(),b.m(i.parentNode,i)):b&&(b.d(1),b=null)},i:xt,o:xt,d($){$&&u(s),w.d(),$&&u(n),b&&b.d($),$&&u(i),c=!1,f()}}}function Vt(r,s,n){let i=!1;function c(){n(0,i=!i)}return[i,c]}class Wt extends Et{constructor(s){super(),Nt(this,s,Vt,Kt,Mt,{})}}function Pt(r,s,n){const i=r.slice();return i[6]=s[n],i}function Rt(r,s,n){const i=r.slice();return i[6]=s[n],i}function St(r){let s;return{c(){s=l("p"),s.innerHTML="Requires admin Authorization:TOKEN
header",d(s,"class","txt-hint txt-sm txt-right")},m(n,i){p(n,s,i)},d(n){n&&u(s)}}}function Ot(r,s){let n,i=s[6].code+"",c,f,m,_;function w(){return s[5](s[6])}return{key:r,first:null,c(){n=l("div"),c=k(i),f=a(),d(n,"class","tab-item"),Ee(n,"active",s[2]===s[6].code),this.first=n},m(b,$){p(b,n,$),e(n,c),e(n,f),m||(_=Ht(n,"click",w),m=!0)},p(b,$){s=b,$&20&&Ee(n,"active",s[2]===s[6].code)},d(b){b&&u(n),m=!1,_()}}}function At(r,s){let n,i,c,f;return i=new Ae({props:{content:s[6].body}}),{key:r,first:null,c(){n=l("div"),ge(i.$$.fragment),c=a(),d(n,"class","tab-item"),Ee(n,"active",s[2]===s[6].code),this.first=n},m(m,_){p(m,n,_),ye(i,n,null),e(n,c),f=!0},p(m,_){s=m,(!f||_&20)&&Ee(n,"active",s[2]===s[6].code)},i(m){f||(ce(i.$$.fragment,m),f=!0)},o(m){de(i.$$.fragment,m),f=!1},d(m){m&&u(n),ve(i)}}}function Xt(r){var mt,bt,ht,_t,$t,kt;let s,n,i=r[0].name+"",c,f,m,_,w,b,$,h=r[0].name+"",M,W,fe,T,pe,O,G,C,H,Fe,A,E,Ce,U,X=r[0].name+"",q,Y,xe,j,Q,D,P,ue,Z,v,I,ee,me,te,N,B,le,be,se,x,J,ne,Le,K,he,V,F,R,Qe,ie,Ne,Je,Me,Ke,_e,Ve,$e,We,ke,Xe,oe,He,Ye,qe,Ze,y,et,we,tt,lt,st,De,nt,Ie,it,ot,at,Be,rt,ze,Te,Ge,ae,Pe,z=[],ct=new Map,dt,Re,S=[],ft=new Map,re;T=new jt({props:{js:`
- import PocketBase from 'pocketbase';
-
- const pb = new PocketBase('${r[3]}');
-
- ...
-
- // fetch a paginated records list
- const resultList = await pb.collection('${(mt=r[0])==null?void 0:mt.name}').getList(1, 50, {
- filter: 'created >= "2022-01-01 00:00:00" && someFiled1 != someField2',
- });
-
- // you can also fetch all records at once via getFullList
- const records = await pb.collection('${(bt=r[0])==null?void 0:bt.name}').getFullList(200 /* batch size */, {
- sort: '-created',
- });
-
- // or fetch only the first record that matches the specified filter
- const record = await pb.collection('${(ht=r[0])==null?void 0:ht.name}').getFirstListItem('someField="test"', {
- expand: 'relField1,relField2.subRelField',
- });
- `,dart:`
- import 'package:pocketbase/pocketbase.dart';
-
- final pb = PocketBase('${r[3]}');
-
- ...
-
- // fetch a paginated records list
- final resultList = await pb.collection('${(_t=r[0])==null?void 0:_t.name}').getList(
- page: 1,
- perPage: 50,
- filter: 'created >= "2022-01-01 00:00:00" && someFiled1 != someField2',
- );
-
- // you can also fetch all records at once via getFullList
- final records = await pb.collection('${($t=r[0])==null?void 0:$t.name}').getFullList(
- batch: 200,
- sort: '-created',
- );
-
- // or fetch only the first record that matches the specified filter
- final record = await pb.collection('${(kt=r[0])==null?void 0:kt.name}').getFirstListItem(
- 'someField="test"',
- expand: 'relField1,relField2.subRelField',
- );
- `}});let L=r[1]&&St();R=new Ae({props:{content:`
- // DESC by created and ASC by id
- ?sort=-created,id
- `}}),$e=new Ae({props:{content:`
- ?filter=(id='abc' && created>'2022-01-01')
- `}}),ke=new Wt({}),we=new Ae({props:{content:"?expand=relField1,relField2.subRelField"}});let Oe=r[4];const pt=t=>t[6].code;for(let t=0;tEnter the email associated with your account and we\u2019ll send you a recovery link:
`,n=g(),H(l.$$.fragment),t=g(),o=_("button"),f=_("i"),m=g(),i=_("span"),i.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(f,"class","ri-mail-send-line"),p(i,"class","txt"),p(o,"type","submit"),p(o,"class","btn btn-lg btn-block"),o.disabled=c[1],F(o,"btn-loading",c[1]),p(e,"class","m-b-base")},m(r,$){k(r,e,$),d(e,s),d(e,n),L(l,e,null),d(e,t),d(e,o),d(o,f),d(o,m),d(o,i),a=!0,b||(u=E(e,"submit",I(c[3])),b=!0)},p(r,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:r}),l.$set(q),(!a||$&2)&&(o.disabled=r[1]),(!a||$&2)&&F(o,"btn-loading",r[1])},i(r){a||(w(l.$$.fragment,r),a=!0)},o(r){y(l.$$.fragment,r),a=!1},d(r){r&&v(e),S(l),b=!1,u()}}}function O(c){let e,s,n,l,t,o,f,m,i;return{c(){e=_("div"),s=_("div"),s.innerHTML='',n=g(),l=_("div"),t=_("p"),o=h("Check "),f=_("strong"),m=h(c[0]),i=h(" for the recovery link."),p(s,"class","icon"),p(f,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,o),d(t,f),d(f,m),d(t,i)},p(a,b){b&1&&J(m,a[0])},i:P,o:P,d(a){a&&v(e)}}}function Q(c){let e,s,n,l,t,o,f,m;return{c(){e=_("label"),s=h("Email"),l=g(),t=_("input"),p(e,"for",n=c[5]),p(t,"type","email"),p(t,"id",o=c[5]),t.required=!0,t.autofocus=!0},m(i,a){k(i,e,a),d(e,s),k(i,l,a),k(i,t,a),R(t,c[0]),t.focus(),f||(m=E(t,"input",c[4]),f=!0)},p(i,a){a&32&&n!==(n=i[5])&&p(e,"for",n),a&32&&o!==(o=i[5])&&p(t,"id",o),a&1&&t.value!==i[0]&&R(t,i[0])},d(i){i&&v(e),i&&v(l),i&&v(t),f=!1,m()}}}function U(c){let e,s,n,l,t,o,f,m;const i=[O,K],a=[];function b(u,r){return u[2]?0:1}return e=b(c),s=a[e]=i[e](c),{c(){s.c(),n=g(),l=_("div"),t=_("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(u,r){a[e].m(u,r),k(u,n,r),k(u,l,r),d(l,t),o=!0,f||(m=A(B.call(null,t)),f=!0)},p(u,r){let $=e;e=b(u),e===$?a[e].p(u,r):(N(),y(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(u,r):(s=a[e]=i[e](u),s.c()),w(s,1),s.m(n.parentNode,n))},i(u){o||(w(s),o=!0)},o(u){y(s),o=!1},d(u){a[e].d(u),u&&v(n),u&&v(l),f=!1,m()}}}function V(c){let e,s;return e=new z({props:{$$slots:{default:[U]},$$scope:{ctx:c}}}),{c(){H(e.$$.fragment)},m(n,l){L(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(w(e.$$.fragment,n),s=!0)},o(n){y(e.$$.fragment,n),s=!1},d(n){S(e,n)}}}function W(c,e,s){let n="",l=!1,t=!1;async function o(){if(!l){s(1,l=!0);try{await C.admins.requestPasswordReset(n),s(2,t=!0)}catch(m){C.errorResponseHandler(m)}s(1,l=!1)}}function f(){n=this.value,s(0,n)}return[n,l,t,o,f]}class Y extends M{constructor(e){super(),T(this,e,W,V,j,{})}}export{Y as default}; diff --git a/ui/dist/assets/PageRecordConfirmEmailChange.824a9bd8.js b/ui/dist/assets/PageRecordConfirmEmailChange.824a9bd8.js deleted file mode 100644 index dddb58619443e3b7314dbc3fc96ff73021bbd03f..0000000000000000000000000000000000000000 --- a/ui/dist/assets/PageRecordConfirmEmailChange.824a9bd8.js +++ /dev/null @@ -1,4 +0,0 @@ -import{S as z,i as G,s as I,F as J,c as S,m as T,t as v,a as y,d as L,C as M,E as N,g as _,k as W,n as Y,o as b,R as j,G as A,p as B,q as D,e as m,w as C,b as h,f as d,r as F,h as k,u as q,v as K,y as E,x as O,z as H}from"./index.662e825a.js";function Q(r){let e,t,l,s,n,o,c,i,a,u,g,$,p=r[3]&&R(r);return o=new D({props:{class:"form-field required",name:"password",$$slots:{default:[V,({uniqueId:f})=>({8:f}),({uniqueId:f})=>f?256:0]},$$scope:{ctx:r}}}),{c(){e=m("form"),t=m("div"),l=m("h5"),s=C(`Type your password to confirm changing your email address - `),p&&p.c(),n=h(),S(o.$$.fragment),c=h(),i=m("button"),a=m("span"),a.textContent="Confirm new email",d(t,"class","content txt-center m-b-base"),d(a,"class","txt"),d(i,"type","submit"),d(i,"class","btn btn-lg btn-block"),i.disabled=r[1],F(i,"btn-loading",r[1])},m(f,w){_(f,e,w),k(e,t),k(t,l),k(l,s),p&&p.m(l,null),k(e,n),T(o,e,null),k(e,c),k(e,i),k(i,a),u=!0,g||($=q(e,"submit",K(r[4])),g=!0)},p(f,w){f[3]?p?p.p(f,w):(p=R(f),p.c(),p.m(l,null)):p&&(p.d(1),p=null);const P={};w&769&&(P.$$scope={dirty:w,ctx:f}),o.$set(P),(!u||w&2)&&(i.disabled=f[1]),(!u||w&2)&&F(i,"btn-loading",f[1])},i(f){u||(v(o.$$.fragment,f),u=!0)},o(f){y(o.$$.fragment,f),u=!1},d(f){f&&b(e),p&&p.d(),L(o),g=!1,$()}}}function U(r){let e,t,l,s,n;return{c(){e=m("div"),e.innerHTML=`Successfully changed the user email address.
-You can now sign in with your new email address.
Successfully changed the user password.
-You can now sign in with your new password.
Invalid or expired verification token.
Successfully verified email address.
Subscribe to realtime changes via Server-Sent Events (SSE).
-Events are sent for create, update - and delete record operations (see "Event data format" section below).
`,_=a(),$=u("div"),$.innerHTML=`You could subscribe to a single record or to an entire collection.
-When you subscribe to a single record, the collection's - ViewRule will be used to determine whether the subscriber has access to receive the - event message.
-When you subscribe to an entire collection, the collection's - ListRule will be used to determine whether the subscriber has access to receive the - event message.
/api/realtime
google
, twitter
,
- github
, etc.Authorization:TOKEN
header",T(t,"class","txt-hint txt-sm txt-right")},m(l,s){a(l,t,s)},d(l){l&&o(t)}}}function kt(p){let t,l,s,b,u,d,f,k,C,v,O,H,A,F,M,N,B;return{c(){t=r("tr"),t.innerHTML='multipart/form-data
.`,N=m(),B=r("p"),B.innerHTML=`File upload is supported only via multipart/form-data
.
- Authorization:TOKEN
header",_(s,"class","txt-hint txt-sm txt-right")},m(n,a){r(n,s,a)},d(n){n&&d(s)}}}function We(i,s){let n,a=s[6].code+"",w,c,p,u;function C(){return s[5](s[6])}return{key:i,first:null,c(){n=o("button"),w=m(a),c=f(),_(n,"class","tab-item"),J(n,"active",s[2]===s[6].code),this.first=n},m(h,R){r(h,n,R),l(n,w),l(n,c),p||(u=rt(n,"click",C),p=!0)},p(h,R){s=h,R&20&&J(n,"active",s[2]===s[6].code)},d(h){h&&d(n),p=!1,u()}}}function Xe(i,s){let n,a,w,c;return a=new Ye({props:{content:s[6].body}}),{key:i,first:null,c(){n=o("div"),_e(a.$$.fragment),w=f(),_(n,"class","tab-item"),J(n,"active",s[2]===s[6].code),this.first=n},m(p,u){r(p,n,u),ke(a,n,null),l(n,w),c=!0},p(p,u){s=p,(!c||u&20)&&J(n,"active",s[2]===s[6].code)},i(p){c||(z(a.$$.fragment,p),c=!0)},o(p){G(a.$$.fragment,p),c=!1},d(p){p&&d(n),he(a)}}}function ct(i){var Ne,Ue;let s,n,a=i[0].name+"",w,c,p,u,C,h,R,N=i[0].name+"",K,ve,W,g,X,B,Y,$,U,we,j,E,ye,Z,Q=i[0].name+"",ee,$e,te,Ce,le,I,se,M,ne,x,oe,O,ie,Fe,ae,D,re,Re,de,ge,k,Oe,S,De,Pe,Te,ce,Ee,pe,Se,Be,Ie,fe,Me,ue,A,be,P,H,F=[],xe=new Map,Ae,q,y=[],He=new Map,T;g=new dt({props:{js:`
- import PocketBase from 'pocketbase';
-
- const pb = new PocketBase('${i[3]}');
-
- ...
-
- const record = await pb.collection('${(Ne=i[0])==null?void 0:Ne.name}').getOne('RECORD_ID', {
- expand: 'relField1,relField2.subRelField',
- });
- `,dart:`
- import 'package:pocketbase/pocketbase.dart';
-
- final pb = PocketBase('${i[3]}');
-
- ...
-
- final record = await pb.collection('${(Ue=i[0])==null?void 0:Ue.name}').getOne('RECORD_ID',
- 'expand': 'relField1,relField2.subRelField',
- );
- `}});let v=i[1]&&Ke();S=new Ye({props:{content:"?expand=relField1,relField2.subRelField"}});let V=i[4];const qe=e=>e[6].code;for(let e=0;e